mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +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 { 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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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) ?? [],
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user