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 { 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`;
}
+74 -17
View File
@@ -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>
<div>
<p className="overline">Last 30 days</p>
<h2 id="past-incidents-title">Past incidents</h2>
<header className="past-incidents-header">
<div className="past-incidents-heading">
<span className="past-incidents-icon">
<History aria-hidden="true" />
</span>
<div>
<p>Last 30 days</p>
<h2 id="past-incidents-title">Past incidents</h2>
</div>
</div>
<span className="past-incidents-count">
{pastIncidents.length === 0 ? 'No incidents' : `${pastIncidents.length} resolved`}
</span>
</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>
{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
View File
@@ -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;
+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))))
.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) ?? [],
})),
});