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 {
~${metric.percentile(
50,
1,
- )
- .toFixed(
- 1,
- )} ms
+ ).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();
}
}