fix: improve showing information

This commit is contained in:
2026-08-31 18:40:54 +07:00
parent 8fca8574da
commit e8341da9a6
6 changed files with 367 additions and 56 deletions
+11
View File
@@ -0,0 +1,11 @@
export function formatDate(value: string) {
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
export function formatDuration(ms: number | null, startedAt: string) {
const duration = ms ?? Math.max(0, Date.now() - new Date(startedAt).getTime());
if (duration < 60_000) return `${Math.max(1, Math.round(duration / 1000))} sec`;
if (duration < 3_600_000) return `${Math.round(duration / 60_000)} min`;
if (duration < 86_400_000) return `${Math.round(duration / 3_600_000)} hr`;
return `${Math.round(duration / 86_400_000)} days`;
}
+1 -13
View File
@@ -10,6 +10,7 @@ import { LatencySparkline } from '../components/charts/LatencySparkline';
import { UptimeBar } from '../components/charts/UptimeBar'; import { UptimeBar } from '../components/charts/UptimeBar';
import { DeleteMonitorDialog } from '../components/dashboard/DeleteMonitorDialog'; import { DeleteMonitorDialog } from '../components/dashboard/DeleteMonitorDialog';
import { INTERVAL_OPTIONS, MonitorFormDialog } from '../components/dashboard/MonitorFormDialog'; import { INTERVAL_OPTIONS, MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
import { formatDate, formatDuration } from '../lib/format';
import { monitorState } from '../lib/monitor-status'; import { monitorState } from '../lib/monitor-status';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { import {
@@ -21,19 +22,6 @@ import {
useRunCheckMutation, useRunCheckMutation,
useUpdateMonitorMutation, useUpdateMonitorMutation,
} from '../queries/monitors'; } from '../queries/monitors';
function formatDate(value: string) {
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
function formatDuration(ms: number | null, startedAt: string) {
const duration = ms ?? Math.max(0, Date.now() - new Date(startedAt).getTime());
if (duration < 60_000) return `${Math.max(1, Math.round(duration / 1000))} sec`;
if (duration < 3_600_000) return `${Math.round(duration / 60_000)} min`;
if (duration < 86_400_000) return `${Math.round(duration / 3_600_000)} hr`;
return `${Math.round(duration / 86_400_000)} days`;
}
function formatInterval(seconds: number) { function formatInterval(seconds: number) {
return INTERVAL_OPTIONS.find((option) => Number(option.value) === seconds)?.label ?? `${Math.round(seconds / 60)} min`; return INTERVAL_OPTIONS.find((option) => Number(option.value) === seconds)?.label ?? `${Math.round(seconds / 60)} min`;
} }
+72 -15
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Wrench, Zap } from 'lucide-react'; import { Activity, ChevronRight, CircleCheck, Database, History, RefreshCw, TriangleAlert, Wrench, Zap } from 'lucide-react';
import { Badge, type BadgeVariant } from '@/components/ui/badge'; import { Badge, type BadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status'; import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
import { SiteIcon } from '../components/SiteIcon'; import { SiteIcon } from '../components/SiteIcon';
import { StatusHistoryBar } from '../components/StatusHistoryBar'; import { StatusHistoryBar } from '../components/StatusHistoryBar';
import { formatDate, formatDuration } from '../lib/format';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { useSessionQuery } from '../queries/auth'; import { useSessionQuery } from '../queries/auth';
import { useIncidentHistoryQuery, useStatusQuery } from '../queries/status'; import { useIncidentHistoryQuery, useStatusQuery } from '../queries/status';
@@ -33,7 +34,15 @@ const SERVICE_STATUS: Record<PublicServiceStatus, { label: string; className: Ba
maintenance: { label: 'Under maintenance', className: 'maintenance' }, maintenance: { label: 'Under maintenance', className: 'maintenance' },
}; };
const INCIDENT_IMPACT: Record<string, { label: string; tone: string }> = {
critical: { label: 'Critical', tone: 'critical' },
major: { label: 'Major', tone: 'major' },
minor: { label: 'Minor', tone: 'minor' },
none: { label: 'Maintenance', tone: 'none' },
};
const maintenanceTime = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }); const maintenanceTime = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
const resolvedTime = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
return error instanceof Error ? error.message : 'Unknown request error'; return error instanceof Error ? error.message : 'Unknown request error';
@@ -61,6 +70,7 @@ export function StatusPage() {
const status = statusQuery.data; const status = statusQuery.data;
const activeIncidents = status?.activeIncidents ?? []; const activeIncidents = status?.activeIncidents ?? [];
const maintenanceServices = status?.services.filter((service) => service.maintenance) ?? []; const maintenanceServices = status?.services.filter((service) => service.maintenance) ?? [];
const pastIncidents = historyQuery.data?.incidents ?? [];
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 5_000); const timer = window.setInterval(() => setNow(Date.now()), 5_000);
@@ -269,25 +279,72 @@ export function StatusPage() {
)} )}
</section> </section>
{(historyQuery.data?.incidents.length ?? 0) > 0 && ( {!historyQuery.isPending && !historyQuery.isError && (
<section className="past-incidents" aria-labelledby="past-incidents-title"> <section className="past-incidents" aria-labelledby="past-incidents-title">
<header> <header className="past-incidents-header">
<div className="past-incidents-heading">
<span className="past-incidents-icon">
<History aria-hidden="true" />
</span>
<div> <div>
<p className="overline">Last 30 days</p> <p>Last 30 days</p>
<h2 id="past-incidents-title">Past incidents</h2> <h2 id="past-incidents-title">Past incidents</h2>
</div> </div>
</header>
<div>
{historyQuery.data!.incidents.map((incident) => (
<button key={incident.id} type="button" onClick={() => navigate(`/incidents/${incident.id}`)}>
<span>
<strong>{incident.title}</strong>
<small>{new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(incident.startedAt))}</small>
</span>
<Badge variant="online">Resolved</Badge>
</button>
))}
</div> </div>
<span className="past-incidents-count">
{pastIncidents.length === 0 ? 'No incidents' : `${pastIncidents.length} resolved`}
</span>
</header>
{pastIncidents.length === 0 ? (
<Empty className="min-h-70 place-content-center p-12">
<EmptyMedia variant="icon">
<CircleCheck />
</EmptyMedia>
<EmptyTitle>No incidents in the last 30 days</EmptyTitle>
<EmptyDescription>Every monitored service stayed healthy for the full window.</EmptyDescription>
</Empty>
) : (
<div className="past-incident-list">
{pastIncidents.map((incident) => {
const impact = INCIDENT_IMPACT[incident.impact] ?? INCIDENT_IMPACT.none;
return (
<button
className="past-incident-row"
key={incident.id}
type="button"
onClick={() => navigate(`/incidents/${incident.id}`)}
>
<span className="past-incident-icon">
<CircleCheck aria-hidden="true" />
</span>
<span className="past-incident-body">
<span className="past-incident-title">
<strong>{incident.title}</strong>
<span className={`past-incident-impact ${impact.tone}`}>{impact.label}</span>
</span>
<span className="past-incident-services">
{incident.services.length
? incident.services.map((service) => service.name).join(', ')
: 'General service incident'}
</span>
{incident.latestUpdate?.body && <span className="past-incident-summary">{incident.latestUpdate.body}</span>}
<span className="past-incident-meta">
<time dateTime={incident.startedAt}>{formatDate(incident.startedAt)}</time>
<i aria-hidden="true">·</i> down {formatDuration(incident.durationMs ?? null, incident.startedAt)}
{incident.resolvedAt && (
<>
<i aria-hidden="true">·</i> resolved {resolvedTime.format(new Date(incident.resolvedAt))}
</>
)}
</span>
</span>
<ChevronRight className="past-incident-chevron" aria-hidden="true" />
</button>
);
})}
</div>
)}
</section> </section>
)} )}
</> </>
+211 -22
View File
@@ -2407,6 +2407,198 @@ button {
box-shadow: 0 0 0 4px rgb(217 92 92 / 0.1); box-shadow: 0 0 0 4px rgb(217 92 92 / 0.1);
animation: blink 1.8s ease-in-out infinite; animation: blink 1.8s ease-in-out infinite;
} }
.past-incidents {
margin-top: 18px;
overflow: hidden;
border: 1px solid #dedede;
border-radius: 10px;
background: #fff;
box-shadow: 0 14px 40px rgb(28 65 49 / 0.04);
animation: enter 500ms 75ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.past-incidents-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 92px;
padding: 18px 22px;
border-bottom: 1px solid #e8ecea;
background: linear-gradient(110deg, #f4faf7 0%, #fbfdfc 72%, #fff 100%);
}
.past-incidents-heading {
display: flex;
align-items: center;
gap: 14px;
}
.past-incidents-icon {
display: grid;
width: 40px;
height: 40px;
place-items: center;
flex: 0 0 auto;
border: 1px solid rgb(22 136 91 / 0.18);
border-radius: 8px;
color: #16885b;
background: rgb(255 255 255 / 0.82);
}
.past-incidents-icon svg {
width: 18px;
height: 18px;
}
.past-incidents-heading p {
margin: 0 0 3px;
font:
500 9px/1.3 'IBM Plex Mono',
monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #3f7a63;
}
.past-incidents-heading h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.25px;
color: #1f3b2f;
}
.past-incidents-count {
flex: 0 0 auto;
padding: 5px 8px;
border: 1px solid #cfe3d9;
border-radius: 5px;
font:
500 9px/1.3 'IBM Plex Mono',
monospace;
color: #3f7a63;
background: rgb(255 255 255 / 0.72);
}
.past-incident-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 14px;
width: 100%;
padding: 18px 22px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.past-incident-row + .past-incident-row {
border-top: 1px solid #ededed;
}
.past-incident-row:hover {
background: #fdfefd;
}
.past-incident-row:focus-visible {
outline: 2px solid rgb(36 180 126 / 0.45);
outline-offset: -2px;
}
.past-incident-icon {
display: grid;
width: 30px;
height: 30px;
place-items: center;
flex: 0 0 auto;
border-radius: 50%;
color: #16885b;
background: #eaf9f2;
}
.past-incident-icon svg {
width: 15px;
height: 15px;
}
.past-incident-body {
display: grid;
gap: 4px;
min-width: 0;
}
.past-incident-title {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.past-incident-title strong {
font-size: 14px;
font-weight: 600;
color: #292323;
}
.past-incident-impact {
padding: 3px 7px;
border: 1px solid;
border-radius: 5px;
font:
500 9px/1.3 'IBM Plex Mono',
monospace;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.past-incident-impact.critical {
border-color: #e8caca;
color: #a93e3e;
background: #fff4f4;
}
.past-incident-impact.major {
border-color: #eddac2;
color: #b25f18;
background: #fff8ef;
}
.past-incident-impact.minor {
border-color: #e8dfc2;
color: #8a6d1f;
background: #fdfaee;
}
.past-incident-impact.none {
border-color: #e3e3e3;
color: #707070;
background: #f5f5f5;
}
.past-incident-services {
display: block;
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
}
.past-incident-summary {
display: -webkit-box;
overflow: hidden;
max-width: 72ch;
margin-top: 2px;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.past-incident-meta {
display: block;
margin-top: 2px;
font:
400 10px/1.4 'IBM Plex Mono',
monospace;
color: var(--faint);
}
.past-incident-meta i {
margin: 0 6px;
font-style: normal;
opacity: 0.55;
}
.past-incident-chevron {
align-self: center;
width: 16px;
height: 16px;
color: #c4c4c4;
}
.past-incident-row:hover .past-incident-chevron {
color: #8f8f8f;
}
.public-services-panel { .public-services-panel {
margin-top: 18px; margin-top: 18px;
overflow: hidden; overflow: hidden;
@@ -2806,6 +2998,10 @@ button {
grid-column: 2; grid-column: 2;
justify-self: start; justify-self: start;
} }
.past-incident-summary {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.uptime-days { .uptime-days {
gap: 1px; gap: 1px;
} }
@@ -3002,6 +3198,21 @@ button {
.active-incidents-count { .active-incidents-count {
margin-left: 54px; margin-left: 54px;
} }
.past-incidents-header {
align-items: flex-start;
flex-direction: column;
gap: 14px;
padding: 18px 16px;
}
.past-incidents-count {
margin-left: 54px;
}
.past-incident-row {
padding: 18px 16px;
}
.past-incident-chevron {
display: none;
}
.active-incident-row { .active-incident-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 14px; gap: 14px;
@@ -3333,36 +3544,14 @@ button {
0 1px 2px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.08),
inset 0 1px rgb(255 255 255 / 0.28); inset 0 1px rgb(255 255 255 / 0.28);
} }
.past-incidents,
.incident-detail-card { .incident-detail-card {
margin-top: 32px; margin-top: 32px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 18px; border-radius: 18px;
background: var(--card); background: var(--card);
overflow: hidden; overflow: hidden;
}
.past-incidents > header,
.past-incidents button,
.incident-detail-card {
padding: 22px 24px; padding: 22px 24px;
} }
.past-incidents button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
border: 0;
border-top: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.past-incidents button span {
display: grid;
gap: 5px;
}
.incident-back { .incident-back {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+3 -4
View File
@@ -244,10 +244,8 @@ statusRoutes.get('/incidents', async (context) => {
.where(and(eq(incidents.status, 'resolved'), gte(incidents.resolvedAt, new Date(Date.now() - 30 * DAY_MS)))) .where(and(eq(incidents.status, 'resolved'), gte(incidents.resolvedAt, new Date(Date.now() - 30 * DAY_MS))))
.orderBy(desc(incidents.resolvedAt)) .orderBy(desc(incidents.resolvedAt))
.limit(limit); .limit(limit);
const services = await loadServices( const incidentIds = rows.map((row) => row.id);
db, const [services, latestUpdates] = await Promise.all([loadServices(db, incidentIds), loadLatestUpdates(db, incidentIds)]);
rows.map((row) => row.id),
);
return context.json({ return context.json({
incidents: rows.map((incident) => ({ incidents: rows.map((incident) => ({
id: incident.id, id: incident.id,
@@ -258,6 +256,7 @@ statusRoutes.get('/incidents', async (context) => {
startedAt: incident.startedAt.toISOString(), startedAt: incident.startedAt.toISOString(),
resolvedAt: incident.resolvedAt?.toISOString() ?? null, resolvedAt: incident.resolvedAt?.toISOString() ?? null,
durationMs: incident.durationMs, durationMs: incident.durationMs,
latestUpdate: latestUpdates.get(incident.id) ?? null,
services: services.get(incident.id) ?? [], services: services.get(incident.id) ?? [],
})), })),
}); });
+67
View File
@@ -20,6 +20,21 @@ type PublicStatusResponse = {
}>; }>;
}; };
type IncidentHistoryResponse = {
incidents: Array<{
id: number;
title: string;
status: string;
impact: string;
source: string;
startedAt: string;
resolvedAt: string | null;
durationMs: number | null;
latestUpdate: { status: string; body: string; createdAt: string } | null;
services: Array<{ id: number; name: string }>;
}>;
};
async function resetDatabase() { async function resetDatabase() {
await env.DB.batch([ await env.DB.batch([
env.DB.prepare('DELETE FROM maintenance_window_monitors'), env.DB.prepare('DELETE FROM maintenance_window_monitors'),
@@ -236,4 +251,56 @@ describe('public status API', () => {
expect(response.status).toBe(404); expect(response.status).toBe(404);
expect(await response.json()).toEqual({ message: 'Service not found' }); expect(await response.json()).toEqual({ message: 'Service not found' });
}); });
describe('incident history', () => {
it('returns recent resolved incident details and excludes incidents older than 30 days', async () => {
const monitorId = await insertMonitor({ name: 'Payments API', lastOk: true });
const now = Date.now();
const startedAt = now - 2 * 60 * 60 * 1_000;
const resolvedAt = now - 60 * 60 * 1_000;
const incidentResult = await env.DB.prepare(
`INSERT INTO incidents (
title, status, impact, source, started_at, resolved_at, duration_ms, created_at, updated_at
) VALUES ('Payment processing disruption', 'resolved', 'critical', 'manual', ?, ?, ?, ?, ?)`,
)
.bind(startedAt, resolvedAt, resolvedAt - startedAt, startedAt, resolvedAt)
.run();
const incidentId = Number(incidentResult.meta.last_row_id);
await env.DB.batch([
env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (?, ?)').bind(incidentId, monitorId),
env.DB.prepare(
"INSERT INTO incident_updates (incident_id, status, body, source, created_at) VALUES (?, 'monitoring', 'Recovery is in progress.', 'manual', ?)",
).bind(incidentId, startedAt + 15 * 60 * 1_000),
env.DB.prepare(
"INSERT INTO incident_updates (incident_id, status, body, source, created_at) VALUES (?, 'resolved', 'Payment processing has fully recovered.', 'manual', ?)",
).bind(incidentId, resolvedAt),
env.DB.prepare(
`INSERT INTO incidents (
title, status, impact, source, started_at, resolved_at, duration_ms, created_at, updated_at
) VALUES ('Old disruption', 'resolved', 'minor', 'manual', ?, ?, ?, ?, ?)`,
).bind(now - 32 * DAY_MS, now - 31 * DAY_MS, DAY_MS, now - 32 * DAY_MS, now - 31 * DAY_MS),
]);
const response = await statusFetch('/api/status/incidents');
const body = await response.json<IncidentHistoryResponse>();
expect(response.status).toBe(200);
expect(body.incidents).toHaveLength(1);
expect(body.incidents[0]).toMatchObject({
id: incidentId,
title: 'Payment processing disruption',
status: 'resolved',
impact: 'critical',
source: 'manual',
durationMs: resolvedAt - startedAt,
services: [{ id: monitorId, name: 'Payments API' }],
latestUpdate: {
status: 'resolved',
body: 'Payment processing has fully recovered.',
},
});
expect(body.incidents[0].startedAt).toBe(new Date(startedAt).toISOString());
expect(body.incidents[0].resolvedAt).toBe(new Date(resolvedAt).toISOString());
});
});
}); });