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/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();
+ }
}
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;
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/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 5b28f2d..ebf1e12 100644
--- a/src/components/pages/HomePage.ts
+++ b/src/components/pages/HomePage.ts
@@ -1,44 +1,129 @@
import { html } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { Site } from "../../api/Site";
+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;
- public constructor(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 site!: Site;
+ 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
new file mode 100644
index 0000000..b097197
--- /dev/null
+++ b/src/models/Incident.ts
@@ -0,0 +1,35 @@
+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 constructor(
+ id: string,
+ name: string,
+ components: BaseComponent[],
+ updates: NoticeUpdate[],
+ status: NoticeStatus,
+ started: Date,
+ resolved: Date | null,
+ impact: ServiceStatus,
+ ) {
+ super(id, name, components, updates, status, started, resolved, 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..31ca61c
--- /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";
+import { ServiceStatus } from "./ServiceStatus";
+
+export class Maintenance extends Notice {
+ public constructor(
+ id: string,
+ name: string,
+ components: BaseComponent[],
+ updates: NoticeUpdate[],
+ status: NoticeStatus,
+ start: Date,
+ end: Date,
+ ) {
+ super(
+ id,
+ name,
+ components,
+ updates,
+ status,
+ start,
+ end,
+ ServiceStatus.UNDER_MAINTENANCE,
+ );
+ }
+
+ 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..2ac6148
--- /dev/null
+++ b/src/models/Notice.ts
@@ -0,0 +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();
+ }
+}
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;
+ }
+}