From 7f03be885bc57a7db899a07735f94bf23eae7d11 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Tue, 17 Mar 2026 20:35:28 +0200 Subject: [PATCH] Show active and pending notices --- src/EnumMappings.ts | 55 ++++++++++++ src/Time.ts | 134 ++++++++++++++++++++++++++++ src/api/NoticeUpdate.ts | 2 +- src/components/ActiveNotices.ts | 96 ++++++++++++++++++++ src/components/NoticeOverview.ts | 97 ++++++++++++++++++++ src/components/ServiceDayTooltip.ts | 81 +++++------------ src/components/ServiceRow.ts | 74 +++------------ src/components/UpdatesFeed.ts | 58 ++++++++++++ src/components/pages/HomePage.ts | 8 +- src/models/Maintenance.ts | 3 + 10 files changed, 485 insertions(+), 123 deletions(-) create mode 100644 src/EnumMappings.ts create mode 100644 src/Time.ts create mode 100644 src/components/ActiveNotices.ts create mode 100644 src/components/NoticeOverview.ts create mode 100644 src/components/UpdatesFeed.ts diff --git a/src/EnumMappings.ts b/src/EnumMappings.ts new file mode 100644 index 0000000..dcc28ff --- /dev/null +++ b/src/EnumMappings.ts @@ -0,0 +1,55 @@ +import { ServiceStatus } from "./models/ServiceStatus"; +import { NoticeStatus } from "./models/NoticeStatus"; + +export namespace EnumMappings { + export const SERVICE_STATUS_STYLES: Record< + ServiceStatus, + { color: string; bar: string; label: string; icon: string } + > = { + [ServiceStatus.OPERATIONAL]: { + color: "fill-emerald-400", + bar: "bg-emerald-500", + label: "Operational", + icon: + ``, + }, + [ServiceStatus.UNDER_MAINTENANCE]: { + color: "fill-blue-400", + bar: "bg-blue-400", + label: "Under maintenance", + icon: + ``, + }, + [ServiceStatus.DEGRADED_PERFORMANCE]: { + color: "fill-amber-400", + bar: "bg-amber-400", + label: "Degraded performance", + icon: + ``, + }, + [ServiceStatus.PARTIAL_OUTAGE]: { + color: "fill-orange-400", + bar: "bg-orange-400", + label: "Partial outage", + icon: + ``, + }, + [ServiceStatus.MAJOR_OUTAGE]: { + color: "fill-red-400", + bar: "bg-red-400", + label: "Major outage", + icon: + ``, + }, + }; + + export const NOTICE_STATUS_NAMES: Record = { + [NoticeStatus.INCIDENT_IDENTIFIED]: "Identified", + [NoticeStatus.INCIDENT_INVESTIGATING]: "Investigating", + [NoticeStatus.INCIDENT_MONITORING]: "Monitoring", + [NoticeStatus.INCIDENT_RESOLVED]: "Resolved", + [NoticeStatus.MAINTENANCE_NOT_STARTED_YET]: "Planned", + [NoticeStatus.MAINTENANCE_IN_PROGRESS]: "In progress", + [NoticeStatus.MAINTENANCE_COMPLETED]: "Completed", + }; +} diff --git a/src/Time.ts b/src/Time.ts new file mode 100644 index 0000000..8654637 --- /dev/null +++ b/src/Time.ts @@ -0,0 +1,134 @@ +export namespace Time { + export class Duration { + public readonly ms: number; + + public constructor(ms: number) { + this.ms = ms; + } + + private getParts() { + const totalSeconds = Math.floor(this.ms / 1000); + return { + days: Math.floor(totalSeconds / 86400), + hours: Math.floor((totalSeconds % 86400) / 3600), + minutes: Math.floor((totalSeconds % 3600) / 60), + }; + } + + public toISOString() { + const { days, hours, minutes } = this.getParts(); + + return "P" + + (days ? `${days}D` : "") + + (hours || minutes ? "T" : "") + + (hours ? `${hours}H` : "") + + (minutes ? `${minutes}M` : ""); + } + + public toString() { + const { days, hours, minutes } = this.getParts(); + + const parts = [ + days && `${days} ${days === 1 ? "day" : "days"}`, + hours && `${hours} ${hours === 1 ? "hour" : "hours"}`, + minutes && `${minutes} ${minutes === 1 ? "minute" : "minutes"}`, + ].filter(Boolean) as string[]; + + return parts.length > 1 + ? parts.slice(0, -1).join(", ") + " and " + parts.at(-1) + : parts[0] ?? "0 minutes"; + } + } + + export class Day { + public readonly date: Date; + + public constructor(date: Date) { + this.date = new Date(date.getTime()); + this.date.setHours(0, 0, 0, 0); + } + + public static today() { + return new Day(new Date()); + } + + public is(date: Date): boolean; + public is(day: Day): boolean; + public is(d: Date | Day): boolean { + return d instanceof Day + ? d.date.getTime() === this.date.getTime() + : new Day(d).date.getTime() === this.date.getTime(); + } + + public toISOString() { + const year = this.date.getFullYear(); + const month = String(this.date.getMonth() + 1).padStart(2, "0"); + const day = String(this.date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; + } + + public toString() { + return this.date.toLocaleString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }); + } + + public add(days: number) { + const newDate = new Date(this.date.getTime()); + newDate.setDate(this.date.getDate() + days); + return new Day(newDate); + } + + public subtract(days: number) { + const newDate = new Date(this.date.getTime()); + newDate.setDate(this.date.getDate() - days); + return new Day(newDate); + } + + public next() { + return this.add(1); + } + + public previous() { + return this.subtract(1); + } + + public getTime() { + return this.date.getTime(); + } + } + + export class DateTime { + public readonly date: Date; + + public constructor(date: Date) { + this.date = new Date(date.getTime()); + } + + public static now() { + return new DateTime(new Date()); + } + + public getDay() { + return new Day(this.date); + } + + public toISOString() { + return this.date.toISOString(); + } + + public toString() { + return this.getDay().toString() + " at " + this.toTimeString(); + } + + public toTimeString() { + return this.date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "numeric", + }); + } + } +} diff --git a/src/api/NoticeUpdate.ts b/src/api/NoticeUpdate.ts index 58c5a41..0936359 100644 --- a/src/api/NoticeUpdate.ts +++ b/src/api/NoticeUpdate.ts @@ -2,6 +2,6 @@ export interface NoticeUpdate { id: string; started: string; status: string; - message: { default: string }; + message: { default: string } | string; attachments: string[]; } diff --git a/src/components/ActiveNotices.ts b/src/components/ActiveNotices.ts new file mode 100644 index 0000000..c27683f --- /dev/null +++ b/src/components/ActiveNotices.ts @@ -0,0 +1,96 @@ +import { customElement, property } from "lit/decorators.js"; +import { Component } from "./Component"; +import { Notice } from "../models/Notice"; +import { Maintenance } from "../models/Maintenance"; +import { html, nothing } from "lit"; +import { EnumMappings } from "../EnumMappings"; +import { ServiceStatus } from "../models/ServiceStatus"; +import { NoticeOverview } from "./NoticeOverview"; + +@customElement("active-notices") +export class ActiveNotices extends Component { + private static readonly MAINTENANCE_COLLAPSE_DAYS = 3; + + @property({ type: Array }) + public readonly notices: Notice[]; + + public constructor(notices: Notice[]) { + super(); + this.notices = notices; + } + + public maintenances(): Maintenance[] { + const threshold = new Date( + Date.now() + ActiveNotices.MAINTENANCE_COLLAPSE_DAYS * 86400000, + ); + return this.notices.filter((n) => + n instanceof Maintenance && n.started > threshold + ) as Maintenance[]; + } + + public active(): Notice[] { + const threshold = new Date( + Date.now() + ActiveNotices.MAINTENANCE_COLLAPSE_DAYS * 86400000, + ); + return this.notices.filter((n) => + n.ended === null || (n.ended > new Date() && n.started <= threshold) + ); + } + + public override render() { + const active = this.active(); + const maintenances = this.maintenances(); + return html` + ${active.length === 0 ? nothing : html` +
    + ${active.map((n) => + html` +
  • ${new NoticeOverview(n)}
  • + ` + )} +
+ `} ${maintenances.length === 0 ? nothing : html` +
+ + + + + ${EnumMappings + .SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE] + .label} + + + + ${maintenances.length === 1 + ? "One maintenance is" + : `${maintenances.length} maintenance periods are`} scheduled + + +
    + ${maintenances.map((n) => + html` +
  • ${new NoticeOverview(n)}
  • + ` + )} +
+
+ `} + `; + } +} diff --git a/src/components/NoticeOverview.ts b/src/components/NoticeOverview.ts new file mode 100644 index 0000000..6aad771 --- /dev/null +++ b/src/components/NoticeOverview.ts @@ -0,0 +1,97 @@ +import { html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Component } from "./Component"; +import { Notice } from "../models/Notice"; +import { EnumMappings } from "../EnumMappings"; +import { Time } from "../Time"; +import { UpdatesFeed } from "./UpdatesFeed"; +import { Maintenance } from "../models/Maintenance"; + +@customElement("notice-overview") +export class NoticeOverview extends Component { + @state() + private notice: Notice; + + public constructor(notice: Notice) { + super(); + this.notice = notice; + } + + public override render() { + 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()); + + return html` +
+
+
+
+ + ${this.notice.name} + + + + + ${EnumMappings + .SERVICE_STATUS_STYLES[ + this.notice.impact + ] + .label} + + +
+ ${this.notice instanceof Maintenance && end !== null + ? html` +

+ Scheduled for + +  –  + +

+ ` + : nothing} +
+ +
+ ${new UpdatesFeed(this.notice.updates)} +
+ `; + } +} diff --git a/src/components/ServiceDayTooltip.ts b/src/components/ServiceDayTooltip.ts index f2b6c39..1e2ecde 100644 --- a/src/components/ServiceDayTooltip.ts +++ b/src/components/ServiceDayTooltip.ts @@ -3,9 +3,9 @@ import { customElement, property, state } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; import { Component } from "./Component"; import { Notice } from "../models/Notice"; -import { ServiceRow } from "./ServiceRow"; -import { NoticeStatus } from "../models/NoticeStatus"; import { ServiceStatus } from "../models/ServiceStatus"; +import { Time } from "../Time"; +import { EnumMappings } from "../EnumMappings"; @customElement("service-day-tooltip") export class ServiceDayTooltip extends Component { @@ -13,22 +13,12 @@ export class ServiceDayTooltip extends Component { public notices: Notice[]; @property({ type: Object }) - public day: Date; + public day: Time.Day; @state() public started: Date | null; - private static readonly STATUS_NAMES: Record = { - [NoticeStatus.INCIDENT_IDENTIFIED]: "Identified", - [NoticeStatus.INCIDENT_INVESTIGATING]: "Investigating", - [NoticeStatus.INCIDENT_MONITORING]: "Monitoring", - [NoticeStatus.INCIDENT_RESOLVED]: "Resolved", - [NoticeStatus.MAINTENANCE_NOT_STARTED_YET]: "Planned", - [NoticeStatus.MAINTENANCE_IN_PROGRESS]: "In progress", - [NoticeStatus.MAINTENANCE_COMPLETED]: "Completed", - }; - - public constructor(notices: Notice[], day: Date, started: Date | null) { + public constructor(notices: Notice[], day: Time.Day, started: Date | null) { super(); this.notices = notices.sort((a, b) => a.started.getTime() - b.started.getTime() @@ -37,37 +27,11 @@ export class ServiceDayTooltip extends Component { this.started = started; } - private static duration(ms: number): { iso: string; human: string } { - const totalSeconds = Math.floor(ms / 1000); - const days = Math.floor(totalSeconds / 86400); - const hours = Math.floor((totalSeconds % 86400) / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - - const iso = "P" + - (days ? `${days}D` : "") + - (hours || minutes ? "T" : "") + - (hours ? `${hours}H` : "") + - (minutes ? `${minutes}M` : ""); - - const parts = [ - days && `${days} ${days === 1 ? "day" : "days"}`, - hours && `${hours} ${hours === 1 ? "hour" : "hours"}`, - minutes && `${minutes} ${minutes === 1 ? "minute" : "minutes"}`, - ].filter(Boolean) as string[]; - - const human = parts.length > 1 - ? parts.slice(0, -1).join(", ") + " and " + parts.at(-1) - : parts[0] ?? "0 minutes"; - - return { iso, human }; - } - public override render() { const now = new Date(); const days = (n: number) => new Date(now.getTime() - n * 86400000).toISOString().split("T")[0]; - const tomorrow = new Date(this.day); - tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrow = this.day.next(); return html`
${this.day.toLocaleString(undefined, { - month: "long", - day: "numeric", - year: "numeric", - })} + >${this.day.toString()}
`} @@ -195,7 +158,7 @@ export class ServiceDayTooltip extends Component { monitorStart, ), end: Math.max(n.started.getTime(), this.day.getTime()) + - n.duration(this.day), + n.duration(this.day.date), impact: n.impact, })) .filter((n) => n.start < n.end); @@ -218,8 +181,8 @@ export class ServiceDayTooltip extends Component { return html` ${greenSegments.map((s) => html` -
` diff --git a/src/components/ServiceRow.ts b/src/components/ServiceRow.ts index 72c2218..ea30fdf 100644 --- a/src/components/ServiceRow.ts +++ b/src/components/ServiceRow.ts @@ -7,52 +7,13 @@ import { Service } from "../models/Service"; import { ServiceStatus } from "../models/ServiceStatus"; import { Notice } from "../models/Notice"; import { ServiceDayTooltip } from "./ServiceDayTooltip"; +import { Time } from "../Time"; +import { EnumMappings } from "../EnumMappings"; @customElement("service-row") export class ServiceRow extends Component { private static readonly MD = markdownit(); - public static readonly STATUS_STYLES: Record< - ServiceStatus, - { color: string; bar: string; label: string; icon: string } - > = { - [ServiceStatus.OPERATIONAL]: { - color: "fill-emerald-400", - bar: "bg-emerald-500", - label: "Operational", - icon: - ``, - }, - [ServiceStatus.UNDER_MAINTENANCE]: { - color: "fill-blue-400", - bar: "bg-blue-400", - label: "Under maintenance", - icon: - ``, - }, - [ServiceStatus.DEGRADED_PERFORMANCE]: { - color: "fill-amber-400", - bar: "bg-amber-400", - label: "Degraded performance", - icon: - ``, - }, - [ServiceStatus.PARTIAL_OUTAGE]: { - color: "fill-orange-400", - bar: "bg-orange-400", - label: "Partial outage", - icon: - ``, - }, - [ServiceStatus.MAJOR_OUTAGE]: { - color: "fill-red-400", - bar: "bg-red-400", - label: "Major outage", - icon: - ``, - }, - }; - private static readonly WEIGHTS: Record = { [ServiceStatus.OPERATIONAL]: 0, [ServiceStatus.UNDER_MAINTENANCE]: 0, @@ -74,12 +35,11 @@ export class ServiceRow extends Component { } private static bar( - day: Date, + day: Time.Day, notices: Notice[], started: Date | null, ): TemplateResult { - const tomorrow = new Date(day); - tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrow = day.next(); const presentNotices = notices.filter((n) => n.started.getTime() < Date.now() @@ -109,8 +69,8 @@ export class ServiceRow extends Component { class="group/bar flex" >
@@ -125,7 +85,7 @@ export class ServiceRow extends Component { class="group/bar flex outline-offset-2 outline-blue-400 has-focus-visible:z-10 has-focus-visible:outline-2" >
@@ -169,20 +129,15 @@ export class ServiceRow extends Component { return 1 - downtime / totalMs; } - protected noticesForDay(day: Date): Notice[] { - const dayStart = new Date(day); - dayStart.setHours(0, 0, 0, 0); - const nextDayStart = new Date(dayStart); - nextDayStart.setDate(dayStart.getDate() + 1); - + protected noticesForDay(day: Time.Day): Notice[] { return this.notices.filter((n) => - n.started.getTime() < nextDayStart.getTime() && - (n.ended === null || n.ended.getTime() >= dayStart.getTime()) + n.started.getTime() < day.next().getTime() && + (n.ended === null || n.ended.getTime() >= day.getTime()) ); } protected renderIcon(): TemplateResult { - const style = ServiceRow.STATUS_STYLES[this.service.status]; + const style = EnumMappings.SERVICE_STATUS_STYLES[this.service.status]; return html` ${this.service.name}

${this.service.description === null ? nothing : html` -
+