diff --git a/src/api/InstatusApi.ts b/src/api/InstatusApi.ts index f3373f3..d6d7afa 100644 --- a/src/api/InstatusApi.ts +++ b/src/api/InstatusApi.ts @@ -58,7 +58,8 @@ export class InstatusApi { if (!res.ok) { throw new Error(`Failed to fetch incident: ${res.status}`); } - return res.json(); + const data: { incident: Incident } = await res.json(); + return data.incident; } public async getMaintenance(id: string): Promise { @@ -66,6 +67,7 @@ export class InstatusApi { if (!res.ok) { throw new Error(`Failed to fetch maintenance: ${res.status}`); } - return res.json(); + const data: { maintenance: Maintenance } = await res.json(); + return data.maintenance; } } diff --git a/src/components/AppHeader.ts b/src/components/AppHeader.ts index 7e78564..4e61040 100644 --- a/src/components/AppHeader.ts +++ b/src/components/AppHeader.ts @@ -21,7 +21,7 @@ export class AppHeader extends Component { public links: SiteLink[] = []; public override render() { - const logoHref = this.home ? this.websiteUrl : "/"; + const logoHref = this.home ? this.websiteUrl ?? "/" : "/"; return html`
; public constructor(api: InstatusApi) { @@ -69,9 +67,14 @@ export class AppRoot extends Component { let initial = true; this.router .on("/", () => { - this.home = true; this.page = new HomePage(this.api, this.services); }) + .on("/incidents/:id", (match) => { + this.page = new NoticePage("incidents", match!.data!.id, this.api); + }) + .on("/maintenance/:id", (match) => { + this.page = new NoticePage("maintenance", match!.data!.id, this.api); + }) .on("*", () => { this.router.navigate("/"); }) @@ -101,7 +104,7 @@ export class AppRoot extends Component { .logoAlt="${this.site.name.default}" .websiteUrl="${this.site.websiteUrl}" .links="${this.site.links.header}" - .home="${this.home}" + .home="${this.page instanceof HomePage}" >
${n.name} @@ -98,7 +101,10 @@ export class ServiceDayTooltip extends Component { n.ended.getTime() > now.getTime() ? EnumMappings.NOTICE_STATUS_NAMES[n.status] : html` - Resolved after diff --git a/src/components/UpdatesFeed.ts b/src/components/UpdatesFeed.ts index b6da88a..c87f1c2 100644 --- a/src/components/UpdatesFeed.ts +++ b/src/components/UpdatesFeed.ts @@ -28,11 +28,11 @@ export class UpdatesFeed extends Component { html`
  • -
    +
    -
    +
    diff --git a/src/components/pages/HomePage.ts b/src/components/pages/HomePage.ts index 4e7165d..cb4099b 100644 --- a/src/components/pages/HomePage.ts +++ b/src/components/pages/HomePage.ts @@ -8,8 +8,6 @@ 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"; import { ActiveNotices } from "../ActiveNotices"; @@ -67,24 +65,7 @@ export class HomePage extends Page { this.notices = []; for (const i of incidents) { - const incident = new Incident( - i.id, - typeof i.name === "string" ? i.name : i.name.default, - i.components, - i.updates.map((u) => - new NoticeUpdate( - u.id, - new Date(u.started), - Incident.parseStatus(u.status), - typeof u.message === "string" ? u.message : u.message.default, - ) - ), - Incident.parseStatus(i.status), - new Date(i.started), - i.resolved === null ? null : new Date(i.resolved), - Service.parseStatus(i.impact), - ); - + const incident = Incident.fromAPI(i); this.notices.push(incident); for (const affected of i.components) { @@ -95,26 +76,7 @@ export class HomePage extends Page { } for (const m of maintenances) { - const maintenance = new Maintenance( - m.id, - typeof m.name === "string" ? m.name : m.name.default, - m.components, - m.updates.map((u) => - new NoticeUpdate( - u.id, - new Date(u.started), - Maintenance.parseStatus(u.status), - typeof u.message === "string" ? u.message : u.message.default, - ) - ), - Maintenance.parseStatus(m.status), - new Date(m.start), - new Date( - m.resolved === null - ? new Date(m.start).getTime() + (m.duration * 60000) - : m.resolved, - ), - ); + const maintenance = Maintenance.fromAPI(m); this.notices.push(maintenance); for (const affected of m.components) { @@ -126,6 +88,7 @@ export class HomePage extends Page { } public override render() { + this.pageTitle(null); return html` ${new ActiveNotices(this.notices ?? [])}
    diff --git a/src/components/pages/NoticePage.ts b/src/components/pages/NoticePage.ts new file mode 100644 index 0000000..dd69231 --- /dev/null +++ b/src/components/pages/NoticePage.ts @@ -0,0 +1,199 @@ +import { customElement, state } from "lit/decorators.js"; +import { Page } from "./Page"; +import { InstatusApi } from "../../api/InstatusApi"; +import { Notice } from "../../models/Notice"; +import { Incident } from "../../models/Incident"; +import { Maintenance } from "../../models/Maintenance"; +import { html, nothing } from "lit"; +import { ServiceStatus } from "../../models/ServiceStatus"; +import { Time } from "../../Time"; +import { UpdatesFeed } from "../UpdatesFeed"; + +@customElement("notice-page") +export class NoticePage extends Page { + private static readonly IMPACT_BADGES: Record< + ServiceStatus, + { style: string; label: string } + > = { + [ServiceStatus.OPERATIONAL]: { + style: "text-neutral-400 bg-neutral-400/10 ring-neutral-400/10", + label: "Notice", + }, + [ServiceStatus.UNDER_MAINTENANCE]: { + style: "text-blue-400 bg-blue-400/10 ring-blue-400/10", + label: "Maintenance", + }, + [ServiceStatus.DEGRADED_PERFORMANCE]: { + style: "text-amber-400 bg-amber-400/10 ring-amber-400/10", + label: "Degraded Performance", + }, + [ServiceStatus.PARTIAL_OUTAGE]: { + style: "text-orange-400 bg-orange-400/10 ring-orange-400/10", + label: "Partial Outage", + }, + [ServiceStatus.MAJOR_OUTAGE]: { + style: "text-red-400 bg-red-400/10 ring-red-400/10", + label: "Major Outage", + }, + }; + + private readonly type: "incidents" | "maintenance"; + private readonly noticeId: string; + private readonly api: InstatusApi; + + @state() + private notice?: Notice; + + public constructor( + type: "incidents" | "maintenance", + id: string, + api: InstatusApi, + ) { + super(); + this.type = type; + this.noticeId = id; + this.api = api; + } + + public override async connectedCallback() { + super.connectedCallback(); + switch (this.type) { + case "incidents": { + this.notice = Incident.fromAPI( + await this.api.getIncident(this.noticeId), + ); + break; + } + case "maintenance": { + this.notice = Maintenance.fromAPI( + await this.api.getMaintenance(this.noticeId), + ); + break; + } + } + } + + public override render() { + if (this.notice === undefined) { + return nothing; + } + + this.pageTitle(this.notice.name); + + const nbsp = "\u00A0"; + const start = new Time.DateTime(this.notice.started); + const end = this.notice.ended === null + ? null + : new Time.DateTime(this.notice.ended); + const duration = new Time.Duration(this.notice.duration()); + + const impactBadge = NoticePage.IMPACT_BADGES[this.notice.impact]; + + return html` + + + + + Back to overview + +
    + +

    ${this.notice.name}

    +
    +
    +

    + ${impactBadge.label} +

    +

    + ${this.notice instanceof Maintenance && end !== null + ? html` + Scheduled for + ${nbsp}–${nbsp} + (). + ` + : html` + Occurred on + . ${this.notice.ended === null + ? "Ongoing for" + : "Resolved after"} . + `} +

    +
    +
    +
    Affected services
    +
    +
      + ${this.notice.components.map((c) => + html` +
    • + ${c.name.default} +
    • + ` + )} +
    +
    +
    + +
    +
    +

    Updates

    + ${this.notice.ended === null || this.notice.ended > new Date() + ? html` + + ` + : nothing} +
    +
    + ${new UpdatesFeed(this.notice.updates)} +
    +
    + `; + } +} diff --git a/src/components/pages/Page.ts b/src/components/pages/Page.ts index b3002bd..8c99543 100644 --- a/src/components/pages/Page.ts +++ b/src/components/pages/Page.ts @@ -1,4 +1,5 @@ import { Component } from "../Component"; +import { CONFIG } from "../../config"; export abstract class Page extends Component { public override connectedCallback() { @@ -12,4 +13,8 @@ export abstract class Page extends Component { super.focus(); }); } + + protected pageTitle(title: string | null) { + document.title = title === null ? CONFIG.NAME : `${title} - ${CONFIG.NAME}`; + } } diff --git a/src/main.ts b/src/main.ts index b756bd3..2e357cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,8 +6,6 @@ import { InstatusApi } from "./api/InstatusApi"; injectSpeedInsights(); -document.title = CONFIG.NAME; - const api = new InstatusApi(CONFIG.ID); const root = new AppRoot(api); diff --git a/src/models/Incident.ts b/src/models/Incident.ts index b097197..53a5ae0 100644 --- a/src/models/Incident.ts +++ b/src/models/Incident.ts @@ -3,6 +3,8 @@ import { NoticeStatus } from "./NoticeStatus"; import { Notice } from "./Notice"; import { NoticeUpdate } from "./NoticeUpdate"; import { ServiceStatus } from "./ServiceStatus"; +import { Incident as IncidentAPI } from "../api/Incident"; +import { Service } from "./Service"; export class Incident extends Notice { public constructor( @@ -32,4 +34,24 @@ export class Incident extends Notice { throw new Error(`Unknown incident status: ${status}`); } } + + public static fromAPI(incident: IncidentAPI): Incident { + return new Incident( + incident.id, + typeof incident.name === "string" ? incident.name : incident.name.default, + incident.components, + incident.updates.map((u) => + new NoticeUpdate( + u.id, + new Date(u.started), + Incident.parseStatus(u.status), + typeof u.message === "string" ? u.message : u.message.default, + ) + ), + Incident.parseStatus(incident.status), + new Date(incident.started), + incident.resolved === null ? null : new Date(incident.resolved), + Service.parseStatus(incident.impact), + ); + } } diff --git a/src/models/Maintenance.ts b/src/models/Maintenance.ts index cefe64f..e0e4870 100644 --- a/src/models/Maintenance.ts +++ b/src/models/Maintenance.ts @@ -3,6 +3,7 @@ import { NoticeStatus } from "./NoticeStatus"; import { Notice } from "./Notice"; import { NoticeUpdate } from "./NoticeUpdate"; import { ServiceStatus } from "./ServiceStatus"; +import { Maintenance as MaintenanceAPI } from "../api/Maintenance"; export class Maintenance extends Notice { public override readonly ended: Date; @@ -41,4 +42,30 @@ export class Maintenance extends Notice { throw new Error(`Unknown maintenance status: ${status}`); } } + + public static fromAPI(maintenance: MaintenanceAPI): Maintenance { + return new Maintenance( + maintenance.id, + typeof maintenance.name === "string" + ? maintenance.name + : maintenance.name.default, + maintenance.components, + maintenance.updates.map((u) => + new NoticeUpdate( + u.id, + new Date(u.started), + Maintenance.parseStatus(u.status), + typeof u.message === "string" ? u.message : u.message.default, + ) + ), + Maintenance.parseStatus(maintenance.status), + new Date(maintenance.start), + new Date( + maintenance.resolved === null + ? new Date(maintenance.start).getTime() + + (maintenance.duration * 60000) + : maintenance.resolved, + ), + ); + } }