mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
fix: improve detect error case
This commit is contained in:
@@ -8,6 +8,11 @@ export type Monitor = {
|
||||
url: string;
|
||||
method: MonitorMethod;
|
||||
expectedStatus: number;
|
||||
expectKeyword: string | null;
|
||||
keywordInverted: boolean;
|
||||
requestHeaders: string | null;
|
||||
requestBody: string | null;
|
||||
degradedLatencyMs: number | null;
|
||||
intervalSeconds: number;
|
||||
timeoutMs: number;
|
||||
enabled: boolean;
|
||||
@@ -15,7 +20,9 @@ export type Monitor = {
|
||||
retryCount: number;
|
||||
failureThreshold: number;
|
||||
consecutiveFailures: number;
|
||||
consecutiveSlow: number;
|
||||
lastOk: boolean | null;
|
||||
lastDegraded: boolean;
|
||||
lastStatusCode: number | null;
|
||||
lastLatencyMs: number | null;
|
||||
lastError: string | null;
|
||||
@@ -29,6 +36,11 @@ export type MonitorInput = {
|
||||
url: string;
|
||||
method: MonitorMethod;
|
||||
expectedStatus: number;
|
||||
expectKeyword?: string | null;
|
||||
keywordInverted?: boolean;
|
||||
requestHeaders?: Record<string, string> | null;
|
||||
requestBody?: string | null;
|
||||
degradedLatencyMs?: number | null;
|
||||
intervalSeconds: number;
|
||||
timeoutMs: number;
|
||||
retryCount?: number;
|
||||
@@ -39,6 +51,7 @@ export type MonitorInput = {
|
||||
|
||||
export type CheckResult = {
|
||||
ok: boolean;
|
||||
degraded: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
@@ -49,6 +62,7 @@ export type Check = {
|
||||
id: number;
|
||||
monitorId: number;
|
||||
ok: boolean;
|
||||
degraded: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
@@ -57,6 +71,7 @@ export type Check = {
|
||||
};
|
||||
|
||||
export type CheckTransition = 'opened' | 'pending' | 'cleared' | 'resolved' | null;
|
||||
export type LatencyTransition = 'degraded' | 'recovered' | null;
|
||||
|
||||
export type Incident = {
|
||||
id: number;
|
||||
@@ -122,5 +137,7 @@ export function deleteMonitor(id: number) {
|
||||
}
|
||||
|
||||
export function runMonitorCheck(id: number) {
|
||||
return postJson<{ result: CheckResult; transition: CheckTransition; monitor: Monitor }>(`/api/monitors/${id}/check`);
|
||||
return postJson<{ result: CheckResult; transition: CheckTransition; latencyTransition: LatencyTransition; monitor: Monitor }>(
|
||||
`/api/monitors/${id}/check`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getJson } from './http';
|
||||
|
||||
export type PublicServiceStatus = 'up' | 'down' | 'unknown' | 'maintenance';
|
||||
export type PublicServiceStatus = 'up' | 'degraded' | 'down' | 'unknown' | 'maintenance';
|
||||
export type PublicOverallStatus = 'operational' | 'degraded' | 'down';
|
||||
|
||||
export type PublicIncidentUpdate = { status: string; body: string; createdAt: string };
|
||||
|
||||
@@ -12,7 +12,7 @@ type UptimeDatum = {
|
||||
id: string;
|
||||
successfulChecks: number;
|
||||
totalChecks: number;
|
||||
status: 'up' | 'down';
|
||||
status: 'up' | 'degraded' | 'down';
|
||||
fill: string;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const MAX_VISIBLE_SEGMENTS = 32;
|
||||
|
||||
const uptimeConfig = {
|
||||
up: { label: 'Up', color: 'var(--primary)' },
|
||||
degraded: { label: 'Degraded', color: 'var(--chart-degraded)' },
|
||||
down: { label: 'Down', color: 'var(--chart-down)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
@@ -58,6 +59,8 @@ function groupChecks(checks: Check[]): UptimeDatum[] {
|
||||
const last = bucket[bucket.length - 1];
|
||||
const successfulChecks = bucket.filter((check) => check.ok).length;
|
||||
const ok = successfulChecks === bucket.length;
|
||||
const degraded = ok && bucket.some((check) => check.degraded);
|
||||
const status = !ok ? 'down' : degraded ? 'degraded' : 'up';
|
||||
|
||||
return {
|
||||
startTime: first.checkedAt,
|
||||
@@ -67,8 +70,8 @@ function groupChecks(checks: Check[]): UptimeDatum[] {
|
||||
id: `${first.id}-${last.id}`,
|
||||
successfulChecks,
|
||||
totalChecks: bucket.length,
|
||||
status: ok ? 'up' : 'down',
|
||||
fill: ok ? 'var(--color-up)' : 'var(--color-down)',
|
||||
status,
|
||||
fill: `var(--color-${status})`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -114,7 +117,7 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
|
||||
onAnimationEnd={() => setHasAnimated(true)}
|
||||
>
|
||||
{data.map((point) => (
|
||||
<Cell key={point.id} fill={point.ok ? 'var(--color-up)' : 'var(--color-down)'} />
|
||||
<Cell key={point.id} fill={point.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
@@ -123,7 +126,7 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
|
||||
<div className="uptime-legend">
|
||||
<span>Oldest</span>
|
||||
<span>
|
||||
<i className="legend-up" /> Up <i className="legend-down" /> Down
|
||||
<i className="legend-up" /> Up <i className="legend-degraded" /> Degraded <i className="legend-down" /> Down
|
||||
</span>
|
||||
<span>Latest</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { ChevronDown, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -16,7 +17,13 @@ export const DEFAULT_MONITOR_INPUT: MonitorInput = {
|
||||
timeoutMs: 10_000,
|
||||
retryCount: 1,
|
||||
failureThreshold: 2,
|
||||
expectKeyword: null,
|
||||
keywordInverted: false,
|
||||
requestHeaders: null,
|
||||
requestBody: null,
|
||||
degradedLatencyMs: null,
|
||||
enabled: true,
|
||||
alertsEnabled: true,
|
||||
};
|
||||
|
||||
export const INTERVAL_OPTIONS = [
|
||||
@@ -32,6 +39,17 @@ type MonitorFormDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type HeaderRow = { id: number; name: string; value: string };
|
||||
|
||||
function headerRows(requestHeaders: string | null): HeaderRow[] {
|
||||
if (!requestHeaders) return [];
|
||||
try {
|
||||
return Object.entries(JSON.parse(requestHeaders) as Record<string, string>).map(([name, value], index) => ({ id: index, name, value }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -47,6 +65,11 @@ function monitorInput(monitor: Monitor | null): MonitorInput {
|
||||
timeoutMs: monitor.timeoutMs,
|
||||
retryCount: monitor.retryCount,
|
||||
failureThreshold: monitor.failureThreshold,
|
||||
expectKeyword: monitor.expectKeyword,
|
||||
keywordInverted: monitor.keywordInverted,
|
||||
requestHeaders: monitor.requestHeaders ? (JSON.parse(monitor.requestHeaders) as Record<string, string>) : null,
|
||||
requestBody: monitor.requestBody,
|
||||
degradedLatencyMs: monitor.degradedLatencyMs,
|
||||
enabled: monitor.enabled,
|
||||
alertsEnabled: monitor.alertsEnabled,
|
||||
};
|
||||
@@ -56,6 +79,8 @@ export function MonitorFormDialog({ editing, onClose }: MonitorFormDialogProps)
|
||||
const createMutation = useCreateMonitorMutation();
|
||||
const updateMutation = useUpdateMonitorMutation();
|
||||
const [form, setForm] = useState<MonitorInput>(() => monitorInput(editing));
|
||||
const [headers, setHeaders] = useState<HeaderRow[]>(() => headerRows(editing?.requestHeaders ?? null));
|
||||
const [nextHeaderId, setNextHeaderId] = useState(() => headers.length);
|
||||
const formMutation = editing ? updateMutation : createMutation;
|
||||
|
||||
function closeForm() {
|
||||
@@ -65,13 +90,31 @@ export function MonitorFormDialog({ editing, onClose }: MonitorFormDialogProps)
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const requestHeaders = Object.fromEntries(
|
||||
headers.filter((header) => header.name.trim()).map((header) => [header.name.trim(), header.value]),
|
||||
);
|
||||
const input: MonitorInput = {
|
||||
...form,
|
||||
expectKeyword: form.expectKeyword?.trim() || null,
|
||||
requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null,
|
||||
requestBody: form.method === 'POST' ? form.requestBody || null : null,
|
||||
};
|
||||
if (editing) {
|
||||
updateMutation.mutate({ id: editing.id, input: form }, { onSuccess: onClose });
|
||||
updateMutation.mutate({ id: editing.id, input }, { onSuccess: onClose });
|
||||
} else {
|
||||
createMutation.mutate(form, { onSuccess: onClose });
|
||||
createMutation.mutate(input, { onSuccess: onClose });
|
||||
}
|
||||
}
|
||||
|
||||
function addHeader() {
|
||||
setHeaders((current) => [...current, { id: nextHeaderId, name: '', value: '' }]);
|
||||
setNextHeaderId((current) => current + 1);
|
||||
}
|
||||
|
||||
function updateHeader(id: number, field: 'name' | 'value', value: string) {
|
||||
setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@@ -217,6 +260,109 @@ export function MonitorFormDialog({ editing, onClose }: MonitorFormDialogProps)
|
||||
/>
|
||||
<label htmlFor="monitor-alerts-enabled">Enable incident alerts</label>
|
||||
</div>
|
||||
<details className="monitor-advanced">
|
||||
<summary>
|
||||
<span>Advanced request and response checks</span>
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="monitor-advanced-grid">
|
||||
<label className="field advanced-keyword" htmlFor="monitor-keyword">
|
||||
<span>Expected response keyword</span>
|
||||
<Input
|
||||
id="monitor-keyword"
|
||||
value={form.expectKeyword ?? ''}
|
||||
onChange={(event) => setForm({ ...form, expectKeyword: event.target.value || null })}
|
||||
maxLength={200}
|
||||
placeholder="healthy"
|
||||
/>
|
||||
<small>Case-insensitive match within the first 256 KB of the response.</small>
|
||||
</label>
|
||||
<div className="toggle-field advanced-inverted">
|
||||
<Switch
|
||||
id="monitor-keyword-inverted"
|
||||
checked={form.keywordInverted ?? false}
|
||||
onCheckedChange={(keywordInverted) => setForm({ ...form, keywordInverted })}
|
||||
disabled={!form.expectKeyword}
|
||||
/>
|
||||
<label htmlFor="monitor-keyword-inverted">Fail when present</label>
|
||||
</div>
|
||||
<label className="field advanced-latency" htmlFor="monitor-degraded-latency">
|
||||
<span>Degraded above (ms)</span>
|
||||
<Input
|
||||
id="monitor-degraded-latency"
|
||||
type="number"
|
||||
min="1"
|
||||
max="30000"
|
||||
value={form.degradedLatencyMs ?? ''}
|
||||
onChange={(event) => setForm({ ...form, degradedLatencyMs: event.target.value ? event.target.valueAsNumber : null })}
|
||||
placeholder="1500"
|
||||
/>
|
||||
<small>Publishes degraded performance after the configured confirmation count.</small>
|
||||
</label>
|
||||
<div className="advanced-headers">
|
||||
<div className="advanced-section-heading">
|
||||
<div>
|
||||
<span>Request headers</span>
|
||||
<small>Up to 10 headers. Restricted transport headers are blocked.</small>
|
||||
</div>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="secondary-button header-add-button"
|
||||
type="button"
|
||||
onClick={addHeader}
|
||||
disabled={headers.length >= 10}
|
||||
>
|
||||
<Plus aria-hidden="true" /> Add header
|
||||
</Button>
|
||||
</div>
|
||||
{headers.length > 0 && (
|
||||
<div className="header-rows">
|
||||
{headers.map((header) => (
|
||||
<div className="header-row" key={header.id}>
|
||||
<Input
|
||||
aria-label="Header name"
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, 'name', event.target.value)}
|
||||
maxLength={64}
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
<Input
|
||||
aria-label="Header value"
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, 'value', event.target.value)}
|
||||
maxLength={512}
|
||||
placeholder="Bearer …"
|
||||
/>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="icon-button header-remove-button"
|
||||
type="button"
|
||||
aria-label={`Remove ${header.name || 'header'}`}
|
||||
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{form.method === 'POST' && (
|
||||
<label className="field advanced-body" htmlFor="monitor-request-body">
|
||||
<span>Request body</span>
|
||||
<textarea
|
||||
id="monitor-request-body"
|
||||
value={form.requestBody ?? ''}
|
||||
onChange={(event) => setForm({ ...form, requestBody: event.target.value || null })}
|
||||
maxLength={8192}
|
||||
rows={6}
|
||||
placeholder={'{"query":"health"}'}
|
||||
/>
|
||||
<small>Sent with POST requests. JSON content type is added unless overridden above.</small>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<div className="form-actions compact-actions">
|
||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={closeForm}>
|
||||
Cancel
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import type { Monitor } from '../api/monitors';
|
||||
|
||||
export function monitorState(monitor: Pick<Monitor, 'lastOk' | 'consecutiveFailures' | 'failureThreshold'>) {
|
||||
export function monitorState(
|
||||
monitor: Pick<Monitor, 'lastOk' | 'lastDegraded' | 'degradedLatencyMs' | 'consecutiveFailures' | 'failureThreshold'>,
|
||||
) {
|
||||
if (monitor.lastOk === false) return { label: 'Down', variant: 'offline' as const, detail: null };
|
||||
if (monitor.consecutiveFailures > 0) {
|
||||
return {
|
||||
label: 'Degrading',
|
||||
label: 'Failing',
|
||||
variant: 'pending' as const,
|
||||
detail: `${monitor.consecutiveFailures} of ${monitor.failureThreshold} failed checks — an incident opens if the next check fails`,
|
||||
};
|
||||
}
|
||||
if (monitor.lastDegraded) {
|
||||
return {
|
||||
label: 'Degraded',
|
||||
variant: 'pending' as const,
|
||||
detail:
|
||||
monitor.degradedLatencyMs === null
|
||||
? 'Response time is above the configured threshold'
|
||||
: `Response time exceeds ${monitor.degradedLatencyMs} ms`,
|
||||
};
|
||||
}
|
||||
if (monitor.lastOk === true) return { label: 'Up', variant: 'online' as const, detail: null };
|
||||
return { label: 'Not checked', variant: 'checking' as const, detail: null };
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const OVERALL_COPY: Record<PublicOverallStatus, { title: string; detail: string
|
||||
|
||||
const SERVICE_STATUS: Record<PublicServiceStatus, { label: string; className: BadgeVariant }> = {
|
||||
up: { label: 'Operational', className: 'online' },
|
||||
degraded: { label: 'Degraded performance', className: 'pending' },
|
||||
down: { label: 'Down', className: 'offline' },
|
||||
unknown: { label: 'Awaiting data', className: 'checking' },
|
||||
maintenance: { label: 'Under maintenance', className: 'maintenance' },
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
--shadcn-primary: oklch(0.205 0 0);
|
||||
--primary-deep: #24b47e;
|
||||
--chart-down: #d95c5c;
|
||||
--chart-degraded: #d97706;
|
||||
--ink: #171717;
|
||||
--muted: #707070;
|
||||
--shadcn-muted: oklch(0.97 0 0);
|
||||
@@ -424,6 +425,119 @@ button {
|
||||
color: #9f2f2f;
|
||||
background: #fff6f6;
|
||||
}
|
||||
.monitor-advanced {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 7px;
|
||||
background: #fcfcfc;
|
||||
}
|
||||
.monitor-advanced summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.monitor-advanced summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.monitor-advanced summary svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
.monitor-advanced[open] summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.monitor-advanced-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px 16px;
|
||||
padding: 2px 16px 18px;
|
||||
border-top: 1px solid #ededed;
|
||||
}
|
||||
.monitor-advanced-grid > * {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.monitor-advanced-grid small,
|
||||
.advanced-section-heading small {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
.advanced-keyword {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.advanced-inverted {
|
||||
grid-column: span 1;
|
||||
}
|
||||
.advanced-latency {
|
||||
grid-column: span 1;
|
||||
}
|
||||
.advanced-headers,
|
||||
.advanced-body {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.advanced-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.advanced-section-heading > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.advanced-section-heading span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.header-add-button svg,
|
||||
.header-remove-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.header-rows {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.header-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 1fr) minmax(180px, 2fr) 36px;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-remove-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #9f2f2f;
|
||||
}
|
||||
.advanced-body textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font:
|
||||
12px/1.5 'IBM Plex Mono',
|
||||
monospace;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
.advanced-body textarea:focus-visible {
|
||||
outline: 2px solid rgb(36 180 126 / 0.3);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.services-panel {
|
||||
margin-top: 24px;
|
||||
@@ -1191,6 +1305,10 @@ button {
|
||||
margin-left: 5px;
|
||||
background: var(--chart-down);
|
||||
}
|
||||
.uptime-legend i.legend-degraded {
|
||||
margin-left: 5px;
|
||||
background: var(--chart-degraded);
|
||||
}
|
||||
.data-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -3380,6 +3498,7 @@ button {
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-down: var(--chart-down);
|
||||
--color-chart-degraded: var(--chart-degraded);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
|
||||
@@ -3,10 +3,12 @@ export const INCIDENT_REASSURANCE =
|
||||
'The problem was detected automatically, our team has been alerted, and we are working to restore normal service as soon as possible.';
|
||||
|
||||
export const RECOVERY_UPDATE_BODY = 'The service has recovered and is responding normally again.';
|
||||
export const DEGRADED_MESSAGE = 'This service is responding more slowly than usual and some pages may take longer to load.';
|
||||
|
||||
/** Plain-language, non-technical description of the impact, used when no AI message is available. */
|
||||
export function describeFailure(statusCode: number | null): string {
|
||||
if (statusCode === null) return 'This service is currently unreachable and may not load for visitors.';
|
||||
if (statusCode >= 200 && statusCode <= 299) return 'This service is responding but is not returning the expected content.';
|
||||
if (statusCode >= 500 && statusCode <= 599) return 'This service is having problems and some requests may fail or load incorrectly.';
|
||||
if (statusCode === 429) return 'This service is under heavy load and is temporarily turning away some requests.';
|
||||
if (statusCode >= 400 && statusCode <= 499) return 'This service is not responding correctly and some features may not work right now.';
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CheckResult, Monitor } from './run-check';
|
||||
/** Only opened and resolved transitions are allowed to send alerts. */
|
||||
export type CheckTransition = 'opened' | 'pending' | 'cleared' | 'resolved' | null;
|
||||
export type AlertTransition = Extract<CheckTransition, 'opened' | 'resolved'>;
|
||||
export type LatencyTransition = 'degraded' | 'recovered' | null;
|
||||
|
||||
type BatchStatement = Parameters<Database['batch']>[0][number];
|
||||
|
||||
@@ -15,6 +16,7 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
db.insert(checks).values({
|
||||
monitorId: monitor.id,
|
||||
ok: result.ok,
|
||||
degraded: result.degraded,
|
||||
statusCode: result.statusCode,
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
@@ -36,7 +38,12 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
})
|
||||
.where(eq(monitors.id, monitor.id)),
|
||||
);
|
||||
return { statements, transition: null as CheckTransition, consecutiveFailures: monitor.consecutiveFailures };
|
||||
return {
|
||||
statements,
|
||||
transition: null as CheckTransition,
|
||||
latencyTransition: null as LatencyTransition,
|
||||
consecutiveFailures: monitor.consecutiveFailures,
|
||||
};
|
||||
}
|
||||
|
||||
const threshold = Math.max(1, monitor.failureThreshold);
|
||||
@@ -44,9 +51,13 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
// This deliberately uses the monitor snapshot. Concurrent manual and scheduled checks may lose one increment,
|
||||
// which delays confirmation by one check but cannot publish a false incident.
|
||||
const nextFailures = result.ok ? 0 : previousFailures + 1;
|
||||
const nextSlow = result.degraded ? monitor.consecutiveSlow + 1 : 0;
|
||||
const wasDown = monitor.lastOk === false;
|
||||
const isDown = !result.ok && nextFailures >= threshold;
|
||||
const confirmed = result.ok ? true : isDown ? false : undefined;
|
||||
const confirmedDegraded = nextSlow >= threshold;
|
||||
const latencyTransition: LatencyTransition =
|
||||
!monitor.lastDegraded && confirmedDegraded ? 'degraded' : monitor.lastDegraded && !confirmedDegraded ? 'recovered' : null;
|
||||
|
||||
statements.push(
|
||||
db
|
||||
@@ -54,6 +65,8 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
.set({
|
||||
...(confirmed === undefined ? {} : { lastOk: confirmed }),
|
||||
consecutiveFailures: nextFailures,
|
||||
consecutiveSlow: nextSlow,
|
||||
lastDegraded: confirmedDegraded,
|
||||
lastStatusCode: result.statusCode,
|
||||
lastLatencyMs: result.latencyMs,
|
||||
lastError: result.error,
|
||||
@@ -114,5 +127,5 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
} else if (!wasDown && !result.ok) transition = 'pending';
|
||||
else if (!wasDown && result.ok && previousFailures > 0) transition = 'cleared';
|
||||
|
||||
return { statements, transition, consecutiveFailures: nextFailures };
|
||||
return { statements, transition, latencyTransition, consecutiveFailures: nextFailures };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import type { monitors } from '../db/schema';
|
||||
import { readBodyLimited } from '../lib/read-body';
|
||||
|
||||
export type Monitor = typeof monitors.$inferSelect;
|
||||
|
||||
export type CheckResult = {
|
||||
ok: boolean;
|
||||
degraded: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const MAX_BODY_MATCH_BYTES = 256 * 1024;
|
||||
|
||||
function requestHeaders(monitor: Monitor) {
|
||||
let configured: Record<string, string> = {};
|
||||
if (monitor.requestHeaders) {
|
||||
try {
|
||||
configured = JSON.parse(monitor.requestHeaders) as Record<string, string>;
|
||||
} catch {
|
||||
// Stored values are API-validated. Ignore malformed legacy values instead of failing the check.
|
||||
}
|
||||
}
|
||||
const headers = new Headers(configured);
|
||||
if (!headers.has('User-Agent')) headers.set('User-Agent', 'Upwatch/1.0 (+uptime monitor)');
|
||||
if (monitor.method === 'POST' && monitor.requestBody !== null && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function runCheck(monitor: Monitor): Promise<CheckResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
@@ -16,19 +37,37 @@ export async function runCheck(monitor: Monitor): Promise<CheckResult> {
|
||||
method: monitor.method,
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(monitor.timeoutMs),
|
||||
headers: { 'User-Agent': 'Upwatch/1.0 (+uptime monitor)' },
|
||||
headers: requestHeaders(monitor),
|
||||
body: monitor.method === 'POST' ? monitor.requestBody : undefined,
|
||||
});
|
||||
await response.body?.cancel();
|
||||
const ok = response.status === monitor.expectedStatus;
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
const statusOk = response.status === monitor.expectedStatus;
|
||||
let keywordOk = true;
|
||||
if (monitor.expectKeyword !== null && monitor.method !== 'HEAD') {
|
||||
const body = await readBodyLimited(response, MAX_BODY_MATCH_BYTES, true);
|
||||
const contains =
|
||||
body !== null && new TextDecoder().decode(body).toLocaleLowerCase().includes(monitor.expectKeyword.toLocaleLowerCase());
|
||||
keywordOk = monitor.keywordInverted ? !contains : contains;
|
||||
} else {
|
||||
await response.body?.cancel();
|
||||
}
|
||||
const ok = statusOk && keywordOk;
|
||||
let error: string | null = null;
|
||||
if (!statusOk) error = `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`;
|
||||
else if (!keywordOk) {
|
||||
error = `${monitor.keywordInverted ? 'Response contained' : 'Response did not contain'} "${monitor.expectKeyword}"`.slice(0, 200);
|
||||
}
|
||||
return {
|
||||
ok,
|
||||
degraded: ok && monitor.degradedLatencyMs !== null && latencyMs > monitor.degradedLatencyMs,
|
||||
statusCode: response.status,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: ok ? null : `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
|
||||
latencyMs,
|
||||
error,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
degraded: false,
|
||||
statusCode: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message.slice(0, 200) : 'Request failed',
|
||||
|
||||
@@ -74,24 +74,45 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
|
||||
let aiMessagesQueued = 0;
|
||||
const notificationBudget: NotificationBudget = { remaining: MAX_NOTIFICATIONS_PER_RUN };
|
||||
const notifications = persisted.flatMap((item) => {
|
||||
if (item.transition !== 'opened' && item.transition !== 'resolved') return [];
|
||||
const kind: AlertTransition = item.transition;
|
||||
const work: Promise<unknown>[] = [
|
||||
dispatchNotification(
|
||||
env,
|
||||
{
|
||||
monitor: { id: item.monitor.id, name: item.monitor.name, url: item.monitor.url },
|
||||
kind: kind === 'opened' ? 'down' : 'recovered',
|
||||
incidentId: null,
|
||||
title: kind === 'opened' ? `${item.monitor.name} is down` : `${item.monitor.name} recovered`,
|
||||
body: item.result.error,
|
||||
statusCode: item.result.statusCode,
|
||||
error: item.result.error,
|
||||
at: item.checkedAt,
|
||||
},
|
||||
notificationBudget,
|
||||
),
|
||||
];
|
||||
const work: Promise<unknown>[] = [];
|
||||
if (item.transition === 'opened' || item.transition === 'resolved') {
|
||||
const kind: AlertTransition = item.transition;
|
||||
work.push(
|
||||
dispatchNotification(
|
||||
env,
|
||||
{
|
||||
monitor: { id: item.monitor.id, name: item.monitor.name, url: item.monitor.url },
|
||||
kind: kind === 'opened' ? 'down' : 'recovered',
|
||||
incidentId: null,
|
||||
title: kind === 'opened' ? `${item.monitor.name} is down` : `${item.monitor.name} recovered`,
|
||||
body: item.result.error,
|
||||
statusCode: item.result.statusCode,
|
||||
error: item.result.error,
|
||||
at: item.checkedAt,
|
||||
},
|
||||
notificationBudget,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (item.latencyTransition) {
|
||||
const degraded = item.latencyTransition === 'degraded';
|
||||
work.push(
|
||||
dispatchNotification(
|
||||
env,
|
||||
{
|
||||
monitor: { id: item.monitor.id, name: item.monitor.name, url: item.monitor.url },
|
||||
kind: degraded ? 'degraded' : 'recovered_degraded',
|
||||
incidentId: null,
|
||||
title: degraded ? `${item.monitor.name} performance degraded` : `${item.monitor.name} performance recovered`,
|
||||
body: degraded ? `Response time was ${item.result.latencyMs} ms.` : 'Response time returned to normal.',
|
||||
statusCode: item.result.statusCode,
|
||||
error: item.result.error,
|
||||
at: item.checkedAt,
|
||||
},
|
||||
notificationBudget,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (item.transition === 'opened' && item.monitor.alertsEnabled && aiMessagesQueued < MAX_AI_MESSAGES_PER_RUN) {
|
||||
aiMessagesQueued += 1;
|
||||
work.push(generateIncidentMessage(env, { monitor: item.monitor, result: item.result }));
|
||||
|
||||
@@ -46,6 +46,11 @@ export const monitors = sqliteTable(
|
||||
url: text('url').notNull(),
|
||||
method: text('method').notNull().default('GET'),
|
||||
expectedStatus: integer('expected_status').notNull().default(200),
|
||||
expectKeyword: text('expect_keyword'),
|
||||
keywordInverted: integer('keyword_inverted', { mode: 'boolean' }).notNull().default(false),
|
||||
requestHeaders: text('request_headers'),
|
||||
requestBody: text('request_body'),
|
||||
degradedLatencyMs: integer('degraded_latency_ms'),
|
||||
intervalSeconds: integer('interval_seconds').notNull().default(300),
|
||||
timeoutMs: integer('timeout_ms').notNull().default(10_000),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
@@ -56,8 +61,12 @@ export const monitors = sqliteTable(
|
||||
failureThreshold: integer('failure_threshold').notNull().default(2),
|
||||
/** Failures since the last successful check. Maintenance checks do not change this value. */
|
||||
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
|
||||
/** Slow successful checks since latency last recovered. Maintenance checks do not change this value. */
|
||||
consecutiveSlow: integer('consecutive_slow').notNull().default(0),
|
||||
/** Confirmed state, not the raw latest result. */
|
||||
lastOk: integer('last_ok', { mode: 'boolean' }),
|
||||
/** Confirmed degraded state. A down monitor is never degraded. */
|
||||
lastDegraded: integer('last_degraded', { mode: 'boolean' }).notNull().default(false),
|
||||
lastStatusCode: integer('last_status_code'),
|
||||
lastLatencyMs: integer('last_latency_ms'),
|
||||
lastError: text('last_error'),
|
||||
@@ -112,6 +121,7 @@ export const checks = sqliteTable(
|
||||
error: text('error'),
|
||||
checkedAt: integer('checked_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
maintenance: integer('maintenance', { mode: 'boolean' }).notNull().default(false),
|
||||
degraded: integer('degraded', { mode: 'boolean' }).notNull().default(false),
|
||||
},
|
||||
(table) => [index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt)],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
|
||||
if (!response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let exceeded = false;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const remaining = maximum - total;
|
||||
if (value.byteLength > remaining) {
|
||||
if (truncate && remaining > 0) {
|
||||
chunks.push(value.subarray(0, remaining));
|
||||
total += remaining;
|
||||
}
|
||||
exceeded = true;
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (exceeded && !truncate) return null;
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body.buffer;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export type TelegramConfig = { botToken: string; chatId: string };
|
||||
export type ChannelConfig = UrlConfig | TelegramConfig;
|
||||
|
||||
export type NotificationEvent = {
|
||||
kind: 'down' | 'recovered' | 'manual_opened' | 'manual_update' | 'test';
|
||||
kind: 'down' | 'recovered' | 'degraded' | 'recovered_degraded' | 'manual_opened' | 'manual_update' | 'test';
|
||||
monitor: { id: number; name: string; url: string } | null;
|
||||
incidentId: number | null;
|
||||
title: string;
|
||||
@@ -49,6 +49,8 @@ export function eventLabel(kind: NotificationEvent['kind']) {
|
||||
return {
|
||||
down: 'Service down',
|
||||
recovered: 'Service recovered',
|
||||
degraded: 'Service degraded',
|
||||
recovered_degraded: 'Performance recovered',
|
||||
manual_opened: 'Incident opened',
|
||||
manual_update: 'Incident update',
|
||||
test: 'Test notification',
|
||||
@@ -57,6 +59,7 @@ export function eventLabel(kind: NotificationEvent['kind']) {
|
||||
|
||||
export function eventColor(kind: NotificationEvent['kind']) {
|
||||
if (kind === 'down' || kind === 'manual_opened') return '#dc2626';
|
||||
if (kind === 'recovered') return '#16a34a';
|
||||
if (kind === 'recovered' || kind === 'recovered_degraded') return '#16a34a';
|
||||
if (kind === 'degraded') return '#d97706';
|
||||
return '#2563eb';
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { checks, incidentMonitors, incidents, maintenanceWindowMonitors, monitor
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { loadActiveMaintenance } from '../maintenance/windows';
|
||||
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||
import { readBodyLimited } from '../lib/read-body';
|
||||
import { dispatchNotification } from '../notifications/dispatch';
|
||||
|
||||
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
|
||||
@@ -17,6 +18,11 @@ type ParsedMonitorInput = {
|
||||
url?: string;
|
||||
method?: MonitorMethod;
|
||||
expectedStatus?: number;
|
||||
expectKeyword?: string | null;
|
||||
keywordInverted?: boolean;
|
||||
requestHeaders?: string | null;
|
||||
requestBody?: string | null;
|
||||
degradedLatencyMs?: number | null;
|
||||
intervalSeconds?: number;
|
||||
timeoutMs?: number;
|
||||
retryCount?: number;
|
||||
@@ -33,6 +39,8 @@ const FAVICON_FETCH_TIMEOUT_MS = 5_000;
|
||||
const MAX_FAVICON_BYTES = 1024 * 1024;
|
||||
const MAX_HEAD_BYTES = 128 * 1024;
|
||||
const MAX_REDIRECTS = 3;
|
||||
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,64}$/;
|
||||
const FORBIDDEN_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection']);
|
||||
|
||||
type FaviconResult = {
|
||||
body: ArrayBuffer;
|
||||
@@ -44,44 +52,6 @@ type EdgeCache = {
|
||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||
};
|
||||
|
||||
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
|
||||
if (!response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let exceeded = false;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const remaining = maximum - total;
|
||||
if (value.byteLength > remaining) {
|
||||
if (truncate && remaining > 0) {
|
||||
chunks.push(value.subarray(0, remaining));
|
||||
total += remaining;
|
||||
}
|
||||
exceeded = true;
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (exceeded && !truncate) return null;
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body.buffer;
|
||||
}
|
||||
|
||||
async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FAVICON_FETCH_TIMEOUT_MS);
|
||||
@@ -232,7 +202,7 @@ export function parseInteger(
|
||||
return { ok: true, value: value as number };
|
||||
}
|
||||
|
||||
export function parseMonitorInput(body: unknown, partial = false): ParseResult {
|
||||
export function parseMonitorInput(body: unknown, partial = false, storedMethod?: MonitorMethod): ParseResult {
|
||||
if (!isRecord(body)) return { ok: false, message: 'Invalid request body' };
|
||||
|
||||
const value: ParsedMonitorInput = {};
|
||||
@@ -263,6 +233,55 @@ export function parseMonitorInput(body: unknown, partial = false): ParseResult {
|
||||
value.method = body.method as MonitorMethod;
|
||||
}
|
||||
|
||||
if ('expectKeyword' in body) {
|
||||
if (body.expectKeyword === null) value.expectKeyword = null;
|
||||
else if (typeof body.expectKeyword !== 'string' || body.expectKeyword.trim().length < 1 || body.expectKeyword.trim().length > 200) {
|
||||
return { ok: false, message: 'expectKeyword must be between 1 and 200 characters or null' };
|
||||
} else value.expectKeyword = body.expectKeyword.trim();
|
||||
}
|
||||
if ('keywordInverted' in body) {
|
||||
if (typeof body.keywordInverted !== 'boolean') return { ok: false, message: 'keywordInverted must be a boolean' };
|
||||
value.keywordInverted = body.keywordInverted;
|
||||
}
|
||||
if ('requestHeaders' in body) {
|
||||
if (body.requestHeaders === null) value.requestHeaders = null;
|
||||
else if (!isRecord(body.requestHeaders)) return { ok: false, message: 'requestHeaders must be an object or null' };
|
||||
else {
|
||||
const entries = Object.entries(body.requestHeaders);
|
||||
if (entries.length > 10) return { ok: false, message: 'requestHeaders cannot contain more than 10 headers' };
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [name, headerValue] of entries) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!HEADER_NAME.test(name)) return { ok: false, message: `Invalid header name: ${name}` };
|
||||
if (FORBIDDEN_HEADERS.has(lowerName) || lowerName.startsWith('cf-')) {
|
||||
return { ok: false, message: `Header is not allowed: ${name}` };
|
||||
}
|
||||
if (typeof headerValue !== 'string' || headerValue.length > 512 || /[\r\n]/.test(headerValue)) {
|
||||
return { ok: false, message: `Invalid value for header: ${name}` };
|
||||
}
|
||||
normalized[name] = headerValue;
|
||||
}
|
||||
value.requestHeaders = JSON.stringify(normalized);
|
||||
}
|
||||
}
|
||||
if ('requestBody' in body) {
|
||||
if (body.requestBody === null) value.requestBody = null;
|
||||
else if (typeof body.requestBody !== 'string' || body.requestBody.length > 8_192) {
|
||||
return { ok: false, message: 'requestBody must be no more than 8192 characters or null' };
|
||||
} else value.requestBody = body.requestBody;
|
||||
if (value.requestBody !== null && (value.method ?? storedMethod) !== 'POST') {
|
||||
return { ok: false, message: 'requestBody can only be used with POST monitors' };
|
||||
}
|
||||
}
|
||||
if ('degradedLatencyMs' in body) {
|
||||
if (body.degradedLatencyMs === null) value.degradedLatencyMs = null;
|
||||
else {
|
||||
const parsed = parseInteger(body.degradedLatencyMs, 'degradedLatencyMs', 1, 30_000);
|
||||
if (!parsed.ok) return parsed;
|
||||
value.degradedLatencyMs = parsed.value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, label, minimum, maximum] of [
|
||||
['expectedStatus', 'expectedStatus', 100, 599],
|
||||
['intervalSeconds', 'intervalSeconds', 300, 86_400],
|
||||
@@ -504,6 +523,11 @@ monitorRoutes.post('/', async (context) => {
|
||||
url: parsed.value.url!,
|
||||
method: parsed.value.method!,
|
||||
expectedStatus: parsed.value.expectedStatus!,
|
||||
expectKeyword: parsed.value.expectKeyword ?? null,
|
||||
keywordInverted: parsed.value.keywordInverted ?? false,
|
||||
requestHeaders: parsed.value.requestHeaders ?? null,
|
||||
requestBody: parsed.value.requestBody ?? null,
|
||||
degradedLatencyMs: parsed.value.degradedLatencyMs ?? null,
|
||||
intervalSeconds: parsed.value.intervalSeconds!,
|
||||
timeoutMs: parsed.value.timeoutMs!,
|
||||
retryCount: parsed.value.retryCount ?? 1,
|
||||
@@ -521,6 +545,9 @@ monitorRoutes.post('/', async (context) => {
|
||||
monitorRoutes.patch('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [existing] = await db.select({ method: monitors.method }).from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!existing) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
@@ -529,13 +556,13 @@ monitorRoutes.patch('/:id', async (context) => {
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
|
||||
const parsed = parseMonitorInput(body, true);
|
||||
const parsed = parseMonitorInput(body, true, existing.method as MonitorMethod);
|
||||
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
|
||||
if (Object.keys(parsed.value).length === 0) {
|
||||
return context.json({ message: 'Provide at least one field to update' }, 400);
|
||||
}
|
||||
|
||||
const [monitor] = await getDb(context.env)
|
||||
const [monitor] = await db
|
||||
.update(monitors)
|
||||
.set({ ...parsed.value, updatedAt: new Date() })
|
||||
.where(eq(monitors.id, id))
|
||||
@@ -573,7 +600,13 @@ monitorRoutes.post('/:id/check', async (context) => {
|
||||
const result = await runCheckWithRetries(monitor);
|
||||
const checkedAt = new Date();
|
||||
const activeMaintenance = await loadActiveMaintenance(db, checkedAt);
|
||||
const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt, activeMaintenance.has(monitor.id));
|
||||
const { statements, transition, latencyTransition } = buildResultStatements(
|
||||
db,
|
||||
monitor,
|
||||
result,
|
||||
checkedAt,
|
||||
activeMaintenance.has(monitor.id),
|
||||
);
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
if (transition === 'opened' || transition === 'resolved') {
|
||||
await dispatchNotification(context.env, {
|
||||
@@ -590,9 +623,22 @@ monitorRoutes.post('/:id/check', async (context) => {
|
||||
await generateIncidentMessage(context.env, { monitor, result });
|
||||
}
|
||||
}
|
||||
if (latencyTransition) {
|
||||
const degraded = latencyTransition === 'degraded';
|
||||
await dispatchNotification(context.env, {
|
||||
monitor: { id: monitor.id, name: monitor.name, url: monitor.url },
|
||||
kind: degraded ? 'degraded' : 'recovered_degraded',
|
||||
incidentId: null,
|
||||
title: degraded ? `${monitor.name} performance degraded` : `${monitor.name} performance recovered`,
|
||||
body: degraded ? `Response time was ${result.latencyMs} ms.` : 'Response time returned to normal.',
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
at: checkedAt,
|
||||
});
|
||||
}
|
||||
const [updated] = await db.select().from(monitors).where(eq(monitors.id, monitor.id)).limit(1);
|
||||
|
||||
return context.json({ result, transition, monitor: updated });
|
||||
return context.json({ result, transition, latencyTransition, monitor: updated });
|
||||
});
|
||||
|
||||
export default monitorRoutes;
|
||||
|
||||
+333
-329
@@ -1,329 +1,333 @@
|
||||
import { and, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { deterministicIncidentMessage } from '../ai/fallback-message';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, incidentMonitors, incidents, incidentUpdates, monitorDailyStats, monitors } from '../db/schema';
|
||||
import { loadActiveMaintenance, type ActiveMaintenance } from '../maintenance/windows';
|
||||
import { resolveFavicon } from './monitors';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FAVICON_CACHE_SECONDS = 86_400;
|
||||
type ServiceStatus = 'up' | 'down' | 'unknown' | 'maintenance';
|
||||
type OverallStatus = 'operational' | 'degraded' | 'down';
|
||||
type DailyAggregate = { monitorId: number; day: Date; totalChecks: number; upChecks: number };
|
||||
type EdgeCache = {
|
||||
match(request: RequestInfo | URL): Promise<Response | undefined>;
|
||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||
};
|
||||
|
||||
type PublicUpdate = { body: string; status: string; createdAt: Date };
|
||||
type PublicIncident = {
|
||||
id: number;
|
||||
title: string | null;
|
||||
status: string;
|
||||
impact: string;
|
||||
source: string;
|
||||
startedAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
durationMs: number | null;
|
||||
startStatusCode: number | null;
|
||||
};
|
||||
|
||||
function parseId(rawId: string) {
|
||||
const id = Number(rawId);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
function parseLimit(raw: string | undefined, fallback: number, maximum: number) {
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : fallback;
|
||||
}
|
||||
function roundUptime(upChecks: number, totalChecks: number) {
|
||||
return totalChecks > 0 ? Math.round((upChecks / totalChecks) * 1_000) / 10 : null;
|
||||
}
|
||||
function serviceStatus(lastOk: boolean | null): ServiceStatus {
|
||||
if (lastOk === true) return 'up';
|
||||
if (lastOk === false) return 'down';
|
||||
return 'unknown';
|
||||
}
|
||||
function overallStatus(statuses: ServiceStatus[], manualImpacts: string[]): OverallStatus {
|
||||
let checked = 0;
|
||||
let down = 0;
|
||||
for (const status of statuses) {
|
||||
if (status === 'unknown' || status === 'maintenance') continue;
|
||||
checked += 1;
|
||||
if (status === 'down') down += 1;
|
||||
}
|
||||
let severity = down === 0 ? 0 : down === checked ? 2 : 1;
|
||||
for (const impact of manualImpacts)
|
||||
severity = Math.max(severity, impact === 'critical' ? 2 : impact === 'minor' || impact === 'major' ? 1 : 0);
|
||||
return severity === 2 ? 'down' : severity === 1 ? 'degraded' : 'operational';
|
||||
}
|
||||
function publicIncidentTitle(incident: Pick<PublicIncident, 'title' | 'source'>) {
|
||||
return incident.title ?? (incident.source === 'auto' ? 'Service disruption' : 'Incident update');
|
||||
}
|
||||
|
||||
async function loadServices(db: ReturnType<typeof getDb>, incidentIds: number[]) {
|
||||
if (incidentIds.length === 0) return new Map<number, Array<{ id: number; name: string }>>();
|
||||
const rows = await db
|
||||
.select({ incidentId: incidentMonitors.incidentId, id: monitors.id, name: monitors.name })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(monitors, eq(monitors.id, incidentMonitors.monitorId))
|
||||
.where(inArray(incidentMonitors.incidentId, incidentIds));
|
||||
const grouped = new Map<number, Array<{ id: number; name: string }>>();
|
||||
for (const row of rows) {
|
||||
const services = grouped.get(row.incidentId);
|
||||
if (services) services.push({ id: row.id, name: row.name });
|
||||
else grouped.set(row.incidentId, [{ id: row.id, name: row.name }]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function loadLatestUpdates(db: ReturnType<typeof getDb>, incidentIds: number[]) {
|
||||
if (incidentIds.length === 0) return new Map<number, PublicUpdate>();
|
||||
const rows = await db
|
||||
.select({
|
||||
incidentId: incidentUpdates.incidentId,
|
||||
body: incidentUpdates.body,
|
||||
status: incidentUpdates.status,
|
||||
createdAt: incidentUpdates.createdAt,
|
||||
})
|
||||
.from(incidentUpdates)
|
||||
.where(inArray(incidentUpdates.incidentId, incidentIds))
|
||||
.orderBy(desc(incidentUpdates.createdAt), desc(incidentUpdates.id));
|
||||
const latest = new Map<number, PublicUpdate>();
|
||||
for (const row of rows) if (!latest.has(row.incidentId)) latest.set(row.incidentId, row);
|
||||
return latest;
|
||||
}
|
||||
|
||||
const incidentSelection = {
|
||||
id: incidents.id,
|
||||
title: incidents.title,
|
||||
status: incidents.status,
|
||||
impact: incidents.impact,
|
||||
source: incidents.source,
|
||||
startedAt: incidents.startedAt,
|
||||
resolvedAt: incidents.resolvedAt,
|
||||
durationMs: incidents.durationMs,
|
||||
startStatusCode: incidents.startStatusCode,
|
||||
};
|
||||
|
||||
const statusRoutes = new Hono<{ Bindings: Env }>();
|
||||
|
||||
statusRoutes.get('/', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const monitorRows = await db
|
||||
.select({
|
||||
id: monitors.id,
|
||||
name: monitors.name,
|
||||
lastOk: monitors.lastOk,
|
||||
lastStatusCode: monitors.lastStatusCode,
|
||||
lastCheckedAt: monitors.lastCheckedAt,
|
||||
})
|
||||
.from(monitors)
|
||||
.where(eq(monitors.enabled, true))
|
||||
.orderBy(monitors.createdAt);
|
||||
const activeIncidentRows = await db
|
||||
.select(incidentSelection)
|
||||
.from(incidents)
|
||||
.where(isNull(incidents.resolvedAt))
|
||||
.orderBy(desc(incidents.startedAt));
|
||||
const incidentIds = activeIncidentRows.map((incident) => incident.id);
|
||||
const [incidentServices, latestUpdates] = await Promise.all([loadServices(db, incidentIds), loadLatestUpdates(db, incidentIds)]);
|
||||
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const cutoff = today - 89 * DAY_MS;
|
||||
const monitorIds = monitorRows.map((monitor) => monitor.id);
|
||||
let historicalRows: DailyAggregate[] = [];
|
||||
let todayRows: DailyAggregate[] = [];
|
||||
let activeMaintenance = new Map<number, ActiveMaintenance>();
|
||||
if (monitorIds.length > 0) {
|
||||
[historicalRows, todayRows, activeMaintenance] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
monitorId: monitorDailyStats.monitorId,
|
||||
day: monitorDailyStats.day,
|
||||
totalChecks: monitorDailyStats.totalChecks,
|
||||
upChecks: monitorDailyStats.upChecks,
|
||||
})
|
||||
.from(monitorDailyStats)
|
||||
.where(
|
||||
and(
|
||||
inArray(monitorDailyStats.monitorId, monitorIds),
|
||||
gte(monitorDailyStats.day, new Date(cutoff)),
|
||||
lt(monitorDailyStats.day, new Date(today)),
|
||||
),
|
||||
)
|
||||
.orderBy(monitorDailyStats.day),
|
||||
db
|
||||
.select({
|
||||
monitorId: checks.monitorId,
|
||||
day: sql<Date>`cast(${today} as integer)`,
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
})
|
||||
.from(checks)
|
||||
.where(and(inArray(checks.monitorId, monitorIds), eq(checks.maintenance, false), gte(checks.checkedAt, new Date(today))))
|
||||
.groupBy(checks.monitorId),
|
||||
loadActiveMaintenance(db, now),
|
||||
]);
|
||||
}
|
||||
const bucketsByMonitor = new Map<number, DailyAggregate[]>();
|
||||
for (const row of [...historicalRows, ...todayRows]) {
|
||||
const buckets = bucketsByMonitor.get(row.monitorId);
|
||||
if (buckets) buckets.push(row);
|
||||
else bucketsByMonitor.set(row.monitorId, [row]);
|
||||
}
|
||||
const activeIncidentByMonitor = new Map<number, PublicIncident>();
|
||||
for (const incident of activeIncidentRows) {
|
||||
for (const service of incidentServices.get(incident.id) ?? [])
|
||||
if (!activeIncidentByMonitor.has(service.id)) activeIncidentByMonitor.set(service.id, incident);
|
||||
}
|
||||
const services = monitorRows.map((monitor) => {
|
||||
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
|
||||
let totalChecks = 0;
|
||||
let upChecks = 0;
|
||||
const history = buckets.map((bucket) => {
|
||||
totalChecks += bucket.totalChecks;
|
||||
upChecks += bucket.upChecks;
|
||||
return {
|
||||
day: bucket.day instanceof Date ? bucket.day.getTime() : Number(bucket.day),
|
||||
uptimePct: roundUptime(bucket.upChecks, bucket.totalChecks),
|
||||
};
|
||||
});
|
||||
const incident = activeIncidentByMonitor.get(monitor.id);
|
||||
const maintenance = activeMaintenance.get(monitor.id);
|
||||
return {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
status: maintenance ? ('maintenance' as const) : serviceStatus(monitor.lastOk),
|
||||
message:
|
||||
!maintenance && monitor.lastOk === false
|
||||
? incident
|
||||
? (latestUpdates.get(incident.id)?.body ?? deterministicIncidentMessage(incident.startStatusCode))
|
||||
: deterministicIncidentMessage(monitor.lastStatusCode)
|
||||
: null,
|
||||
maintenance: maintenance ? { name: maintenance.name, endsAt: maintenance.endsAt.toISOString() } : null,
|
||||
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
|
||||
uptime90d: roundUptime(upChecks, totalChecks),
|
||||
history,
|
||||
};
|
||||
});
|
||||
const activeIncidents = activeIncidentRows.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
latestUpdate: latestUpdates.get(incident.id) ?? null,
|
||||
services: incidentServices.get(incident.id) ?? [],
|
||||
}));
|
||||
return context.json({
|
||||
overall: overallStatus(
|
||||
services.map((service) => service.status),
|
||||
activeIncidentRows.filter((incident) => incident.source === 'manual').map((incident) => incident.impact),
|
||||
),
|
||||
updatedAt: Date.now(),
|
||||
services,
|
||||
activeIncidents,
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/incidents', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const limit = parseLimit(context.req.query('limit'), 20, 20);
|
||||
const rows = await db
|
||||
.select(incidentSelection)
|
||||
.from(incidents)
|
||||
.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),
|
||||
);
|
||||
return context.json({
|
||||
incidents: rows.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
resolvedAt: incident.resolvedAt?.toISOString() ?? null,
|
||||
durationMs: incident.durationMs,
|
||||
services: services.get(incident.id) ?? [],
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/incidents/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [incident] = await db.select(incidentSelection).from(incidents).where(eq(incidents.id, id)).limit(1);
|
||||
if (!incident) return context.json({ message: 'Incident not found' }, 404);
|
||||
const [services, updates] = await Promise.all([
|
||||
loadServices(db, [id]),
|
||||
db
|
||||
.select({ status: incidentUpdates.status, body: incidentUpdates.body, createdAt: incidentUpdates.createdAt })
|
||||
.from(incidentUpdates)
|
||||
.where(eq(incidentUpdates.incidentId, id))
|
||||
.orderBy(incidentUpdates.createdAt, incidentUpdates.id),
|
||||
]);
|
||||
const timeline =
|
||||
updates.length > 0
|
||||
? updates
|
||||
: [{ status: incident.status, body: deterministicIncidentMessage(incident.startStatusCode), createdAt: incident.startedAt }];
|
||||
return context.json({
|
||||
incident: {
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
resolvedAt: incident.resolvedAt?.toISOString() ?? null,
|
||||
durationMs: incident.durationMs,
|
||||
services: services.get(id) ?? [],
|
||||
updates: timeline,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/:id/favicon', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Service not found' }, 404);
|
||||
const [monitor] = await getDb(context.env)
|
||||
.select({ url: monitors.url })
|
||||
.from(monitors)
|
||||
.where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
|
||||
.limit(1);
|
||||
if (!monitor) return context.json({ message: 'Service not found' }, 404);
|
||||
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
|
||||
let cache: EdgeCache | null = null;
|
||||
try {
|
||||
const defaultCache = (caches as CacheStorage & { readonly default: EdgeCache }).default;
|
||||
const cached = await defaultCache.match(cacheKey);
|
||||
if (cached) return cached;
|
||||
cache = defaultCache;
|
||||
} catch {
|
||||
// Cache API availability is best-effort.
|
||||
}
|
||||
const favicon = await resolveFavicon(monitor.url);
|
||||
if (!favicon) return context.json({ message: 'No favicon' }, 404);
|
||||
const response = new Response(favicon.body, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
'Content-Length': String(favicon.body.byteLength),
|
||||
'Content-Type': favicon.contentType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
if (cache) context.executionCtx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => undefined));
|
||||
return response;
|
||||
});
|
||||
|
||||
export default statusRoutes;
|
||||
import { and, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { DEGRADED_MESSAGE, deterministicIncidentMessage } from '../ai/fallback-message';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, incidentMonitors, incidents, incidentUpdates, monitorDailyStats, monitors } from '../db/schema';
|
||||
import { loadActiveMaintenance, type ActiveMaintenance } from '../maintenance/windows';
|
||||
import { resolveFavicon } from './monitors';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FAVICON_CACHE_SECONDS = 86_400;
|
||||
type ServiceStatus = 'up' | 'degraded' | 'down' | 'unknown' | 'maintenance';
|
||||
type OverallStatus = 'operational' | 'degraded' | 'down';
|
||||
type DailyAggregate = { monitorId: number; day: Date; totalChecks: number; upChecks: number };
|
||||
type EdgeCache = {
|
||||
match(request: RequestInfo | URL): Promise<Response | undefined>;
|
||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||
};
|
||||
|
||||
type PublicUpdate = { body: string; status: string; createdAt: Date };
|
||||
type PublicIncident = {
|
||||
id: number;
|
||||
title: string | null;
|
||||
status: string;
|
||||
impact: string;
|
||||
source: string;
|
||||
startedAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
durationMs: number | null;
|
||||
startStatusCode: number | null;
|
||||
};
|
||||
|
||||
function parseId(rawId: string) {
|
||||
const id = Number(rawId);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
function parseLimit(raw: string | undefined, fallback: number, maximum: number) {
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : fallback;
|
||||
}
|
||||
function roundUptime(upChecks: number, totalChecks: number) {
|
||||
return totalChecks > 0 ? Math.round((upChecks / totalChecks) * 1_000) / 10 : null;
|
||||
}
|
||||
function serviceStatus(lastOk: boolean | null, lastDegraded: boolean): ServiceStatus {
|
||||
if (lastOk === true) return lastDegraded ? 'degraded' : 'up';
|
||||
if (lastOk === false) return 'down';
|
||||
return 'unknown';
|
||||
}
|
||||
function overallStatus(statuses: ServiceStatus[], manualImpacts: string[]): OverallStatus {
|
||||
let checked = 0;
|
||||
let down = 0;
|
||||
for (const status of statuses) {
|
||||
if (status === 'unknown' || status === 'maintenance') continue;
|
||||
checked += 1;
|
||||
if (status === 'down') down += 1;
|
||||
}
|
||||
let severity = down === 0 ? 0 : down === checked ? 2 : 1;
|
||||
if (statuses.includes('degraded')) severity = Math.max(severity, 1);
|
||||
for (const impact of manualImpacts)
|
||||
severity = Math.max(severity, impact === 'critical' ? 2 : impact === 'minor' || impact === 'major' ? 1 : 0);
|
||||
return severity === 2 ? 'down' : severity === 1 ? 'degraded' : 'operational';
|
||||
}
|
||||
function publicIncidentTitle(incident: Pick<PublicIncident, 'title' | 'source'>) {
|
||||
return incident.title ?? (incident.source === 'auto' ? 'Service disruption' : 'Incident update');
|
||||
}
|
||||
|
||||
async function loadServices(db: ReturnType<typeof getDb>, incidentIds: number[]) {
|
||||
if (incidentIds.length === 0) return new Map<number, Array<{ id: number; name: string }>>();
|
||||
const rows = await db
|
||||
.select({ incidentId: incidentMonitors.incidentId, id: monitors.id, name: monitors.name })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(monitors, eq(monitors.id, incidentMonitors.monitorId))
|
||||
.where(inArray(incidentMonitors.incidentId, incidentIds));
|
||||
const grouped = new Map<number, Array<{ id: number; name: string }>>();
|
||||
for (const row of rows) {
|
||||
const services = grouped.get(row.incidentId);
|
||||
if (services) services.push({ id: row.id, name: row.name });
|
||||
else grouped.set(row.incidentId, [{ id: row.id, name: row.name }]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function loadLatestUpdates(db: ReturnType<typeof getDb>, incidentIds: number[]) {
|
||||
if (incidentIds.length === 0) return new Map<number, PublicUpdate>();
|
||||
const rows = await db
|
||||
.select({
|
||||
incidentId: incidentUpdates.incidentId,
|
||||
body: incidentUpdates.body,
|
||||
status: incidentUpdates.status,
|
||||
createdAt: incidentUpdates.createdAt,
|
||||
})
|
||||
.from(incidentUpdates)
|
||||
.where(inArray(incidentUpdates.incidentId, incidentIds))
|
||||
.orderBy(desc(incidentUpdates.createdAt), desc(incidentUpdates.id));
|
||||
const latest = new Map<number, PublicUpdate>();
|
||||
for (const row of rows) if (!latest.has(row.incidentId)) latest.set(row.incidentId, row);
|
||||
return latest;
|
||||
}
|
||||
|
||||
const incidentSelection = {
|
||||
id: incidents.id,
|
||||
title: incidents.title,
|
||||
status: incidents.status,
|
||||
impact: incidents.impact,
|
||||
source: incidents.source,
|
||||
startedAt: incidents.startedAt,
|
||||
resolvedAt: incidents.resolvedAt,
|
||||
durationMs: incidents.durationMs,
|
||||
startStatusCode: incidents.startStatusCode,
|
||||
};
|
||||
|
||||
const statusRoutes = new Hono<{ Bindings: Env }>();
|
||||
|
||||
statusRoutes.get('/', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const monitorRows = await db
|
||||
.select({
|
||||
id: monitors.id,
|
||||
name: monitors.name,
|
||||
lastOk: monitors.lastOk,
|
||||
lastDegraded: monitors.lastDegraded,
|
||||
lastStatusCode: monitors.lastStatusCode,
|
||||
lastCheckedAt: monitors.lastCheckedAt,
|
||||
})
|
||||
.from(monitors)
|
||||
.where(eq(monitors.enabled, true))
|
||||
.orderBy(monitors.createdAt);
|
||||
const activeIncidentRows = await db
|
||||
.select(incidentSelection)
|
||||
.from(incidents)
|
||||
.where(isNull(incidents.resolvedAt))
|
||||
.orderBy(desc(incidents.startedAt));
|
||||
const incidentIds = activeIncidentRows.map((incident) => incident.id);
|
||||
const [incidentServices, latestUpdates] = await Promise.all([loadServices(db, incidentIds), loadLatestUpdates(db, incidentIds)]);
|
||||
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const cutoff = today - 89 * DAY_MS;
|
||||
const monitorIds = monitorRows.map((monitor) => monitor.id);
|
||||
let historicalRows: DailyAggregate[] = [];
|
||||
let todayRows: DailyAggregate[] = [];
|
||||
let activeMaintenance = new Map<number, ActiveMaintenance>();
|
||||
if (monitorIds.length > 0) {
|
||||
[historicalRows, todayRows, activeMaintenance] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
monitorId: monitorDailyStats.monitorId,
|
||||
day: monitorDailyStats.day,
|
||||
totalChecks: monitorDailyStats.totalChecks,
|
||||
upChecks: monitorDailyStats.upChecks,
|
||||
})
|
||||
.from(monitorDailyStats)
|
||||
.where(
|
||||
and(
|
||||
inArray(monitorDailyStats.monitorId, monitorIds),
|
||||
gte(monitorDailyStats.day, new Date(cutoff)),
|
||||
lt(monitorDailyStats.day, new Date(today)),
|
||||
),
|
||||
)
|
||||
.orderBy(monitorDailyStats.day),
|
||||
db
|
||||
.select({
|
||||
monitorId: checks.monitorId,
|
||||
day: sql<Date>`cast(${today} as integer)`,
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
})
|
||||
.from(checks)
|
||||
.where(and(inArray(checks.monitorId, monitorIds), eq(checks.maintenance, false), gte(checks.checkedAt, new Date(today))))
|
||||
.groupBy(checks.monitorId),
|
||||
loadActiveMaintenance(db, now),
|
||||
]);
|
||||
}
|
||||
const bucketsByMonitor = new Map<number, DailyAggregate[]>();
|
||||
for (const row of [...historicalRows, ...todayRows]) {
|
||||
const buckets = bucketsByMonitor.get(row.monitorId);
|
||||
if (buckets) buckets.push(row);
|
||||
else bucketsByMonitor.set(row.monitorId, [row]);
|
||||
}
|
||||
const activeIncidentByMonitor = new Map<number, PublicIncident>();
|
||||
for (const incident of activeIncidentRows) {
|
||||
for (const service of incidentServices.get(incident.id) ?? [])
|
||||
if (!activeIncidentByMonitor.has(service.id)) activeIncidentByMonitor.set(service.id, incident);
|
||||
}
|
||||
const services = monitorRows.map((monitor) => {
|
||||
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
|
||||
let totalChecks = 0;
|
||||
let upChecks = 0;
|
||||
const history = buckets.map((bucket) => {
|
||||
totalChecks += bucket.totalChecks;
|
||||
upChecks += bucket.upChecks;
|
||||
return {
|
||||
day: bucket.day instanceof Date ? bucket.day.getTime() : Number(bucket.day),
|
||||
uptimePct: roundUptime(bucket.upChecks, bucket.totalChecks),
|
||||
};
|
||||
});
|
||||
const incident = activeIncidentByMonitor.get(monitor.id);
|
||||
const maintenance = activeMaintenance.get(monitor.id);
|
||||
return {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
status: maintenance ? ('maintenance' as const) : serviceStatus(monitor.lastOk, monitor.lastDegraded),
|
||||
message:
|
||||
!maintenance && monitor.lastOk === false
|
||||
? incident
|
||||
? (latestUpdates.get(incident.id)?.body ?? deterministicIncidentMessage(incident.startStatusCode))
|
||||
: deterministicIncidentMessage(monitor.lastStatusCode)
|
||||
: !maintenance && monitor.lastOk === true && monitor.lastDegraded
|
||||
? DEGRADED_MESSAGE
|
||||
: null,
|
||||
maintenance: maintenance ? { name: maintenance.name, endsAt: maintenance.endsAt.toISOString() } : null,
|
||||
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
|
||||
uptime90d: roundUptime(upChecks, totalChecks),
|
||||
history,
|
||||
};
|
||||
});
|
||||
const activeIncidents = activeIncidentRows.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
latestUpdate: latestUpdates.get(incident.id) ?? null,
|
||||
services: incidentServices.get(incident.id) ?? [],
|
||||
}));
|
||||
return context.json({
|
||||
overall: overallStatus(
|
||||
services.map((service) => service.status),
|
||||
activeIncidentRows.filter((incident) => incident.source === 'manual').map((incident) => incident.impact),
|
||||
),
|
||||
updatedAt: Date.now(),
|
||||
services,
|
||||
activeIncidents,
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/incidents', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const limit = parseLimit(context.req.query('limit'), 20, 20);
|
||||
const rows = await db
|
||||
.select(incidentSelection)
|
||||
.from(incidents)
|
||||
.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),
|
||||
);
|
||||
return context.json({
|
||||
incidents: rows.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
resolvedAt: incident.resolvedAt?.toISOString() ?? null,
|
||||
durationMs: incident.durationMs,
|
||||
services: services.get(incident.id) ?? [],
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/incidents/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [incident] = await db.select(incidentSelection).from(incidents).where(eq(incidents.id, id)).limit(1);
|
||||
if (!incident) return context.json({ message: 'Incident not found' }, 404);
|
||||
const [services, updates] = await Promise.all([
|
||||
loadServices(db, [id]),
|
||||
db
|
||||
.select({ status: incidentUpdates.status, body: incidentUpdates.body, createdAt: incidentUpdates.createdAt })
|
||||
.from(incidentUpdates)
|
||||
.where(eq(incidentUpdates.incidentId, id))
|
||||
.orderBy(incidentUpdates.createdAt, incidentUpdates.id),
|
||||
]);
|
||||
const timeline =
|
||||
updates.length > 0
|
||||
? updates
|
||||
: [{ status: incident.status, body: deterministicIncidentMessage(incident.startStatusCode), createdAt: incident.startedAt }];
|
||||
return context.json({
|
||||
incident: {
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
resolvedAt: incident.resolvedAt?.toISOString() ?? null,
|
||||
durationMs: incident.durationMs,
|
||||
services: services.get(id) ?? [],
|
||||
updates: timeline,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/:id/favicon', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Service not found' }, 404);
|
||||
const [monitor] = await getDb(context.env)
|
||||
.select({ url: monitors.url })
|
||||
.from(monitors)
|
||||
.where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
|
||||
.limit(1);
|
||||
if (!monitor) return context.json({ message: 'Service not found' }, 404);
|
||||
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
|
||||
let cache: EdgeCache | null = null;
|
||||
try {
|
||||
const defaultCache = (caches as CacheStorage & { readonly default: EdgeCache }).default;
|
||||
const cached = await defaultCache.match(cacheKey);
|
||||
if (cached) return cached;
|
||||
cache = defaultCache;
|
||||
} catch {
|
||||
// Cache API availability is best-effort.
|
||||
}
|
||||
const favicon = await resolveFavicon(monitor.url);
|
||||
if (!favicon) return context.json({ message: 'No favicon' }, 404);
|
||||
const response = new Response(favicon.body, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
'Content-Length': String(favicon.body.byteLength),
|
||||
'Content-Type': favicon.contentType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
if (cache) context.executionCtx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => undefined));
|
||||
return response;
|
||||
});
|
||||
|
||||
export default statusRoutes;
|
||||
|
||||
Reference in New Issue
Block a user