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 { + ).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; + } +}