feat: Updated monitor chart
This commit is contained in:
@@ -16,7 +16,9 @@ import {
|
||||
formatDuration,
|
||||
formatMs,
|
||||
formatPct,
|
||||
markIncidents,
|
||||
relativeTime,
|
||||
slotChartColor,
|
||||
slotLabel,
|
||||
statusStripe,
|
||||
targetSummary,
|
||||
@@ -206,7 +208,7 @@ function History({ slots, note }: { slots: Slot[]; note?: string }) {
|
||||
<span
|
||||
className={`w-full rounded-[1px] transition-opacity ${
|
||||
hovered !== null && hovered !== i ? "opacity-50" : ""
|
||||
} ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
|
||||
} ${slotChartColor(s)}`}
|
||||
style={{ height: s.pct === null ? "18%" : `${Math.max(s.pct, 12)}%` }}
|
||||
/>
|
||||
</button>
|
||||
@@ -408,10 +410,14 @@ export default function MonitorDetailPage() {
|
||||
}
|
||||
|
||||
const all: Rollup[] = rollups ?? [];
|
||||
const slots =
|
||||
/* Marked with the incidents, so a bar is red only where the monitor was
|
||||
actually down — a failed check the retry policy absorbed stays amber. */
|
||||
const slots = markIncidents(
|
||||
range.source === "rollups"
|
||||
? buildSlots(all, Math.round(range.minutes / 60))
|
||||
: buildSampleSlots(samples ?? [], range.minutes * 60_000, range.bucketMs);
|
||||
: buildSampleSlots(samples ?? [], range.minutes * 60_000, range.bucketMs),
|
||||
incidents ?? [],
|
||||
);
|
||||
/* Samples expire after 48h and only start accruing once a check runs, so an
|
||||
empty short range is a real answer and not a failure to load. */
|
||||
const emptyRange = range.source === "samples" && (samples ?? []).length === 0;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, Rollup } from "@/lib/api";
|
||||
import { api, Incident, Monitor, Rollup } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
DisplayStatus,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
displayStatus,
|
||||
formatMs,
|
||||
formatPct,
|
||||
markIncidents,
|
||||
relativeTime,
|
||||
statusStripe,
|
||||
targetSummary,
|
||||
@@ -79,11 +80,17 @@ const UNGROUPED = "Ungrouped";
|
||||
|
||||
interface MonitorGroup {
|
||||
name: string;
|
||||
rows: { monitor: Monitor; rollups: Rollup[] }[];
|
||||
rows: MonitorRowData[];
|
||||
}
|
||||
|
||||
interface MonitorRowData {
|
||||
monitor: Monitor;
|
||||
rollups: Rollup[];
|
||||
incidents: Incident[];
|
||||
}
|
||||
|
||||
/** Alphabetical, with the ungrouped remainder last so it reads as a leftover. */
|
||||
function groupMonitors(rows: { monitor: Monitor; rollups: Rollup[] }[]): MonitorGroup[] {
|
||||
function groupMonitors(rows: MonitorRowData[]): MonitorGroup[] {
|
||||
const byName = new Map<string, MonitorGroup["rows"]>();
|
||||
for (const row of rows) {
|
||||
const name = row.monitor.group?.trim() || UNGROUPED;
|
||||
@@ -170,9 +177,11 @@ function GroupHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorRow({ monitor, rollups }: { monitor: Monitor; rollups: Rollup[] }) {
|
||||
function MonitorRow({ monitor, rollups, incidents }: MonitorRowData) {
|
||||
const status = displayStatus(monitor);
|
||||
const slots = buildSlots(rollups);
|
||||
/* Incidents, not raw check results, decide which hours read as down — see
|
||||
markIncidents. */
|
||||
const slots = markIncidents(buildSlots(rollups), incidents);
|
||||
const pct = uptimePct(rollups.slice(-24));
|
||||
const latency = monitor.state.latency_ms > 0 ? monitor.state.latency_ms : avgLatency(rollups.slice(-1));
|
||||
|
||||
@@ -224,9 +233,21 @@ export default function MonitorsPage() {
|
||||
})),
|
||||
});
|
||||
|
||||
const incidentQueries = useQueries({
|
||||
queries: (monitors ?? []).map((m) => ({
|
||||
queryKey: ["monitors", m.monitor_id, "incidents"],
|
||||
queryFn: () => api.getMonitorIncidents(m.monitor_id),
|
||||
refetchInterval: 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const { collapsed, toggle } = useCollapsedGroups();
|
||||
|
||||
const rows = (monitors ?? []).map((m, i) => ({ monitor: m, rollups: uptimeQueries[i]?.data ?? [] }));
|
||||
const rows: MonitorRowData[] = (monitors ?? []).map((m, i) => ({
|
||||
monitor: m,
|
||||
rollups: uptimeQueries[i]?.data ?? [],
|
||||
incidents: incidentQueries[i]?.data ?? [],
|
||||
}));
|
||||
const grouped = rows.some(({ monitor }) => !!monitor.group?.trim());
|
||||
const groups = groupMonitors(rows);
|
||||
|
||||
@@ -292,17 +313,15 @@ export default function MonitorsPage() {
|
||||
<div key={group.name} className="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<GroupHeader group={group} collapsed={isCollapsed} onToggle={() => toggle(group.name)} />
|
||||
{!isCollapsed &&
|
||||
group.rows.map(({ monitor, rollups }) => (
|
||||
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
|
||||
))}
|
||||
group.rows.map((row) => <MonitorRow key={row.monitor.monitor_id} {...row} />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
{rows.map(({ monitor, rollups }) => (
|
||||
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
|
||||
{rows.map((row) => (
|
||||
<MonitorRow key={row.monitor.monitor_id} {...row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
|
||||
import { Incident, Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* Shared vocabulary for the monitors screens.
|
||||
@@ -117,6 +117,14 @@ export interface Slot {
|
||||
pct: number | null;
|
||||
checks: number;
|
||||
latency: number | null;
|
||||
/**
|
||||
* An incident overlapped this slot — the monitor was actually down for
|
||||
* some of it. A failed check on its own is not this: `retries` exists so a
|
||||
* transient failure never opens an incident, and painting one red taught
|
||||
* operators the check had gone offline when nothing had. `pct` still
|
||||
* reports every failed check honestly; `down` is what colour follows.
|
||||
*/
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +155,7 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
|
||||
checks: r?.checks ?? 0,
|
||||
pct: r && r.checks > 0 ? (r.up_count / r.checks) * 100 : null,
|
||||
latency: r && r.checks > 0 ? r.sum_latency / r.checks : null,
|
||||
down: false,
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
@@ -184,19 +193,50 @@ export function buildSampleSlots(samples: MonitorSample[], windowMs: number, buc
|
||||
checks: bucket.checks,
|
||||
pct: bucket.checks > 0 ? (bucket.up / bucket.checks) * 100 : null,
|
||||
latency: bucket.checks > 0 ? bucket.latency / bucket.checks : null,
|
||||
down: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the slots an incident ran through. Incidents are the record of what the
|
||||
* monitor decided after `retries`, so this is the only thing that may paint a
|
||||
* slot as down — the raw check results say only that a check failed, which is
|
||||
* a different and much more common event.
|
||||
*
|
||||
* An unresolved incident runs to now. Returns new slots; the input is left
|
||||
* alone so the same tape can be built once and marked per view.
|
||||
*/
|
||||
export function markIncidents(slots: Slot[], incidents: Incident[]): Slot[] {
|
||||
if (incidents.length === 0) return slots;
|
||||
const spans = incidents.map((i) => ({
|
||||
from: new Date(i.started_at).getTime(),
|
||||
to: i.resolved_at ? new Date(i.resolved_at).getTime() : Date.now(),
|
||||
}));
|
||||
return slots.map((s) => {
|
||||
const from = s.at.getTime();
|
||||
const to = from + s.spanMs;
|
||||
return spans.some((sp) => sp.from < to && sp.to > from) ? { ...s, down: true } : s;
|
||||
});
|
||||
}
|
||||
|
||||
function slotColor(s: Slot): string {
|
||||
if (s.down) return "bg-danger";
|
||||
if (s.pct === null) return "bg-border-soft";
|
||||
if (s.pct >= 99.5) return "bg-success";
|
||||
if (s.pct >= 80) return "bg-warning";
|
||||
return "bg-danger";
|
||||
return "bg-warning";
|
||||
}
|
||||
|
||||
/** The same three states at the chart's lower contrast. */
|
||||
export function slotChartColor(s: Slot): string {
|
||||
if (s.down) return "bg-danger/80";
|
||||
if (s.pct === null) return "bg-border-soft";
|
||||
if (s.pct >= 99.5) return "bg-success/60";
|
||||
return "bg-warning/70";
|
||||
}
|
||||
|
||||
function slotHeight(s: Slot): number {
|
||||
if (s.pct === null) return 26;
|
||||
if (s.pct < 80) return 100;
|
||||
if (s.down || s.pct < 80) return 100;
|
||||
return 55 + (s.pct - 80) * 2.2;
|
||||
}
|
||||
|
||||
@@ -205,7 +245,8 @@ function slotHeight(s: Slot): number {
|
||||
export function slotLabel(s: Slot): string {
|
||||
const when = s.at.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
||||
if (s.pct === null) return `${when} · no checks ran`;
|
||||
return `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
|
||||
const base = `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
|
||||
return s.down ? `${base} · incident` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user