From 901b835e7ef6bd94787738f4cc85861152fc4b7e Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Fri, 13 Mar 2026 11:16:50 +0200 Subject: [PATCH 1/7] incidents API ifaces --- src/api/BaseComponent.ts | 6 ++++++ src/api/Incident.ts | 8 ++++++++ src/api/Maintenance.ts | 7 +++++++ src/api/Notice.ts | 10 ++++++++++ src/api/NoticeUpdate.ts | 7 +++++++ src/api/SiteComponent.ts | 8 +++----- 6 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/api/BaseComponent.ts create mode 100644 src/api/Incident.ts create mode 100644 src/api/Maintenance.ts create mode 100644 src/api/Notice.ts create mode 100644 src/api/NoticeUpdate.ts diff --git a/src/api/BaseComponent.ts b/src/api/BaseComponent.ts new file mode 100644 index 0000000..1f6b272 --- /dev/null +++ b/src/api/BaseComponent.ts @@ -0,0 +1,6 @@ +export interface BaseComponent { + id: string; + name: { default: string }; + status: string; + order: number; +} diff --git a/src/api/Incident.ts b/src/api/Incident.ts new file mode 100644 index 0000000..c7ee24b --- /dev/null +++ b/src/api/Incident.ts @@ -0,0 +1,8 @@ +import { Notice } from "./Notice"; + +export interface Incident extends Notice { + started: string; + resolved: string | null; + duration: number; + impact: string; +} diff --git a/src/api/Maintenance.ts b/src/api/Maintenance.ts new file mode 100644 index 0000000..caf5c49 --- /dev/null +++ b/src/api/Maintenance.ts @@ -0,0 +1,7 @@ +import { Notice } from "./Notice"; + +export interface Maintenance extends Notice { + start: string; + end: string; + duration: number; +} diff --git a/src/api/Notice.ts b/src/api/Notice.ts new file mode 100644 index 0000000..7224454 --- /dev/null +++ b/src/api/Notice.ts @@ -0,0 +1,10 @@ +import { NoticeUpdate } from "./NoticeUpdate"; +import { BaseComponent } from "./BaseComponent"; + +export interface Notice { + id: string; + name: { default: string }; + status: string; + components: BaseComponent[]; + updates: NoticeUpdate[]; +} diff --git a/src/api/NoticeUpdate.ts b/src/api/NoticeUpdate.ts new file mode 100644 index 0000000..58c5a41 --- /dev/null +++ b/src/api/NoticeUpdate.ts @@ -0,0 +1,7 @@ +export interface NoticeUpdate { + id: string; + started: string; + status: string; + message: { default: string }; + attachments: string[]; +} diff --git a/src/api/SiteComponent.ts b/src/api/SiteComponent.ts index 6a28ad6..520d59c 100644 --- a/src/api/SiteComponent.ts +++ b/src/api/SiteComponent.ts @@ -1,9 +1,7 @@ -export interface SiteComponent { - id: string; - name: { default: string }; +import { BaseComponent } from "./BaseComponent"; + +export interface SiteComponent extends BaseComponent { description: { default: string }; - status: string; - order: number; showUptime: boolean; isParent: boolean; isCollapsed: boolean; From 42157bb011ca8658f0af2ae184870811f7924fc3 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Fri, 13 Mar 2026 11:19:31 +0200 Subject: [PATCH 2/7] methods to fetch notices --- src/api/InstatusApi.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/api/InstatusApi.ts b/src/api/InstatusApi.ts index 8dcaf26..f3373f3 100644 --- a/src/api/InstatusApi.ts +++ b/src/api/InstatusApi.ts @@ -1,6 +1,8 @@ import { Site } from "./Site"; import { SiteResponse } from "./SiteResponse"; import { MetricDataPoint } from "../models/MetricDataPoint"; +import { Incident } from "./Incident"; +import { Maintenance } from "./Maintenance"; export class InstatusApi { private readonly base: URL; @@ -32,4 +34,38 @@ export class InstatusApi { const data: { data: MetricDataPoint[] } = await res.json(); return data.data; } + + public async getIncidents(): Promise { + const res = await fetch(new URL("incidents", this.base)); + if (!res.ok) { + throw new Error(`Failed to fetch incidents: ${res.status}`); + } + const data: { incidents: Incident[] } = await res.json(); + return data.incidents; + } + + public async getMaintenances(): Promise { + const res = await fetch(new URL("maintenances", this.base)); + if (!res.ok) { + throw new Error(`Failed to fetch maintenances: ${res.status}`); + } + const data: { maintenances: Maintenance[] } = await res.json(); + return data.maintenances; + } + + public async getIncident(id: string): Promise { + const res = await fetch(new URL(`incidents/${id}`, this.base)); + if (!res.ok) { + throw new Error(`Failed to fetch incident: ${res.status}`); + } + return res.json(); + } + + public async getMaintenance(id: string): Promise { + const res = await fetch(new URL(`maintenances/${id}`, this.base)); + if (!res.ok) { + throw new Error(`Failed to fetch maintenance: ${res.status}`); + } + return res.json(); + } } From 0573fdaed76e1f9ed1f6e2aedfe0de9bbbb21622 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Fri, 13 Mar 2026 11:27:58 +0200 Subject: [PATCH 3/7] notices models --- src/models/Incident.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/models/Maintenance.ts | 41 ++++++++++++++++++++++++++++++++++++ src/models/Notice.ts | 21 +++++++++++++++++++ src/models/NoticeStatus.ts | 9 ++++++++ src/models/NoticeUpdate.ts | 20 ++++++++++++++++++ 5 files changed, 134 insertions(+) create mode 100644 src/models/Incident.ts create mode 100644 src/models/Maintenance.ts create mode 100644 src/models/Notice.ts create mode 100644 src/models/NoticeStatus.ts create mode 100644 src/models/NoticeUpdate.ts diff --git a/src/models/Incident.ts b/src/models/Incident.ts new file mode 100644 index 0000000..1f4ba4a --- /dev/null +++ b/src/models/Incident.ts @@ -0,0 +1,43 @@ +import { BaseComponent } from "../api/BaseComponent"; +import { NoticeStatus } from "./NoticeStatus"; +import { Notice } from "./Notice"; +import { NoticeUpdate } from "./NoticeUpdate"; + +export class Incident extends Notice { + public readonly status: NoticeStatus; + public readonly started: Date; + public readonly resolved: Date | null; + public readonly impact: string; + + public constructor( + id: string, + name: string, + components: BaseComponent[], + updates: NoticeUpdate[], + status: NoticeStatus, + started: Date, + resolved: Date | null, + impact: string, + ) { + super(id, name, components, updates); + this.status = status; + this.started = started; + this.resolved = resolved; + this.impact = impact; + } + + public static parseStatus(status: string): NoticeStatus { + switch (status) { + case "INVESTIGATING": + return NoticeStatus.INCIDENT_INVESTIGATING; + case "IDENTIFIED": + return NoticeStatus.INCIDENT_IDENTIFIED; + case "MONITORING": + return NoticeStatus.INCIDENT_MONITORING; + case "RESOLVED": + return NoticeStatus.INCIDENT_RESOLVED; + default: + throw new Error(`Unknown incident status: ${status}`); + } + } +} diff --git a/src/models/Maintenance.ts b/src/models/Maintenance.ts new file mode 100644 index 0000000..8fa65d5 --- /dev/null +++ b/src/models/Maintenance.ts @@ -0,0 +1,41 @@ +import { BaseComponent } from "../api/BaseComponent"; +import { NoticeStatus } from "./NoticeStatus"; +import { Notice } from "./Notice"; +import { NoticeUpdate } from "./NoticeUpdate"; + +export class Maintenance extends Notice { + public readonly status: NoticeStatus; + public readonly start: Date; + public readonly end: Date; + public readonly duration: number; + + public constructor( + id: string, + name: string, + components: BaseComponent[], + updates: NoticeUpdate[], + status: NoticeStatus, + start: Date, + end: Date, + duration: number, + ) { + super(id, name, components, updates); + this.status = status; + this.start = start; + this.end = end; + this.duration = duration; + } + + public static parseStatus(status: string): NoticeStatus { + switch (status) { + case "NOTSTARTEDYET": + return NoticeStatus.MAINTENANCE_NOT_STARTED_YET; + case "INPROGRESS": + return NoticeStatus.MAINTENANCE_IN_PROGRESS; + case "COMPLETED": + return NoticeStatus.MAINTENANCE_COMPLETED; + default: + throw new Error(`Unknown maintenance status: ${status}`); + } + } +} diff --git a/src/models/Notice.ts b/src/models/Notice.ts new file mode 100644 index 0000000..42391a9 --- /dev/null +++ b/src/models/Notice.ts @@ -0,0 +1,21 @@ +import { BaseComponent } from "../api/BaseComponent"; +import { NoticeUpdate } from "./NoticeUpdate"; + +export abstract class Notice { + public readonly id: string; + public readonly name: string; + public readonly components: BaseComponent[]; + public readonly updates: NoticeUpdate[]; + + public constructor( + id: string, + name: string, + components: BaseComponent[], + updates: NoticeUpdate[], + ) { + this.id = id; + this.name = name; + this.components = components; + this.updates = updates; + } +} diff --git a/src/models/NoticeStatus.ts b/src/models/NoticeStatus.ts new file mode 100644 index 0000000..43c4c98 --- /dev/null +++ b/src/models/NoticeStatus.ts @@ -0,0 +1,9 @@ +export const enum NoticeStatus { + MAINTENANCE_NOT_STARTED_YET, + MAINTENANCE_IN_PROGRESS, + MAINTENANCE_COMPLETED, + INCIDENT_INVESTIGATING, + INCIDENT_IDENTIFIED, + INCIDENT_MONITORING, + INCIDENT_RESOLVED, +} diff --git a/src/models/NoticeUpdate.ts b/src/models/NoticeUpdate.ts new file mode 100644 index 0000000..8dfd2a4 --- /dev/null +++ b/src/models/NoticeUpdate.ts @@ -0,0 +1,20 @@ +import { NoticeStatus } from "./NoticeStatus"; + +export class NoticeUpdate { + public readonly id: string; + public readonly started: Date; + public readonly status: NoticeStatus; + public readonly message: string; + + public constructor( + id: string, + started: Date, + status: NoticeStatus, + message: string, + ) { + this.id = id; + this.started = started; + this.status = status; + this.message = message; + } +} From b9cca23eedbfac97e56ce46967b73152585bb02f Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Fri, 13 Mar 2026 11:46:27 +0200 Subject: [PATCH 4/7] add notices to services --- src/models/Service.ts | 4 ++++ src/models/ServiceGroup.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/models/Service.ts b/src/models/Service.ts index 65204ce..a8d2e8e 100644 --- a/src/models/Service.ts +++ b/src/models/Service.ts @@ -1,11 +1,13 @@ import { ServiceStatus } from "./ServiceStatus"; import { Metric } from "./Metric"; +import { Notice } from "./Notice"; export class Service { public readonly id: string; public readonly name: string; public readonly status: ServiceStatus; public readonly metrics: Metric[]; + public readonly notices: Notice[]; public readonly showUptime: boolean; public constructor( @@ -13,12 +15,14 @@ export class Service { name: string, status: ServiceStatus, metrics: Metric[], + notices: Notice[], showUptime: boolean, ) { this.id = id; this.name = name; this.status = status; this.metrics = metrics; + this.notices = notices; this.showUptime = showUptime; } diff --git a/src/models/ServiceGroup.ts b/src/models/ServiceGroup.ts index 5a736b7..7167313 100644 --- a/src/models/ServiceGroup.ts +++ b/src/models/ServiceGroup.ts @@ -1,5 +1,6 @@ import { Service } from "./Service"; import { Services } from "./Services"; +import { Notice } from "./Notice"; export class ServiceGroup extends Service { public readonly children: Service[]; @@ -9,10 +10,18 @@ export class ServiceGroup extends Service { id: string, name: string, children: Service[], + notices: Notice[], showUptime: boolean, isCollapsed: boolean, ) { - super(id, name, Services.mostSevere(children).status, [], showUptime); + super( + id, + name, + Services.mostSevere(children).status, + [], + notices, + showUptime, + ); this.children = children; this.isCollapsed = isCollapsed; } From eb70294f61f835bbb9f138455f4b1dfe5f7accb0 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Fri, 13 Mar 2026 11:49:50 +0200 Subject: [PATCH 5/7] added API to HomePage --- src/components/AppRoot.ts | 2 +- src/components/pages/HomePage.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/AppRoot.ts b/src/components/AppRoot.ts index 41cc5c8..65ae8e8 100644 --- a/src/components/AppRoot.ts +++ b/src/components/AppRoot.ts @@ -69,7 +69,7 @@ export class AppRoot extends Component { this.router .on("/", () => { this.home = true; - this.page = new HomePage(this.services); + this.page = new HomePage(this.api, this.services); }) .resolve(); } diff --git a/src/components/pages/HomePage.ts b/src/components/pages/HomePage.ts index 5b28f2d..d5aadcf 100644 --- a/src/components/pages/HomePage.ts +++ b/src/components/pages/HomePage.ts @@ -1,17 +1,18 @@ import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { Site } from "../../api/Site"; import { Page } from "./Page"; import { ServiceGroup } from "../../models/ServiceGroup"; import { ServiceRow } from "../ServiceRow"; import { ServiceGroupRow } from "../ServiceGroupRow"; import { Services } from "../../models/Services"; +import { InstatusApi } from "../../api/InstatusApi"; @customElement("home-page") export class HomePage extends Page { readonly #services: Promise; - public constructor(services: Promise) { + public constructor(api: InstatusApi, services: Promise) { super(); + this.api = api; this.#services = services; } @@ -21,7 +22,7 @@ export class HomePage extends Page { } @property({ type: Object }) - public site!: Site; + public api!: InstatusApi; @property({ type: Object }) public services!: Services; From 9d9092c4844b38295a24fc1f9971e431b74b29fd Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Fri, 13 Mar 2026 12:22:24 +0200 Subject: [PATCH 6/7] Revert "add notices to services" This reverts commit b9cca23eedbfac97e56ce46967b73152585bb02f. --- src/models/Service.ts | 4 ---- src/models/ServiceGroup.ts | 11 +---------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/models/Service.ts b/src/models/Service.ts index a8d2e8e..65204ce 100644 --- a/src/models/Service.ts +++ b/src/models/Service.ts @@ -1,13 +1,11 @@ import { ServiceStatus } from "./ServiceStatus"; import { Metric } from "./Metric"; -import { Notice } from "./Notice"; export class Service { public readonly id: string; public readonly name: string; public readonly status: ServiceStatus; public readonly metrics: Metric[]; - public readonly notices: Notice[]; public readonly showUptime: boolean; public constructor( @@ -15,14 +13,12 @@ export class Service { name: string, status: ServiceStatus, metrics: Metric[], - notices: Notice[], showUptime: boolean, ) { this.id = id; this.name = name; this.status = status; this.metrics = metrics; - this.notices = notices; this.showUptime = showUptime; } diff --git a/src/models/ServiceGroup.ts b/src/models/ServiceGroup.ts index 7167313..5a736b7 100644 --- a/src/models/ServiceGroup.ts +++ b/src/models/ServiceGroup.ts @@ -1,6 +1,5 @@ import { Service } from "./Service"; import { Services } from "./Services"; -import { Notice } from "./Notice"; export class ServiceGroup extends Service { public readonly children: Service[]; @@ -10,18 +9,10 @@ export class ServiceGroup extends Service { id: string, name: string, children: Service[], - notices: Notice[], showUptime: boolean, isCollapsed: boolean, ) { - super( - id, - name, - Services.mostSevere(children).status, - [], - notices, - showUptime, - ); + super(id, name, Services.mostSevere(children).status, [], showUptime); this.children = children; this.isCollapsed = isCollapsed; } From 0c2e9997d0ed721e446e80353413ba2d3535c698 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Fri, 13 Mar 2026 14:41:30 +0200 Subject: [PATCH 7/7] Render actual uptime and show history in bar chart --- src/components/ServiceGroupRow.ts | 30 ++++++-- src/components/ServiceRow.ts | 111 ++++++++++++++++++++++++---- src/components/pages/HomePage.ts | 118 +++++++++++++++++++++++++----- src/models/Incident.ts | 14 +--- src/models/Maintenance.ts | 22 +++--- src/models/Notice.ts | 20 +++++ 6 files changed, 256 insertions(+), 59 deletions(-) diff --git a/src/components/ServiceGroupRow.ts b/src/components/ServiceGroupRow.ts index 2028e2f..6602936 100644 --- a/src/components/ServiceGroupRow.ts +++ b/src/components/ServiceGroupRow.ts @@ -3,11 +3,32 @@ import { customElement, property } from "lit/decorators.js"; import { ServiceRow } from "./ServiceRow"; import { ServiceGroup } from "../models/ServiceGroup"; import { ServiceStatus } from "../models/ServiceStatus"; +import { Notice } from "../models/Notice"; @customElement("service-group-row") export class ServiceGroupRow extends ServiceRow { @property({ type: Object }) - public override service!: ServiceGroup; + public override service: ServiceGroup; + + private readonly rows: ServiceRow[]; + + public constructor(service: ServiceGroup, notices: Notice[]) { + super(service, notices); + this.service = service; + this.rows = this.service.children.map((child) => { + const row = new ServiceRow(child, []); + row.classList.add("block", "mt-4"); + return row; + }); + } + + public getRow(id: string): ServiceRow | null { + return this.rows.find((row) => row.service.id === id) ?? null; + } + + public override uptime(days: number): number { + return this.rows.reduce((t, r) => t + r.uptime(days), 0) / this.rows.length; + } protected override renderIcon(): TemplateResult { return html` @@ -39,12 +60,7 @@ export class ServiceGroupRow extends ServiceRow { ${this.renderBars()} ${this.renderBottom()} - ${this.service.children.map((child) => { - const row = new ServiceRow(); - row.service = child; - row.classList.add("block", "mt-4"); - return row; - })} + ${this.rows} `; } diff --git a/src/components/ServiceRow.ts b/src/components/ServiceRow.ts index 075b95e..ff20a3d 100644 --- a/src/components/ServiceRow.ts +++ b/src/components/ServiceRow.ts @@ -3,47 +3,117 @@ import { customElement, property } from "lit/decorators.js"; import { Component } from "./Component"; import { Service } from "../models/Service"; import { ServiceStatus } from "../models/ServiceStatus"; +import { Notice } from "../models/Notice"; @customElement("service-row") export class ServiceRow extends Component { - private static readonly STATUS_STYLES: Record< + protected static readonly STATUS_STYLES: Record< ServiceStatus, - { color: string; label: string; icon: string } + { color: string; bar: string; label: string; icon: string } > = { [ServiceStatus.OPERATIONAL]: { color: "text-emerald-400", + bar: "bg-emerald-400", label: "Operational", icon: ``, }, [ServiceStatus.UNDER_MAINTENANCE]: { color: "text-indigo-400", + bar: "bg-indigo-400", label: "Under Maintenance", icon: ``, }, [ServiceStatus.DEGRADED_PERFORMANCE]: { color: "text-yellow-400", + bar: "bg-yellow-400", label: "Degraded Performance", icon: ``, }, [ServiceStatus.PARTIAL_OUTAGE]: { color: "text-orange-400", + bar: "bg-orange-400", label: "Partial Outage", icon: ``, }, [ServiceStatus.MAJOR_OUTAGE]: { color: "text-red-400", + bar: "bg-red-400", label: "Major Outage", icon: ``, }, }; + private static readonly WEIGHTS: Record = { + [ServiceStatus.OPERATIONAL]: 0, + [ServiceStatus.UNDER_MAINTENANCE]: 0, + [ServiceStatus.DEGRADED_PERFORMANCE]: 0, + [ServiceStatus.PARTIAL_OUTAGE]: 0.3, + [ServiceStatus.MAJOR_OUTAGE]: 1, + }; + @property({ type: Object }) - public service!: Service; + public service: Service; + + @property({ type: Array }) + public notices: Notice[]; + + public constructor(service: Service, notices: Notice[]) { + super(); + this.service = service; + this.notices = notices; + } + + public uptime(days: number): number { + const now = Date.now(); + const periodStart = now - days * 86400000; + const totalMs = now - periodStart; + + const relevant = this.notices.filter((n) => + n.started.getTime() < now && + (n.ended === null || n.ended.getTime() > periodStart) + ); + + let downtime = 0; + + for (const notice of relevant) { + const overlapStart = Math.max(notice.started.getTime(), periodStart); + const overlapEnd = Math.min(notice.ended?.getTime() ?? now, now); + const overlapMs = overlapEnd - overlapStart; + + const higherSeverityOverlap = relevant + .filter((other) => + other.id !== notice.id && + other.impact > notice.impact + ) + .reduce((total, other) => { + const oStart = Math.max(other.started.getTime(), overlapStart); + const oEnd = Math.min(other.ended?.getTime() ?? now, overlapEnd); + return oStart < oEnd ? total + (oEnd - oStart) : total; + }, 0); + + downtime += ServiceRow.WEIGHTS[notice.impact] * + (overlapMs - higherSeverityOverlap); + } + + return 1 - downtime / totalMs; + } + + protected noticesForDay(day: Date): Notice[] { + const dayStart = new Date(day); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(day); + dayEnd.setHours(23, 59, 59, 999); + + return this.notices.filter((n) => + n.started.getTime() <= dayEnd.getTime() && + (n.ended === null || n.ended.getTime() >= dayStart.getTime()) + ); + } protected renderIcon(): TemplateResult { const style = ServiceRow.STATUS_STYLES[this.service.status]; @@ -77,16 +147,18 @@ export class ServiceRow extends Component { + ).toLocaleString(undefined, { + maximumFractionDigits: 1, + })} ms

` )} ${this.service.showUptime ? html`

100% uptime

+ : "text-neutral-300"}">${(this.uptime(90) * 100).toLocaleString( + undefined, + { minimumFractionDigits: 1, maximumFractionDigits: 2 }, + )}% uptime

` : nothing} @@ -95,15 +167,27 @@ export class ServiceRow extends Component { } protected renderBars(): TemplateResult { + const now = new Date(); + const bars = Array.from({ length: 90 }, (_, i) => { + const day = new Date(now.getTime() - (89 - i) * 86400000); + const notices = this.noticesForDay(day); + if (notices.length === 0) { + return html` +
+ `; + } + const worst = notices.reduce((w, n) => n.impact > w.impact ? n : w); + return html` +
+ `; + }); + return html`
- ${Array(90).fill(null).map(() => - html` -
- ` - )} + ${bars}
`; } @@ -134,6 +218,7 @@ export class ServiceRow extends Component { } public override render(): TemplateResult { + console.log(this.service, this.notices); this.classList.add("mt-2", "mb-6"); return html` ${this.renderTop()} ${this.renderBars()} ${this.renderBottom()} diff --git a/src/components/pages/HomePage.ts b/src/components/pages/HomePage.ts index d5aadcf..ebf1e12 100644 --- a/src/components/pages/HomePage.ts +++ b/src/components/pages/HomePage.ts @@ -1,45 +1,129 @@ import { html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import { Page } from "./Page"; import { ServiceGroup } from "../../models/ServiceGroup"; import { ServiceRow } from "../ServiceRow"; import { ServiceGroupRow } from "../ServiceGroupRow"; import { Services } from "../../models/Services"; import { InstatusApi } from "../../api/InstatusApi"; +import { Notice } from "../../models/Notice"; +import { Incident } from "../../models/Incident"; +import { Service } from "../../models/Service"; +import { NoticeUpdate } from "../../models/NoticeUpdate"; +import { Maintenance } from "../../models/Maintenance"; @customElement("home-page") export class HomePage extends Page { readonly #services: Promise; + private rows!: ServiceRow[]; + + @property({ type: Object }) + public api!: InstatusApi; + + @property({ type: Object }) + public services!: Services; + + @property({ type: Array }) + public notices!: Notice[]; + public constructor(api: InstatusApi, services: Promise) { super(); this.api = api; this.#services = services; } + public getRows(id: string): [ServiceRow] | [ServiceRow, ServiceGroupRow] { + for (const row of this.rows) { + if (row.service.id === id) { + return [row]; + } + + if (row instanceof ServiceGroupRow) { + const childRow = row.getRow(id); + if (childRow !== null) { + return [childRow, row]; + } + } + } + + throw new Error(`Could not find row for service ${id}`); + } + public override async connectedCallback() { super.connectedCallback(); - this.services = await this.#services; - } + const [services, incidents, maintenances] = await Promise.all([ + this.#services, + this.api.getIncidents(), + this.api.getMaintenances(), + ]); + this.services = services; + this.rows = this.services.items.map((service) => { + if (service instanceof ServiceGroup) { + return new ServiceGroupRow(service, []); + } + return new ServiceRow(service, []); + }); + this.notices = []; - @property({ type: Object }) - public api!: InstatusApi; + for (const i of incidents) { + const incident = new Incident( + i.id, + i.name.default, + i.components, + i.updates.map((u) => + new NoticeUpdate( + u.id, + new Date(u.started), + Incident.parseStatus(u.status), + u.message.default, + ) + ), + Incident.parseStatus(i.status), + new Date(i.started), + i.resolved === null ? null : new Date(i.resolved), + Service.parseStatus(i.impact), + ); - @property({ type: Object }) - public services!: Services; + this.notices.push(incident); + + for (const affected of i.components) { + for (const row of this.getRows(affected.id)) { + row.notices.push(incident); + } + } + } + + for (const m of maintenances) { + const maintenance = new Maintenance( + m.id, + m.name.default, + m.components, + m.updates.map((u) => + new NoticeUpdate( + u.id, + new Date(u.started), + Maintenance.parseStatus(u.status), + u.message.default, + ) + ), + Maintenance.parseStatus(m.status), + new Date(m.start), + new Date(m.end), + ); + this.notices.push(maintenance); + + for (const affected of m.components) { + for (const row of this.getRows(affected.id)) { + row.notices.push(maintenance); + } + } + } + } public override render() { return html`
- ${this.services.items.map((service) => { - if (service instanceof ServiceGroup) { - const row = new ServiceGroupRow(); - row.service = service; - return row; - } - const row = new ServiceRow(); - row.service = service; - return row; - })} + ${this.rows}
`; } diff --git a/src/models/Incident.ts b/src/models/Incident.ts index 1f4ba4a..b097197 100644 --- a/src/models/Incident.ts +++ b/src/models/Incident.ts @@ -2,13 +2,9 @@ import { BaseComponent } from "../api/BaseComponent"; import { NoticeStatus } from "./NoticeStatus"; import { Notice } from "./Notice"; import { NoticeUpdate } from "./NoticeUpdate"; +import { ServiceStatus } from "./ServiceStatus"; export class Incident extends Notice { - public readonly status: NoticeStatus; - public readonly started: Date; - public readonly resolved: Date | null; - public readonly impact: string; - public constructor( id: string, name: string, @@ -17,13 +13,9 @@ export class Incident extends Notice { status: NoticeStatus, started: Date, resolved: Date | null, - impact: string, + impact: ServiceStatus, ) { - super(id, name, components, updates); - this.status = status; - this.started = started; - this.resolved = resolved; - this.impact = impact; + super(id, name, components, updates, status, started, resolved, impact); } public static parseStatus(status: string): NoticeStatus { diff --git a/src/models/Maintenance.ts b/src/models/Maintenance.ts index 8fa65d5..31ca61c 100644 --- a/src/models/Maintenance.ts +++ b/src/models/Maintenance.ts @@ -2,13 +2,9 @@ import { BaseComponent } from "../api/BaseComponent"; import { NoticeStatus } from "./NoticeStatus"; import { Notice } from "./Notice"; import { NoticeUpdate } from "./NoticeUpdate"; +import { ServiceStatus } from "./ServiceStatus"; export class Maintenance extends Notice { - public readonly status: NoticeStatus; - public readonly start: Date; - public readonly end: Date; - public readonly duration: number; - public constructor( id: string, name: string, @@ -17,13 +13,17 @@ export class Maintenance extends Notice { status: NoticeStatus, start: Date, end: Date, - duration: number, ) { - super(id, name, components, updates); - this.status = status; - this.start = start; - this.end = end; - this.duration = duration; + super( + id, + name, + components, + updates, + status, + start, + end, + ServiceStatus.UNDER_MAINTENANCE, + ); } public static parseStatus(status: string): NoticeStatus { diff --git a/src/models/Notice.ts b/src/models/Notice.ts index 42391a9..2ac6148 100644 --- a/src/models/Notice.ts +++ b/src/models/Notice.ts @@ -1,21 +1,41 @@ import { BaseComponent } from "../api/BaseComponent"; import { NoticeUpdate } from "./NoticeUpdate"; +import { ServiceStatus } from "./ServiceStatus"; +import { NoticeStatus } from "./NoticeStatus"; export abstract class Notice { public readonly id: string; public readonly name: string; public readonly components: BaseComponent[]; public readonly updates: NoticeUpdate[]; + public readonly status: NoticeStatus; + public readonly started: Date; + public readonly ended: Date | null; + public readonly impact: ServiceStatus; public constructor( id: string, name: string, components: BaseComponent[], updates: NoticeUpdate[], + status: NoticeStatus, + started: Date, + ended: Date | null, + impact: ServiceStatus, ) { this.id = id; this.name = name; this.components = components; this.updates = updates; + this.status = status; + this.started = new Date(Math.floor(started.getTime() / 60000) * 60000); + this.ended = ended === null + ? null + : new Date(Math.ceil(ended.getTime() / 60000) * 60000); + this.impact = impact; + } + + public get duration(): number { + return (this.ended?.getTime() ?? Date.now()) - this.started.getTime(); } }