mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: add status page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 }) },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,159 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type PublicStatusResponse = {
|
||||
overall: "operational" | "degraded" | "down";
|
||||
updatedAt: number;
|
||||
services: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
status: "up" | "down" | "unknown";
|
||||
lastCheckedAt: string | null;
|
||||
uptime90d: number | null;
|
||||
history: Array<{ day: number; uptimePct: number | null }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function resetDatabase() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("DELETE FROM checks"),
|
||||
env.DB.prepare("DELETE FROM incidents"),
|
||||
env.DB.prepare("DELETE FROM monitor_daily_stats"),
|
||||
env.DB.prepare("DELETE FROM notification_settings"),
|
||||
env.DB.prepare("DELETE FROM monitors"),
|
||||
env.DB.prepare("DELETE FROM login_attempts"),
|
||||
env.DB.prepare("DELETE FROM sessions"),
|
||||
env.DB.prepare("DELETE FROM admin_credentials"),
|
||||
]);
|
||||
}
|
||||
|
||||
async function insertMonitor(input: {
|
||||
name: string;
|
||||
url?: string;
|
||||
enabled?: boolean;
|
||||
lastOk?: boolean | null;
|
||||
lastStatusCode?: number | null;
|
||||
lastLatencyMs?: number | null;
|
||||
lastError?: string | null;
|
||||
}) {
|
||||
const now = Date.now();
|
||||
const result = await env.DB.prepare(`
|
||||
INSERT INTO monitors (
|
||||
name, url, method, expected_status, interval_seconds, timeout_ms,
|
||||
enabled, alerts_enabled, last_ok, last_status_code, last_latency_ms,
|
||||
last_error, last_checked_at, created_at, updated_at
|
||||
) VALUES (?, ?, 'GET', 200, 300, 10000, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.bind(
|
||||
input.name,
|
||||
input.url ?? `https://${input.name.toLowerCase()}.example.com/health`,
|
||||
input.enabled === false ? 0 : 1,
|
||||
input.lastOk === null || input.lastOk === undefined ? null : input.lastOk ? 1 : 0,
|
||||
input.lastStatusCode ?? null,
|
||||
input.lastLatencyMs ?? null,
|
||||
input.lastError ?? null,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return Number(result.meta.last_row_id);
|
||||
}
|
||||
|
||||
function statusFetch(path = "/api/status") {
|
||||
return SELF.fetch(`https://example.com${path}`);
|
||||
}
|
||||
|
||||
describe("public status API", () => {
|
||||
beforeAll(async () => {
|
||||
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
|
||||
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
||||
});
|
||||
beforeEach(resetDatabase);
|
||||
|
||||
it("returns public status without authentication and omits sensitive monitor fields", async () => {
|
||||
await insertMonitor({
|
||||
name: "Public API",
|
||||
lastOk: true,
|
||||
lastStatusCode: 200,
|
||||
lastLatencyMs: 42,
|
||||
lastError: "sensitive diagnostic",
|
||||
});
|
||||
|
||||
const response = await statusFetch();
|
||||
const body = await response.json<PublicStatusResponse>();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.overall).toBe("operational");
|
||||
expect(body.updatedAt).toEqual(expect.any(Number));
|
||||
expect(body.services).toHaveLength(1);
|
||||
expect(body.services[0]).toMatchObject({
|
||||
name: "Public API",
|
||||
status: "up",
|
||||
uptime90d: null,
|
||||
history: [],
|
||||
});
|
||||
for (const privateField of ["url", "lastError", "lastStatusCode", "lastLatencyMs", "method", "timeoutMs"]) {
|
||||
expect(body.services[0]).not.toHaveProperty(privateField);
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes disabled monitors and reports degraded health for a partial outage", async () => {
|
||||
await insertMonitor({ name: "Healthy", lastOk: true });
|
||||
await insertMonitor({ name: "Unavailable", lastOk: false });
|
||||
await insertMonitor({ name: "Disabled secret", enabled: false, lastOk: false });
|
||||
await insertMonitor({ name: "New service", lastOk: null });
|
||||
|
||||
const response = await statusFetch();
|
||||
const body = await response.json<PublicStatusResponse>();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.overall).toBe("degraded");
|
||||
expect(body.services.map((service) => service.name)).toEqual(["Healthy", "Unavailable", "New service"]);
|
||||
expect(body.services.map((service) => service.status)).toEqual(["up", "down", "unknown"]);
|
||||
});
|
||||
|
||||
it("reports a total outage when every checked service is down", async () => {
|
||||
await insertMonitor({ name: "Website", lastOk: false });
|
||||
await insertMonitor({ name: "API", lastOk: false });
|
||||
await insertMonitor({ name: "Not checked", lastOk: null });
|
||||
|
||||
const body = await (await statusFetch()).json<PublicStatusResponse>();
|
||||
expect(body.overall).toBe("down");
|
||||
});
|
||||
|
||||
it("combines historical daily rollups and today's checks into 90-day uptime", async () => {
|
||||
const id = await insertMonitor({ name: "Aggregated", lastOk: true });
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)").bind(id, today - DAY_MS),
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 90, ?)").bind(id, today + 1_000),
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 110, ?)").bind(id, today + 2_000),
|
||||
]);
|
||||
|
||||
const body = await (await statusFetch()).json<PublicStatusResponse>();
|
||||
const service = body.services[0];
|
||||
|
||||
expect(service.uptime90d).toBe(80);
|
||||
expect(service.history).toEqual([
|
||||
{ day: today - DAY_MS, uptimePct: 75 },
|
||||
{ day: today, uptimePct: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the administrative monitor collection protected", async () => {
|
||||
const response = await SELF.fetch("https://example.com/api/monitors");
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ message: "Authentication required" });
|
||||
});
|
||||
|
||||
it("does not expose a favicon for a disabled service", async () => {
|
||||
const id = await insertMonitor({ name: "Private", enabled: false });
|
||||
const response = await statusFetch(`/api/status/${id}/favicon`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ message: "Service not found" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user