From e8341da9a67292b94ecb333a2d0eef723cad9585 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Mon, 31 Aug 2026 18:40:54 +0700 Subject: [PATCH] fix: improve showing information --- src/client/lib/format.ts | 11 ++ src/client/pages/MonitorDetailPage.tsx | 14 +- src/client/pages/StatusPage.tsx | 91 ++++++++-- src/client/styles.css | 233 ++++++++++++++++++++++--- src/worker/routes/status.ts | 7 +- test/status.spec.ts | 67 +++++++ 6 files changed, 367 insertions(+), 56 deletions(-) create mode 100644 src/client/lib/format.ts diff --git a/src/client/lib/format.ts b/src/client/lib/format.ts new file mode 100644 index 0000000..d5409d2 --- /dev/null +++ b/src/client/lib/format.ts @@ -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`; +} diff --git a/src/client/pages/MonitorDetailPage.tsx b/src/client/pages/MonitorDetailPage.tsx index ca69dfb..082b4ca 100644 --- a/src/client/pages/MonitorDetailPage.tsx +++ b/src/client/pages/MonitorDetailPage.tsx @@ -10,6 +10,7 @@ import { LatencySparkline } from '../components/charts/LatencySparkline'; import { UptimeBar } from '../components/charts/UptimeBar'; import { DeleteMonitorDialog } from '../components/dashboard/DeleteMonitorDialog'; import { INTERVAL_OPTIONS, MonitorFormDialog } from '../components/dashboard/MonitorFormDialog'; +import { formatDate, formatDuration } from '../lib/format'; import { monitorState } from '../lib/monitor-status'; import { navigate } from '../lib/router'; import { @@ -21,19 +22,6 @@ import { useRunCheckMutation, useUpdateMonitorMutation, } 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) { return INTERVAL_OPTIONS.find((option) => Number(option.value) === seconds)?.label ?? `${Math.round(seconds / 60)} min`; } diff --git a/src/client/pages/StatusPage.tsx b/src/client/pages/StatusPage.tsx index 6be7110..abc43a4 100644 --- a/src/client/pages/StatusPage.tsx +++ b/src/client/pages/StatusPage.tsx @@ -1,11 +1,12 @@ 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 { Button } from '@/components/ui/button'; import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; import type { PublicOverallStatus, PublicServiceStatus } from '../api/status'; import { SiteIcon } from '../components/SiteIcon'; import { StatusHistoryBar } from '../components/StatusHistoryBar'; +import { formatDate, formatDuration } from '../lib/format'; import { navigate } from '../lib/router'; import { useSessionQuery } from '../queries/auth'; import { useIncidentHistoryQuery, useStatusQuery } from '../queries/status'; @@ -33,7 +34,15 @@ const SERVICE_STATUS: Record = { + 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 resolvedTime = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }); function errorMessage(error: unknown) { return error instanceof Error ? error.message : 'Unknown request error'; @@ -61,6 +70,7 @@ export function StatusPage() { const status = statusQuery.data; const activeIncidents = status?.activeIncidents ?? []; const maintenanceServices = status?.services.filter((service) => service.maintenance) ?? []; + const pastIncidents = historyQuery.data?.incidents ?? []; useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 5_000); @@ -269,25 +279,72 @@ export function StatusPage() { )} - {(historyQuery.data?.incidents.length ?? 0) > 0 && ( + {!historyQuery.isPending && !historyQuery.isError && (
-
-
-

Last 30 days

-

Past incidents

+
+
+ + +
+

Last 30 days

+

Past incidents

+
+ + {pastIncidents.length === 0 ? 'No incidents' : `${pastIncidents.length} resolved`} +
-
- {historyQuery.data!.incidents.map((incident) => ( - - ))} -
+ + {pastIncidents.length === 0 ? ( + + + + + No incidents in the last 30 days + Every monitored service stayed healthy for the full window. + + ) : ( +
+ {pastIncidents.map((incident) => { + const impact = INCIDENT_IMPACT[incident.impact] ?? INCIDENT_IMPACT.none; + return ( + + ); + })} +
+ )}
)} diff --git a/src/client/styles.css b/src/client/styles.css index 2df5700..a39c299 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -2407,6 +2407,198 @@ button { box-shadow: 0 0 0 4px rgb(217 92 92 / 0.1); 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 { margin-top: 18px; overflow: hidden; @@ -2806,6 +2998,10 @@ button { grid-column: 2; justify-self: start; } + .past-incident-summary { + -webkit-line-clamp: 3; + line-clamp: 3; + } .uptime-days { gap: 1px; } @@ -3002,6 +3198,21 @@ button { .active-incidents-count { 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 { grid-template-columns: 1fr; gap: 14px; @@ -3333,36 +3544,14 @@ button { 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.28); } -.past-incidents, .incident-detail-card { margin-top: 32px; border: 1px solid var(--border); border-radius: 18px; background: var(--card); overflow: hidden; -} -.past-incidents > header, -.past-incidents button, -.incident-detail-card { 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 { display: inline-flex; align-items: center; diff --git a/src/worker/routes/status.ts b/src/worker/routes/status.ts index fadcd4e..e3d7592 100644 --- a/src/worker/routes/status.ts +++ b/src/worker/routes/status.ts @@ -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)))) .orderBy(desc(incidents.resolvedAt)) .limit(limit); - const services = await loadServices( - db, - rows.map((row) => row.id), - ); + const incidentIds = rows.map((row) => row.id); + const [services, latestUpdates] = await Promise.all([loadServices(db, incidentIds), loadLatestUpdates(db, incidentIds)]); return context.json({ incidents: rows.map((incident) => ({ id: incident.id, @@ -258,6 +256,7 @@ statusRoutes.get('/incidents', async (context) => { startedAt: incident.startedAt.toISOString(), resolvedAt: incident.resolvedAt?.toISOString() ?? null, durationMs: incident.durationMs, + latestUpdate: latestUpdates.get(incident.id) ?? null, services: services.get(incident.id) ?? [], })), }); diff --git a/test/status.spec.ts b/test/status.spec.ts index 0d31d32..800ef9c 100644 --- a/test/status.spec.ts +++ b/test/status.spec.ts @@ -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() { await env.DB.batch([ env.DB.prepare('DELETE FROM maintenance_window_monitors'), @@ -236,4 +251,56 @@ describe('public status API', () => { expect(response.status).toBe(404); 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(); + + 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()); + }); + }); });