feat: add status page

This commit is contained in:
2026-08-28 16:30:50 +07:00
parent 05922d7e03
commit 6e2ead19e5
16 changed files with 710 additions and 12 deletions
+2
View File
@@ -4,9 +4,11 @@ import { DashboardPage } from "./pages/DashboardPage";
import { LoginPage } from "./pages/LoginPage";
import { MonitorDetailPage } from "./pages/MonitorDetailPage";
import { SettingsPage } from "./pages/SettingsPage";
import { StatusPage } from "./pages/StatusPage";
function App() {
const pathname = usePathname();
if (pathname === "/") return <StatusPage />;
if (pathname === "/login") return <LoginPage />;
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
const page = monitorMatch
+26
View File
@@ -0,0 +1,26 @@
import { getJson } from "./http";
export type PublicServiceStatus = "up" | "down" | "unknown";
export type PublicOverallStatus = "operational" | "degraded" | "down";
export type PublicService = {
id: number;
name: string;
status: PublicServiceStatus;
lastCheckedAt: string | null;
uptime90d: number | null;
history: Array<{
day: number;
uptimePct: number | null;
}>;
};
export type PublicStatus = {
overall: PublicOverallStatus;
updatedAt: number;
services: PublicService[];
};
export function getStatus(signal?: AbortSignal) {
return getJson<PublicStatus>("/api/status", { signal });
}
+5 -2
View File
@@ -3,16 +3,19 @@ import { useState } from "react";
type SiteIconProps = {
monitorId: number;
favicon?: "admin" | "public";
};
export function SiteIcon({ monitorId }: SiteIconProps) {
export function SiteIcon({ monitorId, favicon = "admin" }: SiteIconProps) {
const [failed, setFailed] = useState(false);
if (failed) return <Database aria-hidden="true" />;
return (
<img
src={`/api/monitors/${monitorId}/favicon`}
src={favicon === "public"
? `/api/status/${monitorId}/favicon`
: `/api/monitors/${monitorId}/favicon`}
alt=""
width={22}
height={22}
@@ -0,0 +1,52 @@
import type { PublicService } from "../api/status";
const DAY_MS = 24 * 60 * 60 * 1000;
const HISTORY_DAYS = 90;
type HistoryEntry = PublicService["history"][number];
function dayClass(uptimePct: number | null) {
if (uptimePct === null) return "is-empty";
if (uptimePct === 100) return "is-up";
if (uptimePct === 0) return "is-down";
return "is-partial";
}
function dayTitle(day: number, uptimePct: number | null) {
const date = new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(new Date(day));
return `${date}: ${uptimePct === null ? "No data" : `${uptimePct.toFixed(1)}% uptime`}`;
}
export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) {
const historyByDay = new Map(history.map((entry) => [entry.day, entry.uptimePct]));
const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const days = Array.from({ length: HISTORY_DAYS }, (_, index) => {
const day = today - (HISTORY_DAYS - index - 1) * DAY_MS;
return { day, uptimePct: historyByDay.get(day) ?? null };
});
return (
<div className="status-history">
<div className="uptime-days" aria-label="Daily uptime over the last 90 days">
{days.map(({ day, uptimePct }) => (
<span
className={`uptime-day ${dayClass(uptimePct)}`}
key={day}
title={dayTitle(day, uptimePct)}
/>
))}
</div>
<div className="uptime-days-caption" aria-hidden="true">
<span>90 days ago</span>
<i />
<span>Today</span>
</div>
</div>
);
}
@@ -17,7 +17,7 @@ type LatencyDatum = {
ok: boolean;
};
function LatencyTooltip({ active, payload }: TooltipContentProps<number, string>) {
function LatencyTooltip({ active, payload }: TooltipContentProps) {
if (!active || !payload.length) return null;
const point = payload[0].payload as LatencyDatum;
+1 -1
View File
@@ -18,7 +18,7 @@ type UptimeDatum = {
id: number;
};
function UptimeTooltip({ active, payload }: TooltipContentProps<number, string>) {
function UptimeTooltip({ active, payload }: TooltipContentProps) {
if (!active || !payload.length) return null;
const point = payload[0].payload as UptimeDatum;
+2 -1
View File
@@ -103,12 +103,13 @@ export function DashboardPage() {
<div className="dashboard-shell">
<header className="dashboard-header">
<div className="dashboard-header-inner">
<a className="brand" href="/" aria-label="Upwatch dashboard">
<a className="brand" href="/dashboard" aria-label="Upwatch dashboard">
<Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</a>
<div className="nav-actions">
<span className="header-context">Production monitors</span>
<a className="nav-auth" href="/">View status page</a>
<button className="nav-auth" type="button" onClick={() => navigate("/settings")}>Settings</button>
<button className="nav-auth" type="button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
{logoutMutation.isPending ? "Signing out…" : "Sign out"}
+2 -2
View File
@@ -9,14 +9,14 @@ export function LoginPage() {
const loginMutation = useLoginMutation();
useEffect(() => {
if (sessionQuery.data?.authenticated) navigate("/", { replace: true });
if (sessionQuery.data?.authenticated) navigate("/dashboard", { replace: true });
}, [sessionQuery.data?.authenticated]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
loginMutation.mutate(
{ password },
{ onSuccess: () => navigate("/", { replace: true }) },
{ onSuccess: () => navigate("/dashboard", { replace: true }) },
);
}
+3 -3
View File
@@ -44,17 +44,17 @@ export function MonitorDetailPage({ id }: { id: number }) {
if (monitorQuery.isPending) return <div className="full-page-loading"><RefreshCw className="loading-mark" /><p>Loading monitor history</p></div>;
if (monitorQuery.isError || !monitor) return (
<div className="detail-error"><strong>Monitor not found</strong><p>The requested monitor could not be loaded.</p><button className="secondary-button" onClick={() => navigate("/")}>Return to dashboard</button></div>
<div className="detail-error"><strong>Monitor not found</strong><p>The requested monitor could not be loaded.</p><button className="secondary-button" onClick={() => navigate("/dashboard")}>Return to dashboard</button></div>
);
return (
<div className="dashboard-shell">
<header className="dashboard-header"><div className="dashboard-header-inner">
<button className="brand brand-button" type="button" onClick={() => navigate("/")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button>
<button className="brand brand-button" type="button" onClick={() => navigate("/dashboard")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button>
<div className="nav-actions"><button className="nav-auth" onClick={() => navigate("/settings")}>Settings</button><button className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>Sign out</button></div>
</div></header>
<main className="dashboard-main detail-main">
<button className="back-link" type="button" onClick={() => navigate("/")}><ArrowLeft /> All monitors</button>
<button className="back-link" type="button" onClick={() => navigate("/dashboard")}><ArrowLeft /> All monitors</button>
<section className="detail-hero">
<div className="detail-title"><span className={`status-orb ${status.className}`} /><div><p className="overline">Monitor #{monitor.id}</p><h1>{monitor.name}</h1><a href={monitor.url} target="_blank" rel="noreferrer">{monitor.url}<ExternalLink /></a></div></div>
<div className="detail-actions"><span className={`row-status ${status.className}`}><i />{status.label}</span>{!monitor.alertsEnabled && <span className="muted-alert"><BellOff /> Alerts muted</span>}<button className="primary-button" type="button" onClick={() => checkMutation.mutate(id)} disabled={checkMutation.isPending}>{checkMutation.isPending ? "Checking…" : "Check now"}</button></div>
+2 -2
View File
@@ -30,8 +30,8 @@ export function SettingsPage() {
const settingsQuery = useNotificationSettingsQuery();
const logoutMutation = useLogoutMutation();
return <div className="dashboard-shell">
<header className="dashboard-header"><div className="dashboard-header-inner"><button className="brand brand-button" onClick={() => navigate("/")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button><div className="nav-actions"><span className="header-context">Settings</span><button className="nav-auth" onClick={() => logoutMutation.mutate()}>Sign out</button></div></div></header>
<main className="settings-main"><button className="back-link" type="button" onClick={() => navigate("/")}><ArrowLeft /> Dashboard</button><section className="settings-heading"><p className="overline">Integrations</p><h1>Notifications</h1><p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p></section>
<header className="dashboard-header"><div className="dashboard-header-inner"><button className="brand brand-button" onClick={() => navigate("/dashboard")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button><div className="nav-actions"><span className="header-context">Settings</span><button className="nav-auth" onClick={() => logoutMutation.mutate()}>Sign out</button></div></div></header>
<main className="settings-main"><button className="back-link" type="button" onClick={() => navigate("/dashboard")}><ArrowLeft /> Dashboard</button><section className="settings-heading"><p className="overline">Integrations</p><h1>Notifications</h1><p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p></section>
<section className="settings-card"><div className="settings-card-intro"><span><BellRing /></span><div><h2>Incident webhook</h2><p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p></div></div>{settingsQuery.isPending ? <div className="table-empty">Loading settings</div> : settingsQuery.isError ? <p className="form-error">Unable to load notification settings.</p> : <SettingsForm key={settingsQuery.data.settings.updatedAt ?? "new"} settings={settingsQuery.data.settings} />}</section>
<section className="payload-preview"><p className="overline">Payload preview</p><pre>{`{
"event": "down",
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from "lucide-react";
import type { PublicOverallStatus, PublicServiceStatus } from "../api/status";
import { SiteIcon } from "../components/SiteIcon";
import { StatusHistoryBar } from "../components/StatusHistoryBar";
import { navigate } from "../lib/router";
import { useSessionQuery } from "../queries/auth";
import { useStatusQuery } from "../queries/status";
const OVERALL_COPY: Record<PublicOverallStatus, { title: string; detail: string }> = {
operational: {
title: "All systems operational",
detail: "Every monitored service is responding normally.",
},
degraded: {
title: "Some systems are degraded",
detail: "One or more services are currently experiencing disruption.",
},
down: {
title: "Major service disruption",
detail: "All reporting services are currently unavailable.",
},
};
const SERVICE_STATUS: Record<PublicServiceStatus, { label: string; className: string }> = {
up: { label: "Operational", className: "online" },
down: { label: "Down", className: "offline" },
unknown: { label: "Awaiting data", className: "checking" },
};
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Unknown request error";
}
function relativeUpdate(updatedAt: number, now: number) {
const seconds = Math.max(0, Math.floor((now - updatedAt) / 1_000));
if (seconds < 5) return "Updated just now";
if (seconds < 60) return `Updated ${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
return `Updated ${minutes}m ago`;
}
function OverallIcon({ status }: { status: PublicOverallStatus }) {
if (status === "operational") return <CircleCheck aria-hidden="true" />;
if (status === "degraded") return <TriangleAlert aria-hidden="true" />;
return <Activity aria-hidden="true" />;
}
export function StatusPage() {
const statusQuery = useStatusQuery();
const sessionQuery = useSessionQuery();
const [now, setNow] = useState(Date.now);
const status = statusQuery.data;
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 5_000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="status-page-shell">
<header className="dashboard-header status-header">
<div className="dashboard-header-inner status-header-inner">
<a className="brand" href="/" aria-label="Upwatch public status">
<Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</a>
<button
className="status-header-action"
type="button"
onClick={() => navigate(sessionQuery.data?.authenticated ? "/dashboard" : "/login")}
>
{sessionQuery.data?.authenticated ? "Dashboard" : "Sign in"}
</button>
</div>
</header>
<main className="status-main">
<section className="status-intro">
<p className="overline">System status</p>
<h1>Service availability</h1>
<p>Live operational health and 90-day availability for every public service.</p>
</section>
{statusQuery.isPending ? (
<div className="status-loading" aria-busy="true" aria-label="Loading service status">
<div className="status-banner-skeleton" />
<div className="status-service-skeleton">{[0, 1, 2].map((item) => <div key={item}><i /><span /><b /></div>)}</div>
</div>
) : statusQuery.isError || !status ? (
<section className="status-panel-state status-error-state">
<span><TriangleAlert /></span>
<strong>Status could not be loaded</strong>
<p>{errorMessage(statusQuery.error)}</p>
<button className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>Try again</button>
</section>
) : (
<>
<section className={`status-banner ${status.overall}`} aria-live="polite">
<span className="status-banner-icon"><OverallIcon status={status.overall} /></span>
<div>
<h2>{OVERALL_COPY[status.overall].title}</h2>
<p>{OVERALL_COPY[status.overall].detail}</p>
</div>
<time dateTime={new Date(status.updatedAt).toISOString()}>{relativeUpdate(status.updatedAt, now)}</time>
</section>
<section className="public-services-panel" aria-labelledby="public-services-title">
<div className="public-services-heading">
<div>
<h2 id="public-services-title">Services</h2>
<p>Availability is calculated from checks collected over the last 90 days.</p>
</div>
<button className="icon-button" type="button" onClick={() => void statusQuery.refetch()} disabled={statusQuery.isFetching} aria-label="Refresh service status">
<RefreshCw className={statusQuery.isFetching ? "is-spinning" : ""} />
</button>
</div>
{status.services.length === 0 ? (
<div className="status-panel-state">
<span><Database /></span>
<strong>No public services yet</strong>
<p>Service health will appear here after monitoring is enabled.</p>
</div>
) : (
<div className="public-service-list">
{status.services.map((service) => {
const serviceStatus = SERVICE_STATUS[service.status];
return (
<article className="public-service-row" key={service.id}>
<div className="public-service-summary">
<span className="service-icon"><SiteIcon monitorId={service.id} favicon="public" /></span>
<div>
<strong>{service.name}</strong>
<span className={`row-status ${serviceStatus.className}`}><i />{serviceStatus.label}</span>
</div>
</div>
<div className="public-service-uptime">
<span>90-day uptime</span>
<strong>{service.uptime90d === null ? "—" : `${service.uptime90d.toFixed(1)}%`}</strong>
</div>
<StatusHistoryBar history={service.history} />
</article>
);
})}
</div>
)}
</section>
</>
)}
</main>
<footer className="status-footer">
<span>Powered by upwatch</span>
<span><i /> Monitoring from Cloudflare's edge</span>
</footer>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { getStatus } from "../api/status";
export const statusKeys = {
all: ["public-status"] as const,
};
export function useStatusQuery() {
return useQuery({
queryKey: statusKeys.all,
queryFn: ({ signal }) => getStatus(signal),
refetchInterval: 60_000,
refetchIntervalInBackground: false,
});
}
+94
View File
@@ -271,6 +271,83 @@ button { color: inherit; }
.payload-preview .overline { color: #83dcb4; }
.payload-preview pre { margin: 16px 0 0; overflow-x: auto; font: 400 12px/1.7 "IBM Plex Mono", monospace; color: #d7d7d7; }
.status-page-shell {
min-height: 100dvh;
background:
radial-gradient(circle at 12% 22%, rgb(62 207 142 / 0.07), transparent 28rem),
linear-gradient(180deg, #fff 0, #fff 36%, #fafcfb 100%);
}
.status-header { position: relative; }
.status-header-inner { width: min(960px, calc(100% - 48px)); }
.status-header-action {
min-height: 34px; padding: 6px 13px; border: 1px solid #d5d5d5; border-radius: 6px;
font-size: 12px; font-weight: 500; color: #444; background: rgb(255 255 255 / 0.86); cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, transform 160ms ease;
}
.status-header-action:hover { border-color: #9bcdb7; color: #16885b; transform: translateY(-1px); }
.status-main { width: min(960px, calc(100% - 48px)); margin: 0 auto; padding: 72px 0 56px; }
.status-intro { max-width: 620px; }
.status-intro h1 { margin: 0; font-size: clamp(38px, 6vw, 56px); font-weight: 500; line-height: 1.02; letter-spacing: -2.5px; }
.status-intro > p:last-child { max-width: 560px; margin: 18px 0 0; font-size: 16px; line-height: 1.6; color: var(--muted); }
.status-banner {
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; min-height: 116px;
margin-top: 42px; padding: 24px 26px; border: 1px solid #b9e6d2; border-radius: 10px;
color: #125c41; background: linear-gradient(110deg, #edfaf4, #f8fdfb); box-shadow: 0 12px 32px rgb(24 110 76 / 0.06);
animation: enter 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.status-banner.degraded { border-color: #e7d796; color: #705d0f; background: linear-gradient(110deg, #fff9e8, #fffdf6); box-shadow: 0 12px 32px rgb(130 105 14 / 0.06); }
.status-banner.down { border-color: #ebc1c1; color: #8e3030; background: linear-gradient(110deg, #fff2f2, #fffafa); box-shadow: 0 12px 32px rgb(130 34 34 / 0.06); }
.status-banner-icon { display: grid; width: 46px; height: 46px; place-items: center; border: 1px solid rgb(36 180 126 / 0.22); border-radius: 50%; background: rgb(255 255 255 / 0.72); }
.status-banner-icon svg { width: 21px; height: 21px; }
.status-banner.degraded .status-banner-icon { border-color: rgb(178 145 24 / 0.25); }
.status-banner.down .status-banner-icon { border-color: rgb(174 61 61 / 0.22); }
.status-banner h2 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
.status-banner p { margin: 6px 0 0; font-size: 13px; color: currentColor; opacity: 0.75; }
.status-banner time { justify-self: end; font: 400 10px/1.4 "IBM Plex Mono", monospace; opacity: 0.66; white-space: nowrap; }
.public-services-panel { 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 60ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.public-services-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 88px; padding: 18px 22px; border-bottom: 1px solid #e8e8e8; }
.public-services-heading h2 { margin: 0; font-size: 18px; font-weight: 500; }
.public-services-heading p { margin: 5px 0 0; font-size: 12px; color: var(--muted); }
.public-service-row { display: grid; grid-template-columns: minmax(190px, 0.7fr) 120px minmax(320px, 1.2fr); align-items: center; gap: 24px; min-height: 128px; padding: 22px; }
.public-service-row + .public-service-row { border-top: 1px solid #ededed; }
.public-service-row:hover { background: #fdfefd; }
.public-service-summary { display: flex; align-items: center; gap: 14px; min-width: 0; }
.public-service-summary > div { min-width: 0; }
.public-service-summary strong { display: block; overflow: hidden; font-size: 15px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.public-service-summary .row-status { margin-top: 7px; }
.public-service-uptime { text-align: right; }
.public-service-uptime span { display: block; font-size: 10px; color: var(--faint); }
.public-service-uptime strong { display: block; margin-top: 5px; font: 500 17px/1.2 "IBM Plex Mono", monospace; letter-spacing: -0.5px; }
.status-history { min-width: 0; }
.uptime-days { display: flex; align-items: stretch; width: 100%; height: 32px; gap: 2px; }
.uptime-day { flex: 1 1 0; min-width: 1px; border-radius: 2px; background: #e6e8e7; transition: filter 140ms ease, transform 140ms ease; }
.uptime-day:hover { z-index: 1; filter: saturate(1.15) brightness(0.92); transform: scaleY(1.12); }
.uptime-day.is-up { background: #3ecf8e; }
.uptime-day.is-partial { background: #e1bc45; }
.uptime-day.is-down { background: #dc6262; }
.uptime-day.is-empty { background: #e8ebe9; }
.uptime-days-caption { display: flex; align-items: center; gap: 9px; margin-top: 8px; font: 400 9px/1.3 "IBM Plex Mono", monospace; color: #a0a0a0; }
.uptime-days-caption i { flex: 1; height: 1px; background: #ededed; }
.status-panel-state { display: grid; justify-items: center; min-height: 280px; place-content: center; padding: 48px 24px; text-align: center; }
.status-panel-state > span { display: grid; width: 44px; height: 44px; margin-bottom: 16px; place-items: center; border: 1px solid #dcdcdc; border-radius: 50%; color: #666; background: #fafafa; }
.status-panel-state svg { width: 19px; height: 19px; }
.status-panel-state strong { font-size: 15px; font-weight: 500; }
.status-panel-state p { max-width: 400px; margin: 8px 0 18px; font-size: 13px; line-height: 1.5; color: var(--muted); }
.status-error-state { margin-top: 42px; border: 1px solid #ebd1d1; border-radius: 10px; background: #fffafa; }
.status-error-state > span { border-color: #ebcaca; color: #a34242; background: #fff3f3; }
.status-loading { margin-top: 42px; }
.status-banner-skeleton { height: 116px; border-radius: 10px; background: linear-gradient(90deg, #eef2f0 20%, #f8faf9 50%, #eef2f0 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
.status-service-skeleton { margin-top: 18px; overflow: hidden; border: 1px solid #e4e4e4; border-radius: 10px; background: #fff; }
.status-service-skeleton > div { display: grid; grid-template-columns: 42px 1fr minmax(240px, 0.8fr); align-items: center; gap: 16px; min-height: 116px; padding: 22px; }
.status-service-skeleton > div + div { border-top: 1px solid #ededed; }
.status-service-skeleton i, .status-service-skeleton span, .status-service-skeleton b { display: block; border-radius: 5px; background: linear-gradient(90deg, #f0f2f1 20%, #fafafa 50%, #f0f2f1 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
.status-service-skeleton i { width: 42px; height: 42px; }
.status-service-skeleton span { width: min(220px, 75%); height: 34px; }
.status-service-skeleton b { height: 32px; }
.status-footer { display: flex; justify-content: space-between; width: min(960px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 36px; border-top: 1px solid #e7ebe9; font-size: 11px; color: #939393; }
.status-footer span:last-child { display: inline-flex; align-items: center; gap: 7px; }
.status-footer i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12); }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 50% { opacity: 0.35; } }
@keyframes loading-pulse { 50% { opacity: 0.4; transform: scale(0.94); } }
@@ -291,6 +368,7 @@ button { color: inherit; }
@media (max-width: 760px) {
.dashboard-header-inner, .dashboard-main, .dashboard-page-footer { width: min(100% - 32px, 1280px); }
.status-header-inner, .status-main, .status-footer { width: min(100% - 32px, 960px); }
.header-context { display: none; }
.dashboard-main { padding: 40px 0 56px; }
.dashboard-intro { align-items: flex-start; flex-direction: column; }
@@ -314,6 +392,11 @@ button { color: inherit; }
.sla-grid { grid-template-columns: repeat(2, 1fr); }
.sla-card.incident-summary { grid-column: 1 / -1; }
.settings-main { width: min(100% - 32px, 760px); }
.status-main { padding: 52px 0 44px; }
.status-banner { grid-template-columns: auto 1fr; }
.status-banner time { grid-column: 2; justify-self: start; }
.public-service-row { grid-template-columns: 1fr auto; gap: 20px; }
.status-history { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
@@ -336,6 +419,17 @@ button { color: inherit; }
.settings-heading h1 { font-size: 34px; }
.settings-card-intro, .settings-form { padding: 20px; }
.settings-actions { align-items: stretch; flex-direction: column-reverse; }
.status-intro h1 { letter-spacing: -1.8px; }
.status-intro > p:last-child { font-size: 14px; }
.status-banner { grid-template-columns: 1fr; gap: 12px; padding: 22px 20px; }
.status-banner-icon { width: 40px; height: 40px; }
.status-banner time { grid-column: auto; }
.public-services-heading { padding: 17px 16px; }
.public-services-heading p { max-width: 250px; }
.public-service-row { padding: 20px 16px; }
.public-service-uptime strong { font-size: 15px; }
.uptime-days { height: 28px; gap: 1px; }
.status-footer { align-items: flex-start; flex-direction: column; gap: 8px; }
}
@media (prefers-reduced-motion: reduce) {
+2
View File
@@ -4,6 +4,7 @@ import { runDueChecks } from "./checks/run-due-checks";
import authRoutes from "./routes/auth";
import monitorRoutes from "./routes/monitors";
import settingsRoutes from "./routes/settings";
import statusRoutes from "./routes/status";
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
import { runDailyRollup } from "./scheduled/rollup";
@@ -26,6 +27,7 @@ app.get("/api/health", async (context) => {
app.route("/", authRoutes);
app.route("/api/monitors", monitorRoutes);
app.route("/api/settings", settingsRoutes);
app.route("/api/status", statusRoutes);
export default {
fetch: app.fetch,
+185
View File
@@ -0,0 +1,185 @@
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
import { Hono } from "hono";
import { getDb } from "../db/client";
import { checks, monitorDailyStats, monitors } from "../db/schema";
import { resolveFavicon } from "./monitors";
const DAY_MS = 24 * 60 * 60 * 1000;
const FAVICON_CACHE_SECONDS = 86_400;
type ServiceStatus = "up" | "down" | "unknown";
type OverallStatus = "operational" | "degraded" | "down";
type HistoryEntry = {
day: number;
uptimePct: number | null;
};
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>;
};
function parseId(rawId: string) {
const id = Number(rawId);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
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[]): OverallStatus {
let checked = 0;
let down = 0;
for (const status of statuses) {
if (status === "unknown") continue;
checked += 1;
if (status === "down") down += 1;
}
if (down === 0) return "operational";
if (down === checked) return "down";
return "degraded";
}
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,
lastCheckedAt: monitors.lastCheckedAt,
})
.from(monitors)
.where(eq(monitors.enabled, true))
.orderBy(monitors.createdAt);
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[] = [];
if (monitorIds.length > 0) {
[historicalRows, todayRows] = 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),
gte(checks.checkedAt, new Date(today)),
))
.groupBy(checks.monitorId),
]);
}
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 services = monitorRows.map((monitor) => {
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
let totalChecks = 0;
let upChecks = 0;
const history: HistoryEntry[] = 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),
};
});
return {
id: monitor.id,
name: monitor.name,
status: serviceStatus(monitor.lastOk),
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
uptime90d: roundUptime(upChecks, totalChecks),
history,
};
});
return context.json({
overall: overallStatus(services.map((service) => service.status)),
updatedAt: Date.now(),
services,
});
});
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, particularly in local and preview environments.
}
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;