mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 05:41:59 +00:00
fix: improve showing information
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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<PublicServiceStatus, { label: string; className: Ba
|
||||
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 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() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{(historyQuery.data?.incidents.length ?? 0) > 0 && (
|
||||
{!historyQuery.isPending && !historyQuery.isError && (
|
||||
<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>
|
||||
<p className="overline">Last 30 days</p>
|
||||
<p>Last 30 days</p>
|
||||
<h2 id="past-incidents-title">Past incidents</h2>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
+211
-22
@@ -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;
|
||||
|
||||
@@ -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) ?? [],
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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<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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user