Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/api/BaseComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface BaseComponent {
id: string;
name: { default: string };
status: string;
order: number;
}
8 changes: 8 additions & 0 deletions src/api/Incident.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Notice } from "./Notice";

export interface Incident extends Notice {
started: string;
resolved: string | null;
duration: number;
impact: string;
}
36 changes: 36 additions & 0 deletions src/api/InstatusApi.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -32,4 +34,38 @@ export class InstatusApi {
const data: { data: MetricDataPoint[] } = await res.json();
return data.data;
}

public async getIncidents(): Promise<Incident[]> {
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<Maintenance[]> {
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<Incident> {
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<Maintenance> {
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();
}
}
7 changes: 7 additions & 0 deletions src/api/Maintenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Notice } from "./Notice";

export interface Maintenance extends Notice {
start: string;
end: string;
duration: number;
}
10 changes: 10 additions & 0 deletions src/api/Notice.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
7 changes: 7 additions & 0 deletions src/api/NoticeUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface NoticeUpdate {
id: string;
started: string;
status: string;
message: { default: string };
attachments: string[];
}
8 changes: 3 additions & 5 deletions src/api/SiteComponent.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/AppRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
30 changes: 23 additions & 7 deletions src/components/ServiceGroupRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -39,12 +60,7 @@ export class ServiceGroupRow extends ServiceRow {
${this.renderBars()} ${this.renderBottom()}
</div>
</summary>
${this.service.children.map((child) => {
const row = new ServiceRow();
row.service = child;
row.classList.add("block", "mt-4");
return row;
})}
${this.rows}
</details>
`;
}
Expand Down
111 changes: 98 additions & 13 deletions src/components/ServiceRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"></path>`,
},
[ServiceStatus.UNDER_MAINTENANCE]: {
color: "text-indigo-400",
bar: "bg-indigo-400",
label: "Under Maintenance",
icon:
`<path d="M128 24a104 104 0 1 0 104 104A104.13 104.13 0 0 0 128 24m14.052 54.734a34.2 34.2 0 0 1 9.427 1.006 3.79 3.79 0 0 1 1.865 6.25l-17.76 19.265 2.682 12.485 12.484 2.677 19.266-17.782a3.79 3.79 0 0 1 6.25 1.865 34.4 34.4 0 0 1 1.02 8.333 34.122 34.122 0 0 1-47.833 31.282l-24.672 28.536a4 4 0 0 1-.187.203 15.168 15.168 0 0 1-21.448-21.453q.098-.095.203-.182l28.542-24.667a34.155 34.155 0 0 1 30.161-47.818" />`,
},
[ServiceStatus.DEGRADED_PERFORMANCE]: {
color: "text-yellow-400",
bar: "bg-yellow-400",
label: "Degraded Performance",
icon:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-8,56a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,104a12,12,0,1,1,12-12A12,12,0,0,1,128,184Z"></path>`,
},
[ServiceStatus.PARTIAL_OUTAGE]: {
color: "text-orange-400",
bar: "bg-orange-400",
label: "Partial Outage",
icon:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-8,56a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,104a12,12,0,1,1,12-12A12,12,0,0,1,128,184Z"></path>`,
},
[ServiceStatus.MAJOR_OUTAGE]: {
color: "text-red-400",
bar: "bg-red-400",
label: "Major Outage",
icon:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm37.66,130.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path>`,
},
};

private static readonly WEIGHTS: Record<ServiceStatus, number> = {
[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];
Expand Down Expand Up @@ -77,16 +147,18 @@ export class ServiceRow extends Component {
<p class="hidden text-white sm:block">~${metric.percentile(
50,
1,
)
.toFixed(
1,
)} ms</p>
).toLocaleString(undefined, {
maximumFractionDigits: 1,
})} ms</p>
`
)} ${this.service.showUptime
? html`
<p class="${this.service.status === ServiceStatus.OPERATIONAL
? "text-emerald-400"
: "text-neutral-300"}">100% uptime</p>
: "text-neutral-300"}">${(this.uptime(90) * 100).toLocaleString(
undefined,
{ minimumFractionDigits: 1, maximumFractionDigits: 2 },
)}% uptime</p>
`
: nothing}
</div>
Expand All @@ -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`
<div class="${ServiceRow.STATUS_STYLES[ServiceStatus.OPERATIONAL]
.bar}"></div>
`;
}
const worst = notices.reduce((w, n) => n.impact > w.impact ? n : w);
return html`
<div class="${ServiceRow.STATUS_STYLES[worst.impact].bar}"></div>
`;
});

return html`
<div
class="mt-2 grid h-8 grid-cols-30 overflow-hidden rounded-sm sm:grid-cols-60 md:grid-cols-90 text-neutral-900 *:border-r *:last:border-r-0 [&>*:not(:nth-last-child(-n+30))]:max-sm:hidden [&>*:not(:nth-last-child(-n+60))]:max-md:hidden"
>
${Array(90).fill(null).map(() =>
html`
<div class="bg-neutral-800"></div>
`
)}
${bars}
</div>
`;
}
Expand Down Expand Up @@ -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()}
Expand Down
Loading