diff --git a/src/client/queries/monitors.ts b/src/client/queries/monitors.ts
new file mode 100644
index 0000000..a1f0831
--- /dev/null
+++ b/src/client/queries/monitors.ts
@@ -0,0 +1,60 @@
+import { queryOptions, useMutation, useQuery } from "@tanstack/react-query";
+import {
+ createMonitor,
+ deleteMonitor,
+ listMonitors,
+ runMonitorCheck,
+ updateMonitor,
+ type MonitorInput,
+} from "../api/monitors";
+import { queryClient } from "../lib/query-client";
+
+export const monitorKeys = {
+ all: ["monitors"] as const,
+ list: () => [...monitorKeys.all, "list"] as const,
+};
+
+export const monitorsQueryOptions = () =>
+ queryOptions({
+ queryKey: monitorKeys.list(),
+ queryFn: ({ signal }) => listMonitors(signal),
+ refetchInterval: 60_000,
+ refetchIntervalInBackground: false,
+ });
+
+export function useMonitorsQuery() {
+ return useQuery(monitorsQueryOptions());
+}
+
+function invalidateMonitors() {
+ return queryClient.invalidateQueries({ queryKey: monitorKeys.all });
+}
+
+export function useCreateMonitorMutation() {
+ return useMutation({
+ mutationFn: (input: MonitorInput) => createMonitor(input),
+ onSuccess: invalidateMonitors,
+ });
+}
+
+export function useUpdateMonitorMutation() {
+ return useMutation({
+ mutationFn: ({ id, input }: { id: number; input: Partial }) =>
+ updateMonitor(id, input),
+ onSuccess: invalidateMonitors,
+ });
+}
+
+export function useDeleteMonitorMutation() {
+ return useMutation({
+ mutationFn: (id: number) => deleteMonitor(id),
+ onSuccess: invalidateMonitors,
+ });
+}
+
+export function useRunCheckMutation() {
+ return useMutation({
+ mutationFn: (id: number) => runMonitorCheck(id),
+ onSuccess: invalidateMonitors,
+ });
+}
diff --git a/src/client/styles.css b/src/client/styles.css
index 985775e..4b55175 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -4,7 +4,7 @@
:root {
font-family: "DM Sans", "Helvetica Neue", sans-serif;
color: #171717;
- background: #ffffff;
+ background: #fff;
font-synthesis: none;
text-rendering: optimizeLegibility;
--primary: #3ecf8e;
@@ -20,268 +20,191 @@
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #fff; }
-button, a { font: inherit; }
+button, input, select { font: inherit; }
a { color: inherit; text-decoration: none; }
-button:focus-visible, a:focus-visible { outline: 2px solid var(--primary-deep); outline-offset: 3px; }
-.site-shell { min-height: 100vh; overflow: hidden; }
+button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--primary-deep); outline-offset: 3px; }
+button { color: inherit; }
-.site-header {
- position: relative;
- z-index: 20;
- border-bottom: 1px solid #ededed;
- background: rgb(255 255 255 / 0.92);
- backdrop-filter: blur(14px);
-}
-.nav-container {
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- align-items: center;
- width: min(1280px, calc(100% - 48px));
- height: 68px;
- margin: 0 auto;
-}
-.brand { display: inline-flex; align-items: center; justify-self: start; gap: 8px; font-size: 20px; font-weight: 600; letter-spacing: -0.6px; }
-.brand-mark { width: 26px; height: 26px; color: var(--primary); }
-.nav-links { display: flex; align-items: center; gap: 34px; font-size: 14px; color: #4d4d4d; }
-.nav-links a, .text-link { transition: color 160ms ease; }
-.nav-links a:hover, .text-link:hover { color: var(--primary-deep); }
-.nav-actions { display: flex; align-items: center; justify-self: end; gap: 16px; }
-.nav-auth {
- padding: 0; border: 0; font-size: 13px; font-weight: 500; color: #525252; background: transparent;
- cursor: pointer; transition: color 160ms ease;
-}
+.full-page-loading { display: grid; place-items: center; align-content: center; gap: 14px; min-height: 100dvh; color: var(--muted); }
+.full-page-loading p { margin: 0; font-size: 13px; }
+.loading-mark { width: 32px; height: 32px; color: var(--primary-deep); animation: loading-pulse 1.2s ease-in-out infinite; }
+
+.dashboard-shell { min-height: 100dvh; background: linear-gradient(#fff 0, #fff 68px, #fcfcfc 68px); }
+.dashboard-header { position: sticky; z-index: 20; top: 0; border-bottom: 1px solid #ededed; background: rgb(255 255 255 / 0.94); backdrop-filter: blur(14px); }
+.dashboard-header-inner { display: flex; align-items: center; justify-content: space-between; width: min(1280px, calc(100% - 48px)); height: 68px; margin: 0 auto; }
+.brand { display: inline-flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 600; letter-spacing: -0.6px; }
+.brand-mark { width: 26px; height: 26px; color: var(--primary-deep); }
+.nav-actions { display: flex; align-items: center; gap: 20px; }
+.header-context { padding-right: 20px; border-right: 1px solid #e5e5e5; font-size: 13px; color: var(--muted); }
+.nav-auth { padding: 0; border: 0; font-size: 13px; font-weight: 500; color: #525252; background: transparent; cursor: pointer; transition: color 160ms ease; }
.nav-auth:hover:not(:disabled) { color: var(--primary-deep); }
.nav-auth:disabled { cursor: wait; opacity: 0.55; }
-.nav-cta {
- display: inline-flex; align-items: center; justify-self: end; gap: 8px; min-height: 36px; padding: 0 14px;
- border: 1px solid #cfcfcf; border-radius: 6px; font-size: 13px; font-weight: 500; background: #fff;
- box-shadow: 0 1px 2px rgb(0 0 0 / 0.04); transition: border-color 160ms ease, background 160ms ease;
-}
-.nav-cta:hover { border-color: #a8a8a8; background: var(--soft); }
-.nav-cta svg, .primary-button svg, .announcement svg, .dashboard-footer svg { width: 16px; height: 16px; }
-.hero {
- display: grid;
- grid-template-columns: minmax(390px, 0.8fr) minmax(580px, 1.2fr);
- align-items: center;
- gap: clamp(56px, 7vw, 112px);
- width: min(1280px, calc(100% - 48px));
- min-height: calc(100vh - 137px);
- margin: 0 auto;
- padding: 88px 0 96px;
+.dashboard-main { width: min(1280px, calc(100% - 48px)); margin: 0 auto; padding: 56px 0 72px; }
+.dashboard-intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; }
+.overline { margin: 0 0 8px; font: 500 12px/1.4 "IBM Plex Mono", monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #55816e; }
+.dashboard-intro h1 { margin: 0; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: -1.5px; }
+.dashboard-intro > div > p:last-child { margin: 12px 0 0; font-size: 16px; color: var(--muted); }
+.primary-button, .secondary-button {
+ display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 42px; padding: 8px 16px;
+ border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; white-space: nowrap;
+ transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
-.hero-copy { position: relative; z-index: 2; animation: enter-copy 700ms cubic-bezier(0.16, 1, 0.3, 1) both; }
-.announcement { display: inline-flex; align-items: center; gap: 9px; margin-bottom: 28px; font-size: 13px; color: var(--muted); }
-.announcement > span { padding: 3px 8px; border-radius: 999px; color: var(--ink); background: #def7eb; }
-.announcement svg { color: #999; }
-h1 { max-width: 590px; margin: 0; font-size: clamp(48px, 4.65vw, 68px); font-weight: 500; line-height: 1.06; letter-spacing: -3.2px; }
-.hero-lead { max-width: 540px; margin: 28px 0 0; font-size: 18px; line-height: 1.6; color: var(--muted); }
-.hero-actions { display: flex; align-items: center; gap: 24px; margin-top: 34px; }
-.primary-button {
- display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 42px; padding: 0 18px;
- border: 1px solid #35c586; border-radius: 6px; font-size: 14px; font-weight: 500; background: var(--primary);
- box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); transition: background 160ms ease, transform 160ms ease;
-}
-.primary-button:hover { background: #36c487; transform: translateY(-1px); }
-.text-link { font-size: 14px; font-weight: 500; text-decoration: underline; text-underline-offset: 4px; text-decoration-color: #cfcfcf; }
-.trust-note { display: flex; align-items: center; gap: 16px; margin-top: 58px; padding-top: 24px; border-top: 1px solid #ededed; }
-.trust-note p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--faint); }
-.trust-note strong { font-weight: 500; color: #5f5f5f; }
-.trust-avatars { display: flex; padding-left: 10px; }
-.trust-avatars i {
- display: grid; place-items: center; width: 32px; height: 32px; margin-left: -10px; border: 2px solid #fff; border-radius: 50%;
- font: 500 9px/1 "IBM Plex Mono", monospace; font-style: normal; color: #444; background: #f0f0f0;
-}
-.trust-avatars i:nth-child(2) { background: #e2f8ed; }
-.trust-avatars i:nth-child(3) { color: #fff; background: var(--night); }
+.primary-button { border: 1px solid #35c586; color: var(--ink); background: var(--primary); box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); }
+.primary-button:hover:not(:disabled) { background: #36c487; transform: translateY(-1px); }
+.secondary-button { border: 1px solid #cfcfcf; color: #444; background: #fff; }
+.secondary-button:hover:not(:disabled) { border-color: #aaa; background: var(--soft); }
+.primary-button:active:not(:disabled), .secondary-button:active:not(:disabled) { transform: translateY(1px); }
+.primary-button:disabled, .secondary-button:disabled { cursor: wait; opacity: 0.6; }
+.primary-button svg { width: 16px; height: 16px; }
-.product-stage { position: relative; min-width: 0; perspective: 1200px; animation: enter-stage 900ms 100ms cubic-bezier(0.16, 1, 0.3, 1) both; }
-.product-stage::before {
- position: absolute; z-index: -1; inset: -42px -120px -54px -48px; border: 1px solid #f1f1f1; border-radius: 24px;
- background-image: radial-gradient(#dedede 0.7px, transparent 0.7px); background-size: 14px 14px;
- mask-image: linear-gradient(120deg, transparent 2%, black 36%, black 75%, transparent 98%); content: "";
+.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 40px; }
+.metric-card { min-height: 142px; padding: 24px; border: 1px solid #e3e3e3; border-radius: 8px; background: #fff; }
+.metric-card p { margin: 0 0 20px; font-size: 13px; color: var(--muted); }
+.metric-card strong { display: block; font-size: 32px; font-weight: 500; line-height: 1; letter-spacing: -1px; }
+.metric-card span { display: block; margin-top: 10px; font-size: 13px; color: var(--faint); }
+
+.monitor-form-panel { margin-top: 24px; padding: 28px; border: 1px solid #dcdcdc; border-radius: 8px; background: #fff; box-shadow: 0 8px 28px rgb(29 74 54 / 0.06); }
+.form-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; padding-bottom: 24px; border-bottom: 1px solid #ededed; }
+.form-panel-heading h2 { margin: 0; font-size: 22px; font-weight: 500; letter-spacing: -0.4px; }
+.form-panel-heading > p { max-width: 480px; margin: 0; font-size: 13px; line-height: 1.55; color: var(--muted); }
+.monitor-form { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px 16px; margin-top: 24px; }
+.field { display: grid; gap: 8px; }
+.field-name { grid-column: span 1; }
+.field-url { grid-column: span 3; }
+.field > span { font-size: 13px; font-weight: 500; color: #353535; }
+.field input, .field select {
+ width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid #cfcfcf; border-radius: 6px;
+ font-size: 14px; color: var(--ink); background: #fff; box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025);
}
-.dashboard-window {
- position: relative; width: 100%; border: 1px solid #d8d8d8; border-radius: 12px; overflow: hidden; background: #fff;
- box-shadow: 0 24px 70px rgb(0 0 0 / 0.13), 0 3px 10px rgb(0 0 0 / 0.06);
-}
-.window-bar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 54px; padding: 0 14px 0 18px; border-bottom: 1px solid #e8e8e8; background: #fcfcfc; }
-.window-brand, .window-project { display: flex; align-items: center; }
-.window-brand { gap: 6px; font-size: 12px; font-weight: 600; }
-.window-brand svg { width: 16px; height: 16px; color: var(--primary); }
-.window-project { gap: 7px; font-size: 11px; color: #555; }
-.project-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12); }
-.chevron { margin-left: 3px; color: #999; }
-.icon-button {
- display: grid; place-items: center; justify-self: end; width: 30px; height: 30px; padding: 0; border: 1px solid #dedede;
- border-radius: 6px; color: #777; background: #fff; cursor: pointer;
-}
-.icon-button:hover:not(:disabled) { color: #222; border-color: #bdbdbd; }
-.icon-button:disabled, .dashboard-footer button:disabled { cursor: wait; opacity: 0.6; }
-.icon-button svg { width: 14px; height: 14px; }
+.field input:hover, .field select:hover { border-color: #aaa; }
+.field input:focus, .field select:focus { border-color: var(--primary-deep); outline: 0; box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14); }
+.toggle-field { display: flex; align-items: center; gap: 9px; align-self: end; min-height: 42px; font-size: 13px; color: #444; cursor: pointer; }
+.toggle-field input { width: 16px; height: 16px; accent-color: var(--primary-deep); }
+.form-actions { display: flex; justify-content: flex-end; gap: 10px; align-self: end; grid-column: span 3; }
+.form-error { grid-column: 1 / -1; margin: 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 13px; color: #9f2f2f; background: #fff6f6; }
+
+.services-panel { margin-top: 24px; border: 1px solid #dedede; border-radius: 8px; overflow: hidden; background: #fff; }
+.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 78px; padding: 16px 20px; border-bottom: 1px solid #e8e8e8; }
+.panel-heading h2 { margin: 0; font-size: 18px; font-weight: 500; }
+.panel-heading p { margin: 4px 0 0; font-size: 13px; color: var(--muted); }
+.icon-button { display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 1px solid #d8d8d8; border-radius: 6px; color: #666; background: #fff; cursor: pointer; }
+.icon-button:hover:not(:disabled) { color: #222; border-color: #aaa; }
+.icon-button:disabled { cursor: wait; opacity: 0.6; }
+.icon-button svg { width: 15px; height: 15px; }
.is-spinning { animation: spin 900ms linear infinite; }
-.dashboard-layout { display: grid; grid-template-columns: 54px 1fr; min-height: 520px; }
-.dashboard-sidebar { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 20px; border-right: 1px solid #ebebeb; background: #fbfbfb; }
-.sidebar-icon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 5px; color: #999; }
-.sidebar-icon.active { color: #333; background: #ebebeb; }
-.sidebar-icon svg { width: 16px; height: 16px; }
-.grid-icon { width: 13px; height: 13px; background: linear-gradient(90deg, currentColor 4px, transparent 4px 8px, currentColor 8px), linear-gradient(currentColor 4px, transparent 4px 8px, currentColor 8px); opacity: 0.8; }
-.pulse-icon { width: 14px; height: 9px; border-bottom: 1.5px solid currentColor; transform: skewY(-28deg); }
-.dashboard-content { min-width: 0; padding: 30px 30px 20px; }
-.dashboard-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
-.overline { margin: 0 0 5px; font: 500 9px/1.4 "IBM Plex Mono", monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #999; }
-.dashboard-heading h2 { margin: 0; font-size: 24px; font-weight: 500; letter-spacing: -0.6px; }
-.health-badge { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid; border-radius: 999px; font-size: 10px; font-weight: 500; }
-.health-badge span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
-.health-badge.healthy { border-color: #b9ead3; color: #16885b; background: #edfbf4; }
-.health-badge.pending { border-color: #e4dfc0; color: #8b7722; background: #fffced; }
-.health-badge.unhealthy { border-color: #efc4c4; color: #ae3d3d; background: #fff3f3; }
-.metric-grid { display: grid; grid-template-columns: 1fr 1.25fr; gap: 12px; margin-top: 25px; }
-.metric-card { position: relative; min-height: 116px; padding: 15px 16px; border: 1px solid #e5e5e5; border-radius: 8px; overflow: hidden; background: #fff; }
-.metric-card p { margin: 0 0 12px; font-size: 10px; color: #777; }
-.metric-card strong { display: block; font-size: 22px; font-weight: 500; letter-spacing: -0.6px; }
-.metric-card > span { display: block; margin-top: 4px; font-size: 9px; color: #aaa; }
-.chart-card { padding-right: 48%; }
-.chart-card strong { font-size: 17px; }
-.mini-bars { position: absolute; right: 14px; bottom: 17px; display: flex; align-items: flex-end; gap: 4px; width: 42%; height: 55px; padding-bottom: 1px; border-bottom: 1px solid #eee; }
-.mini-bars i { flex: 1; min-width: 2px; border-radius: 1px 1px 0 0; background: var(--primary); opacity: 0.72; }
-.services-panel { margin-top: 14px; border: 1px solid #e5e5e5; border-radius: 8px; overflow: hidden; }
-.services-title, .service-row { display: grid; grid-template-columns: 1.6fr 0.75fr 0.65fr; align-items: center; column-gap: 16px; }
-.services-title { height: 36px; padding: 0 16px; border-bottom: 1px solid #e8e8e8; font-size: 9px; color: #999; background: #fafafa; }
-.service-row { min-height: 67px; padding: 0 16px; }
+.services-title, .service-row { display: grid; grid-template-columns: minmax(320px, 1.35fr) minmax(220px, 0.8fr) minmax(340px, 1fr); align-items: center; column-gap: 20px; }
+.services-title { min-height: 40px; padding: 0 20px; border-bottom: 1px solid #e8e8e8; font-size: 12px; color: #888; background: #fafafa; }
+.service-row { min-height: 112px; padding: 18px 20px; transition: opacity 160ms ease, background 160ms ease; }
.service-row + .service-row { border-top: 1px solid #ededed; }
-.service-name { display: flex; align-items: center; gap: 10px; min-width: 0; }
-.service-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border: 1px solid #e2e2e2; border-radius: 6px; color: #606060; background: #fafafa; }
-.service-icon svg { width: 15px; height: 15px; }
+.service-row:hover { background: #fdfdfd; }
+.service-row.is-disabled { opacity: 0.55; background: #fafafa; }
+.service-name { display: flex; align-items: center; gap: 14px; min-width: 0; }
+.service-name > div { min-width: 0; }
+.service-icon { display: grid; place-items: center; flex: 0 0 auto; width: 40px; height: 40px; border: 1px solid #dedede; border-radius: 6px; color: #555; background: #fafafa; }
+.service-icon svg { width: 19px; height: 19px; }
.service-name strong, .service-name small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.service-name strong { font-size: 11px; font-weight: 500; }
-.service-name small { margin-top: 3px; font: 400 8px/1.3 "IBM Plex Mono", monospace; color: #aaa; }
-.service-row code { font: 400 9px/1.4 "IBM Plex Mono", monospace; color: #777; }
-.row-status { display: inline-flex; align-items: center; gap: 6px; font-size: 9px; color: #777; }
-.row-status i, .footer-dot { width: 6px; height: 6px; border-radius: 50%; background: #d05a5a; }
-.row-status.online i, .footer-dot.healthy { background: var(--primary); box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12); }
+.service-name strong { font-size: 15px; font-weight: 500; }
+.service-name small { margin-top: 4px; font: 400 12px/1.4 "IBM Plex Mono", monospace; color: #888; }
+.monitor-meta { display: block; margin-top: 5px; font-size: 12px; color: #aaa; }
+.monitor-result { display: grid; justify-items: start; gap: 6px; min-width: 0; }
+.monitor-result code { font: 400 12px/1.4 "IBM Plex Mono", monospace; color: #606060; }
+.monitor-result small { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #999; }
+.row-status { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 500; color: #777; }
+.row-status i { width: 7px; height: 7px; border-radius: 50%; background: #d05a5a; }
+.row-status.online { color: #16885b; }
+.row-status.online i { background: var(--primary-deep); box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12); }
+.row-status.offline { color: #ae3d3d; }
+.row-status.checking { color: #8b7722; }
.row-status.checking i { background: #d7bd53; animation: blink 1.1s ease-in-out infinite; }
-.dashboard-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 46px; font-size: 9px; color: #aaa; }
-.dashboard-footer > span { max-width: 72%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.dashboard-footer button { display: inline-flex; align-items: center; gap: 5px; padding: 0; border: 0; font-size: 9px; color: #555; background: transparent; cursor: pointer; }
-.dashboard-footer svg { width: 12px; height: 12px; }
+.row-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
+.row-actions button { min-height: 32px; padding: 6px 9px; border: 1px solid #d8d8d8; border-radius: 6px; font-size: 12px; color: #555; background: #fff; cursor: pointer; }
+.row-actions button:hover:not(:disabled) { border-color: #aaa; color: #222; background: #fafafa; }
+.row-actions button:disabled { cursor: wait; opacity: 0.55; }
+.row-actions .danger-action { color: #a03b3b; }
-.floating-log {
- position: absolute; right: -38px; bottom: -34px; width: 260px; padding: 14px 16px; border: 1px solid #353535;
- border-radius: 8px; color: #e7e7e7; background: var(--night); box-shadow: 0 16px 40px rgb(0 0 0 / 0.24);
-}
-.floating-log > div { display: flex; align-items: center; gap: 7px; font-size: 10px; }
-.floating-log small { margin-left: auto; font-size: 8px; color: #777; }
-.log-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 8px var(--primary); }
-.floating-log code { display: block; margin-top: 12px; padding-top: 11px; border-top: 1px solid #303030; font: 400 9px/1.4 "IBM Plex Mono", monospace; color: #aaa; }
-.floating-log em { margin-right: 7px; font-style: normal; color: var(--primary); }
-.floating-log strong { float: right; font-weight: 500; color: #fff; }
-.site-footer { display: flex; align-items: center; justify-content: space-between; width: min(1280px, calc(100% - 48px)); min-height: 68px; margin: 0 auto; border-top: 1px solid #ededed; font-size: 11px; color: #999; }
-.site-footer p { margin: 0; }
-.site-footer > div, .site-footer > div span { display: flex; align-items: center; }
-.site-footer > div { gap: 28px; }
-.site-footer > div span { gap: 7px; }
+.panel-state { display: grid; justify-items: center; padding: 72px 24px; text-align: center; }
+.panel-state strong { font-size: 16px; font-weight: 500; }
+.panel-state p { max-width: 420px; margin: 8px 0 20px; font-size: 13px; line-height: 1.55; color: var(--muted); }
+.empty-icon { display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 18px; border: 1px solid #dcdcdc; border-radius: 8px; color: #666; background: #fafafa; }
+.empty-icon svg { width: 22px; height: 22px; }
+.error-state { background: #fffafa; }
+.error-state strong { color: #8b3434; }
+.monitor-skeleton > div { display: grid; grid-template-columns: 40px 1fr 180px; align-items: center; gap: 16px; min-height: 104px; padding: 18px 20px; }
+.monitor-skeleton > div + div { border-top: 1px solid #ededed; }
+.monitor-skeleton i, .monitor-skeleton span, .monitor-skeleton b { display: block; border-radius: 5px; background: linear-gradient(90deg, #f1f1f1 20%, #f8f8f8 50%, #f1f1f1 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
+.monitor-skeleton i { width: 40px; height: 40px; }
+.monitor-skeleton span { width: min(360px, 80%); height: 32px; }
+.monitor-skeleton b { width: 180px; height: 32px; }
+.dashboard-page-footer { display: flex; justify-content: space-between; width: min(1280px, calc(100% - 48px)); margin: 0 auto; padding: 24px 0 32px; border-top: 1px solid #ededed; font-size: 12px; color: #999; }
-.auth-page {
- display: grid;
- grid-template-rows: auto 1fr auto;
- justify-items: center;
- min-height: 100dvh;
- padding: 32px 24px 24px;
- background:
- radial-gradient(circle at 50% 38%, rgb(62 207 142 / 0.08), transparent 34%),
- linear-gradient(#fff, #fcfcfc);
-}
-.auth-page::before {
- position: fixed; inset: 0; z-index: 0; pointer-events: none;
- background-image: radial-gradient(#dcdcdc 0.65px, transparent 0.65px);
- background-size: 16px 16px; mask-image: linear-gradient(to bottom, transparent, black 24%, transparent 76%); content: "";
-}
-.auth-brand {
- position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px;
- font-size: 20px; font-weight: 600; letter-spacing: -0.6px;
-}
-.auth-brand-mark { display: grid; place-items: center; width: 26px; height: 26px; color: var(--primary-deep); font-size: 23px; }
-.auth-card {
- position: relative; z-index: 1; align-self: center; width: min(100%, 420px); padding: 36px;
- border: 1px solid var(--hairline); border-radius: 12px; background: rgb(255 255 255 / 0.96);
- box-shadow: 0 18px 55px rgb(24 74 52 / 0.08), 0 2px 8px rgb(0 0 0 / 0.04);
- animation: enter-copy 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
-}
+.auth-page { display: grid; grid-template-rows: auto 1fr auto; justify-items: center; min-height: 100dvh; padding: 32px 24px 24px; background: radial-gradient(circle at 50% 38%, rgb(62 207 142 / 0.08), transparent 34%), linear-gradient(#fff, #fcfcfc); }
+.auth-page::before { position: fixed; inset: 0; z-index: 0; pointer-events: none; background-image: radial-gradient(#dcdcdc 0.65px, transparent 0.65px); background-size: 16px 16px; mask-image: linear-gradient(to bottom, transparent, black 24%, transparent 76%); content: ""; }
+.auth-brand { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 600; letter-spacing: -0.6px; }
+.auth-brand-mark { width: 26px; height: 26px; color: var(--primary-deep); }
+.auth-card { position: relative; z-index: 1; align-self: center; width: min(100%, 420px); padding: 36px; border: 1px solid var(--hairline); border-radius: 12px; background: rgb(255 255 255 / 0.96); box-shadow: 0 18px 55px rgb(24 74 52 / 0.08), 0 2px 8px rgb(0 0 0 / 0.04); animation: enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.auth-heading p { margin: 0 0 12px; font: 500 10px/1.4 "IBM Plex Mono", monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #55816e; }
-.auth-heading h1 { margin: 0; font-size: 30px; line-height: 1.16; letter-spacing: -1.2px; }
+.auth-heading h1 { margin: 0; font-size: 30px; font-weight: 500; line-height: 1.16; letter-spacing: -1.2px; }
.auth-heading > span { display: block; margin-top: 12px; font-size: 14px; line-height: 1.55; color: #626262; }
.auth-form { display: grid; gap: 20px; margin-top: 30px; }
.auth-field { display: grid; gap: 8px; }
.auth-field label { font-size: 13px; font-weight: 500; color: #353535; }
-.auth-field input {
- width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid #cfcfcf; border-radius: 6px;
- font: inherit; font-size: 14px; color: var(--ink); background: #fff;
- box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025); transition: border-color 160ms ease, box-shadow 160ms ease;
-}
+.auth-field input { width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid #cfcfcf; border-radius: 6px; font-size: 14px; color: var(--ink); background: #fff; box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025); }
.auth-field input:hover { border-color: #a9a9a9; }
.auth-field input:focus { border-color: var(--primary-deep); outline: 0; box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14); }
.auth-error { margin: -4px 0 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 12px; line-height: 1.45; color: #9f2f2f; background: #fff6f6; }
-.auth-submit {
- display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 42px; padding: 8px 16px;
- border: 1px solid #35c586; border-radius: 6px; font-size: 14px; font-weight: 600; color: var(--ink); background: var(--primary);
- box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); cursor: pointer;
- transition: background 160ms ease, transform 160ms ease;
-}
+.auth-submit { display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 42px; padding: 8px 16px; border: 1px solid #35c586; border-radius: 6px; font-size: 14px; font-weight: 600; color: var(--ink); background: var(--primary); box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); cursor: pointer; }
.auth-submit:hover:not(:disabled) { background: #36c487; transform: translateY(-1px); }
.auth-submit:active:not(:disabled) { background: var(--primary-deep); transform: translateY(1px); }
.auth-submit:disabled { cursor: wait; opacity: 0.62; }
.auth-footnote { position: relative; z-index: 1; margin: 0; font-size: 11px; color: #858585; }
-@keyframes enter-copy { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
-@keyframes enter-stage { from { opacity: 0; transform: translateY(24px) rotateY(-2deg); } to { opacity: 1; transform: translateY(0) rotateY(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 50% { opacity: 0.35; } }
+@keyframes loading-pulse { 50% { opacity: 0.4; transform: scale(0.94); } }
+@keyframes skeleton { to { background-position: -220% 0; } }
+@keyframes enter { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
-@media (max-width: 1100px) {
- .hero { grid-template-columns: 1fr; gap: 72px; padding-top: 72px; }
- .hero-copy { max-width: 720px; }
- h1 { max-width: 680px; }
- .product-stage { width: min(800px, calc(100% - 24px)); margin: 0 auto; }
- .site-footer { margin-top: 56px; }
+@media (max-width: 1000px) {
+ .monitor-form { grid-template-columns: repeat(2, 1fr); }
+ .field-name, .field-url { grid-column: span 1; }
+ .form-actions { grid-column: span 1; }
+ .services-title { display: none; }
+ .service-row { grid-template-columns: minmax(280px, 1fr) minmax(200px, 0.7fr); }
+ .row-actions { grid-column: 1 / -1; justify-content: flex-start; margin-left: 54px; }
}
@media (max-width: 760px) {
- .nav-container, .hero, .site-footer { width: min(100% - 32px, 1280px); }
- .nav-container { grid-template-columns: 1fr auto; height: 62px; }
- .nav-links { display: none; }
- .nav-actions { gap: 10px; }
- .nav-cta { display: none; }
- .hero { min-height: auto; padding: 58px 0 70px; }
- h1 { font-size: clamp(40px, 12vw, 56px); line-height: 1.04; letter-spacing: -2.25px; }
- .hero-lead { font-size: 16px; }
- .trust-note { margin-top: 44px; }
- .product-stage { width: 100%; }
- .dashboard-layout { grid-template-columns: 42px 1fr; min-height: 480px; }
- .dashboard-content { padding: 23px 16px 14px; }
- .metric-grid { grid-template-columns: 1fr; }
- .chart-card { display: none; }
- .services-title, .service-row { grid-template-columns: 1fr auto; }
- .services-title span:nth-child(2), .service-row > code { display: none; }
- .services-title span:last-child { text-align: right; }
- .floating-log { right: 12px; bottom: -38px; width: 230px; }
- .site-footer { flex-direction: column; align-items: flex-start; gap: 12px; padding: 24px 0; }
+ .dashboard-header-inner, .dashboard-main, .dashboard-page-footer { width: min(100% - 32px, 1280px); }
+ .header-context { display: none; }
+ .dashboard-main { padding: 40px 0 56px; }
+ .dashboard-intro { align-items: flex-start; flex-direction: column; }
+ .dashboard-intro h1 { font-size: 34px; }
+ .metric-grid { grid-template-columns: 1fr; gap: 10px; margin-top: 28px; }
+ .metric-card { min-height: auto; padding: 20px; }
+ .metric-card p { margin-bottom: 12px; }
+ .monitor-form-panel { padding: 20px; }
+ .form-panel-heading { flex-direction: column; gap: 12px; }
+ .monitor-form { grid-template-columns: 1fr; }
+ .field-name, .field-url, .form-actions { grid-column: auto; }
+ .form-actions { justify-content: stretch; }
+ .form-actions button { flex: 1; }
+ .service-row { grid-template-columns: 1fr; gap: 18px; }
+ .row-actions { grid-column: auto; margin-left: 54px; }
+ .dashboard-page-footer { flex-direction: column; gap: 8px; }
}
@media (max-width: 520px) {
.auth-page { padding: 24px 16px; }
.auth-card { padding: 28px 22px; }
.auth-heading h1 { font-size: 27px; }
-}
-
-@media (max-width: 430px) {
- .announcement { font-size: 11px; }
- .hero-actions { align-items: flex-start; flex-direction: column; gap: 18px; }
- .window-project { display: none; }
- .window-bar { grid-template-columns: 1fr auto; }
- .dashboard-heading { align-items: flex-start; flex-direction: column; gap: 12px; }
- .dashboard-footer > span { display: none; }
- .dashboard-footer { justify-content: flex-end; }
+ .dashboard-header-inner { height: 62px; }
+ .dashboard-main { padding-top: 32px; }
+ .dashboard-intro > div > p:last-child { font-size: 14px; line-height: 1.5; }
+ .panel-heading { min-height: 70px; padding: 14px 16px; }
+ .service-row { padding: 18px 16px; }
+ .row-actions { margin-left: 0; }
+ .row-actions button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/src/worker/checks/run-check.ts b/src/worker/checks/run-check.ts
new file mode 100644
index 0000000..dd91852
--- /dev/null
+++ b/src/worker/checks/run-check.ts
@@ -0,0 +1,41 @@
+import type { monitors } from "../db/schema";
+
+export type Monitor = typeof monitors.$inferSelect;
+
+export type CheckResult = {
+ ok: boolean;
+ statusCode: number | null;
+ latencyMs: number;
+ error: string | null;
+};
+
+export async function runCheck(monitor: Monitor): Promise {
+ const startedAt = Date.now();
+ try {
+ const response = await fetch(monitor.url, {
+ method: monitor.method,
+ redirect: "follow",
+ signal: AbortSignal.timeout(monitor.timeoutMs),
+ headers: { "User-Agent": "Upwatch/1.0 (+uptime monitor)" },
+ });
+ await response.body?.cancel();
+ const ok = response.status === monitor.expectedStatus;
+ return {
+ ok,
+ statusCode: response.status,
+ latencyMs: Date.now() - startedAt,
+ error: ok
+ ? null
+ : `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ statusCode: null,
+ latencyMs: Date.now() - startedAt,
+ error: error instanceof Error
+ ? error.message.slice(0, 200)
+ : "Request failed",
+ };
+ }
+}
diff --git a/src/worker/checks/run-due-checks.ts b/src/worker/checks/run-due-checks.ts
new file mode 100644
index 0000000..caa6148
--- /dev/null
+++ b/src/worker/checks/run-due-checks.ts
@@ -0,0 +1,73 @@
+import { and, eq, sql } from "drizzle-orm";
+import { getDb } from "../db/client";
+import { checks, monitors } from "../db/schema";
+import { runCheck } from "./run-check";
+
+const MAX_MONITORS_PER_RUN = 40;
+const CONCURRENCY = 10;
+
+export type DueCheckSummary = {
+ checked: number;
+ up: number;
+ down: number;
+};
+
+export async function runDueChecks(env: Env): Promise {
+ const db = getDb(env);
+ const now = Date.now();
+ const due = await db
+ .select()
+ .from(monitors)
+ .where(
+ and(
+ eq(monitors.enabled, true),
+ sql`(${monitors.lastCheckedAt} IS NULL OR ${monitors.lastCheckedAt} <= ${now} - ${monitors.intervalSeconds} * 1000)`,
+ ),
+ )
+ .orderBy(sql`${monitors.lastCheckedAt} ASC NULLS FIRST`)
+ .limit(MAX_MONITORS_PER_RUN);
+
+ if (due.length === 0) return { checked: 0, up: 0, down: 0 };
+
+ const completed: Array<{
+ monitor: (typeof due)[number];
+ result: Awaited>;
+ checkedAt: Date;
+ }> = [];
+
+ for (let offset = 0; offset < due.length; offset += CONCURRENCY) {
+ const batch = due.slice(offset, offset + CONCURRENCY);
+ const results = await Promise.all(
+ batch.map(async (monitor) => ({
+ monitor,
+ result: await runCheck(monitor),
+ checkedAt: new Date(),
+ })),
+ );
+ completed.push(...results);
+ }
+
+ const statements = completed.flatMap(({ monitor, result, checkedAt }) => [
+ db.insert(checks).values({
+ monitorId: monitor.id,
+ ok: result.ok,
+ statusCode: result.statusCode,
+ latencyMs: result.latencyMs,
+ error: result.error,
+ checkedAt,
+ }),
+ db.update(monitors).set({
+ lastOk: result.ok,
+ lastStatusCode: result.statusCode,
+ lastLatencyMs: result.latencyMs,
+ lastError: result.error,
+ lastCheckedAt: checkedAt,
+ updatedAt: checkedAt,
+ }).where(eq(monitors.id, monitor.id)),
+ ]);
+
+ await db.batch(statements as [typeof statements[number], ...typeof statements]);
+
+ const up = completed.reduce((count, item) => count + Number(item.result.ok), 0);
+ return { checked: completed.length, up, down: completed.length - up };
+}
diff --git a/src/worker/db/schema.ts b/src/worker/db/schema.ts
index ce6aef2..4efa52c 100644
--- a/src/worker/db/schema.ts
+++ b/src/worker/db/schema.ts
@@ -27,3 +27,51 @@ export const loginAttempts = sqliteTable(
},
(table) => [index("login_attempts_ip_attempted_at_idx").on(table.ipAddress, table.attemptedAt)],
);
+
+export const monitors = sqliteTable(
+ "monitors",
+ {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ name: text("name").notNull(),
+ url: text("url").notNull(),
+ method: text("method").notNull().default("GET"),
+ expectedStatus: integer("expected_status").notNull().default(200),
+ intervalSeconds: integer("interval_seconds").notNull().default(300),
+ timeoutMs: integer("timeout_ms").notNull().default(10_000),
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
+ lastOk: integer("last_ok", { mode: "boolean" }),
+ lastStatusCode: integer("last_status_code"),
+ lastLatencyMs: integer("last_latency_ms"),
+ lastError: text("last_error"),
+ lastCheckedAt: integer("last_checked_at", { mode: "timestamp_ms" }),
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
+ },
+ (table) => [
+ index("monitors_enabled_last_checked_at_idx").on(
+ table.enabled,
+ table.lastCheckedAt,
+ ),
+ ],
+);
+
+export const checks = sqliteTable(
+ "checks",
+ {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ monitorId: integer("monitor_id")
+ .notNull()
+ .references(() => monitors.id, { onDelete: "cascade" }),
+ ok: integer("ok", { mode: "boolean" }).notNull(),
+ statusCode: integer("status_code"),
+ latencyMs: integer("latency_ms"),
+ error: text("error"),
+ checkedAt: integer("checked_at", { mode: "timestamp_ms" }).notNull(),
+ },
+ (table) => [
+ index("checks_monitor_id_checked_at_idx").on(
+ table.monitorId,
+ table.checkedAt,
+ ),
+ ],
+);
diff --git a/src/worker/index.ts b/src/worker/index.ts
index e3cc134..c9b68df 100644
--- a/src/worker/index.ts
+++ b/src/worker/index.ts
@@ -1,11 +1,13 @@
import { Hono } from "hono";
import { csrf } from "hono/csrf";
+import { runDueChecks } from "./checks/run-due-checks";
import authRoutes from "./routes/auth";
+import monitorRoutes from "./routes/monitors";
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
const app = new Hono<{ Bindings: Env }>();
-app.use("/api/auth/*", csrf());
+app.use("/api/*", csrf());
app.get("/api/health", async (context) => {
const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{
@@ -20,16 +22,19 @@ app.get("/api/health", async (context) => {
});
app.route("/", authRoutes);
+app.route("/api/monitors", monitorRoutes);
export default {
fetch: app.fetch,
async scheduled(controller, env) {
await cleanupExpiredAuthRecords(env);
+ const result = await runDueChecks(env);
console.log(
JSON.stringify({
- message: "scheduled auth cleanup completed",
+ message: "scheduled run completed",
cron: controller.cron,
scheduledTime: controller.scheduledTime,
+ ...result,
}),
);
},
diff --git a/src/worker/routes/monitors.ts b/src/worker/routes/monitors.ts
new file mode 100644
index 0000000..023a56e
--- /dev/null
+++ b/src/worker/routes/monitors.ts
@@ -0,0 +1,216 @@
+import { eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { runCheck } from "../checks/run-check";
+import { getDb } from "../db/client";
+import { checks, monitors } from "../db/schema";
+import { requireAuth, type AuthVariables } from "../lib/require-auth";
+
+type MonitorMethod = "GET" | "HEAD" | "POST";
+
+type ParsedMonitorInput = {
+ name?: string;
+ url?: string;
+ method?: MonitorMethod;
+ expectedStatus?: number;
+ intervalSeconds?: number;
+ timeoutMs?: number;
+ enabled?: boolean;
+};
+
+type ParseResult =
+ | { ok: true; value: ParsedMonitorInput }
+ | { ok: false; message: string };
+
+const METHODS = new Set(["GET", "HEAD", "POST"]);
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseInteger(
+ value: unknown,
+ name: string,
+ minimum: number,
+ maximum: number,
+): { ok: true; value: number } | { ok: false; message: string } {
+ if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
+ return {
+ ok: false,
+ message: `${name} must be an integer between ${minimum} and ${maximum}`,
+ };
+ }
+ return { ok: true, value: value as number };
+}
+
+export function parseMonitorInput(body: unknown, partial = false): ParseResult {
+ if (!isRecord(body)) return { ok: false, message: "Invalid request body" };
+
+ const value: ParsedMonitorInput = {};
+ if (!partial || "name" in body) {
+ if (typeof body.name !== "string" || body.name.trim().length < 1 || body.name.trim().length > 100) {
+ return { ok: false, message: "Name must be between 1 and 100 characters" };
+ }
+ value.name = body.name.trim();
+ }
+
+ if (!partial || "url" in body) {
+ if (typeof body.url !== "string") {
+ return { ok: false, message: "Enter a valid http or https URL" };
+ }
+ try {
+ const url = new URL(body.url);
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Invalid protocol");
+ value.url = url.toString();
+ } catch {
+ return { ok: false, message: "Enter a valid http or https URL" };
+ }
+ }
+
+ if (!partial || "method" in body) {
+ if (typeof body.method !== "string" || !METHODS.has(body.method as MonitorMethod)) {
+ return { ok: false, message: "Method must be GET, HEAD, or POST" };
+ }
+ value.method = body.method as MonitorMethod;
+ }
+
+ for (const [key, label, minimum, maximum] of [
+ ["expectedStatus", "expectedStatus", 100, 599],
+ ["intervalSeconds", "intervalSeconds", 300, 86_400],
+ ["timeoutMs", "timeoutMs", 1_000, 30_000],
+ ] as const) {
+ if (!partial || key in body) {
+ const parsed = parseInteger(body[key], label, minimum, maximum);
+ if (!parsed.ok) return parsed;
+ value[key] = parsed.value;
+ }
+ }
+
+ if ("enabled" in body) {
+ if (typeof body.enabled !== "boolean") {
+ return { ok: false, message: "enabled must be a boolean" };
+ }
+ value.enabled = body.enabled;
+ }
+
+ return { ok: true, value };
+}
+
+function parseId(rawId: string) {
+ const id = Number(rawId);
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
+}
+
+const monitorRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
+
+monitorRoutes.use("*", requireAuth);
+
+monitorRoutes.get("/", async (context) => {
+ const rows = await getDb(context.env).select().from(monitors).orderBy(monitors.createdAt);
+ return context.json({ monitors: rows });
+});
+
+monitorRoutes.post("/", async (context) => {
+ let body: unknown;
+ try {
+ body = await context.req.json();
+ } catch {
+ return context.json({ message: "Invalid request body" }, 400);
+ }
+
+ const parsed = parseMonitorInput(body);
+ if (!parsed.ok) return context.json({ message: parsed.message }, 400);
+
+ const now = new Date();
+ const [monitor] = await getDb(context.env)
+ .insert(monitors)
+ .values({
+ name: parsed.value.name!,
+ url: parsed.value.url!,
+ method: parsed.value.method!,
+ expectedStatus: parsed.value.expectedStatus!,
+ intervalSeconds: parsed.value.intervalSeconds!,
+ timeoutMs: parsed.value.timeoutMs!,
+ enabled: parsed.value.enabled ?? true,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+
+ return context.json({ monitor });
+});
+
+monitorRoutes.patch("/:id", async (context) => {
+ const id = parseId(context.req.param("id"));
+ if (id === null) return context.json({ message: "Monitor not found" }, 404);
+
+ let body: unknown;
+ try {
+ body = await context.req.json();
+ } catch {
+ return context.json({ message: "Invalid request body" }, 400);
+ }
+
+ const parsed = parseMonitorInput(body, true);
+ 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)
+ .update(monitors)
+ .set({ ...parsed.value, updatedAt: new Date() })
+ .where(eq(monitors.id, id))
+ .returning();
+ if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+
+ return context.json({ monitor });
+});
+
+monitorRoutes.delete("/: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 [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
+ if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+
+ await db.batch([
+ db.delete(checks).where(eq(checks.monitorId, id)),
+ db.delete(monitors).where(eq(monitors.id, id)),
+ ]);
+ return context.json({ ok: true });
+});
+
+monitorRoutes.post("/:id/check", 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 [monitor] = await db.select().from(monitors).where(eq(monitors.id, id)).limit(1);
+ if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+
+ const result = await runCheck(monitor);
+ const checkedAt = new Date();
+ const [, updated] = await db.batch([
+ db.insert(checks).values({
+ monitorId: monitor.id,
+ ok: result.ok,
+ statusCode: result.statusCode,
+ latencyMs: result.latencyMs,
+ error: result.error,
+ checkedAt,
+ }),
+ db.update(monitors).set({
+ lastOk: result.ok,
+ lastStatusCode: result.statusCode,
+ lastLatencyMs: result.latencyMs,
+ lastError: result.error,
+ lastCheckedAt: checkedAt,
+ updatedAt: checkedAt,
+ }).where(eq(monitors.id, monitor.id)).returning(),
+ ]);
+
+ return context.json({ result, monitor: updated[0] });
+});
+
+export default monitorRoutes;
diff --git a/src/worker/scheduled/cleanup.ts b/src/worker/scheduled/cleanup.ts
index 7eb6f5c..af335c7 100644
--- a/src/worker/scheduled/cleanup.ts
+++ b/src/worker/scheduled/cleanup.ts
@@ -1,8 +1,9 @@
import { lt } from "drizzle-orm";
import { getDb } from "../db/client";
-import { loginAttempts, sessions } from "../db/schema";
+import { checks, loginAttempts, sessions } from "../db/schema";
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
+const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export async function cleanupExpiredAuthRecords(env: Env) {
const db = getDb(env);
@@ -18,5 +19,13 @@ export async function cleanupExpiredAuthRecords(env: Env) {
new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS),
),
),
+ db
+ .delete(checks)
+ .where(
+ lt(
+ checks.checkedAt,
+ new Date(now.getTime() - CHECK_RETENTION_MS),
+ ),
+ ),
]);
}
diff --git a/test/checks.spec.ts b/test/checks.spec.ts
new file mode 100644
index 0000000..050dd58
--- /dev/null
+++ b/test/checks.spec.ts
@@ -0,0 +1,103 @@
+import { applyD1Migrations, env, type D1Migration } from "cloudflare:test";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { runDueChecks } from "../src/worker/checks/run-due-checks";
+
+async function clearMonitoringTables() {
+ await env.DB.batch([
+ env.DB.prepare("DELETE FROM checks"),
+ env.DB.prepare("DELETE FROM monitors"),
+ ]);
+}
+
+async function insertMonitor(overrides: Record = {}) {
+ const now = Date.now();
+ const values = {
+ name: "Example",
+ url: "https://example.com/health",
+ method: "GET",
+ expected_status: 200,
+ interval_seconds: 300,
+ timeout_ms: 10_000,
+ enabled: 1,
+ last_checked_at: null,
+ created_at: now,
+ updated_at: now,
+ ...overrides,
+ };
+ const result = await env.DB.prepare(`
+ INSERT INTO monitors
+ (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, last_checked_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `)
+ .bind(
+ values.name,
+ values.url,
+ values.method,
+ values.expected_status,
+ values.interval_seconds,
+ values.timeout_ms,
+ values.enabled,
+ values.last_checked_at,
+ values.created_at,
+ values.updated_at,
+ )
+ .run();
+ return Number(result.meta.last_row_id);
+}
+
+describe("scheduled monitor checks", () => {
+ beforeAll(async () => {
+ const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
+ await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
+ });
+ beforeEach(clearMonitoringTables);
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("records a successful check and updates the monitor snapshot", async () => {
+ const id = await insertMonitor();
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 })));
+
+ const summary = await runDueChecks(env);
+ expect(summary).toEqual({ checked: 1, up: 1, down: 0 });
+
+ const monitor = await env.DB.prepare("SELECT last_ok, last_status_code, last_latency_ms, last_checked_at FROM monitors WHERE id = ?")
+ .bind(id)
+ .first<{ last_ok: number; last_status_code: number; last_latency_ms: number; last_checked_at: number }>();
+ expect(monitor).toMatchObject({ last_ok: 1, last_status_code: 200 });
+ expect(monitor?.last_latency_ms).toEqual(expect.any(Number));
+ expect(monitor?.last_checked_at).toEqual(expect.any(Number));
+
+ const checkCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM checks WHERE monitor_id = ?")
+ .bind(id)
+ .first<{ count: number }>();
+ expect(checkCount?.count).toBe(1);
+ });
+
+ it("records a mismatched status as down with a useful error", async () => {
+ const id = await insertMonitor();
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 })));
+
+ const summary = await runDueChecks(env);
+ expect(summary).toEqual({ checked: 1, up: 0, down: 1 });
+
+ const monitor = await env.DB.prepare("SELECT last_ok, last_status_code, last_error FROM monitors WHERE id = ?")
+ .bind(id)
+ .first<{ last_ok: number; last_status_code: number; last_error: string }>();
+ expect(monitor).toEqual({
+ last_ok: 0,
+ last_status_code: 500,
+ last_error: "Expected HTTP 200, received 500",
+ });
+ });
+
+ it("skips disabled and not-yet-due monitors", async () => {
+ await insertMonitor({ name: "Disabled", enabled: 0 });
+ await insertMonitor({ name: "Recent", last_checked_at: Date.now() });
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const summary = await runDueChecks(env);
+ expect(summary).toEqual({ checked: 0, up: 0, down: 0 });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/monitors.spec.ts b/test/monitors.spec.ts
new file mode 100644
index 0000000..6c13e2b
--- /dev/null
+++ b/test/monitors.spec.ts
@@ -0,0 +1,137 @@
+import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
+import { beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { hashPassword } from "../src/worker/lib/password";
+
+const ADMIN_PASSWORD = "correct-horse-battery-staple";
+const VALID_MONITOR = {
+ name: "Example",
+ url: "https://example.com/health",
+ method: "GET",
+ expectedStatus: 200,
+ intervalSeconds: 300,
+ timeoutMs: 10_000,
+};
+
+async function seedAdmin() {
+ await env.DB.batch([
+ env.DB.prepare("DELETE FROM checks"),
+ 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"),
+ ]);
+ const now = Date.now();
+ await env.DB.prepare("INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)")
+ .bind(await hashPassword(ADMIN_PASSWORD), now, now)
+ .run();
+}
+
+async function authenticatedCookie() {
+ const response = await SELF.fetch("https://example.com/api/auth/login", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "CF-Connecting-IP": "198.51.100.20",
+ Origin: "https://example.com",
+ },
+ body: JSON.stringify({ password: ADMIN_PASSWORD }),
+ });
+ return response.headers.get("Set-Cookie")?.split(";", 1)[0] ?? "";
+}
+
+function apiFetch(path: string, method = "GET", cookie = "", body?: unknown) {
+ return SELF.fetch(`https://example.com${path}`, {
+ method,
+ headers: {
+ ...(cookie ? { Cookie: cookie } : {}),
+ ...(method !== "GET" ? { Origin: "https://example.com" } : {}),
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+}
+
+async function createMonitor(cookie: string, overrides: Record = {}) {
+ return apiFetch("/api/monitors", "POST", cookie, { ...VALID_MONITOR, ...overrides });
+}
+
+describe("monitor API", () => {
+ beforeAll(async () => {
+ const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
+ await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
+ });
+ beforeEach(seedAdmin);
+
+ it("protects every monitor endpoint, including the collection path without a trailing slash", async () => {
+ const requests = [
+ apiFetch("/api/monitors"),
+ apiFetch("/api/monitors", "POST", "", VALID_MONITOR),
+ apiFetch("/api/monitors/1", "PATCH", "", { name: "Changed" }),
+ apiFetch("/api/monitors/1", "DELETE"),
+ apiFetch("/api/monitors/1/check", "POST"),
+ ];
+ const responses = await Promise.all(requests);
+ for (const response of responses) {
+ expect(response.status).toBe(401);
+ expect(await response.json()).toEqual({ message: "Authentication required" });
+ }
+ });
+
+ it("creates a valid monitor and returns it in the list", async () => {
+ const cookie = await authenticatedCookie();
+ const response = await createMonitor(cookie);
+ const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean } }>();
+
+ expect(response.status).toBe(200);
+ expect(created.monitor).toMatchObject({ name: "Example", enabled: true });
+
+ const listResponse = await apiFetch("/api/monitors", "GET", cookie);
+ const list = await listResponse.json<{ monitors: Array<{ id: number; url: string }> }>();
+ expect(list.monitors).toHaveLength(1);
+ expect(list.monitors[0]).toMatchObject({ id: created.monitor.id, url: "https://example.com/health" });
+ });
+
+ it.each([
+ [{ url: "file:///etc/passwd" }, "Enter a valid http or https URL"],
+ [{ intervalSeconds: 60 }, "intervalSeconds must be an integer between 300 and 86400"],
+ [{ expectedStatus: 99 }, "expectedStatus must be an integer between 100 and 599"],
+ ] as const)("rejects invalid monitor input %o", async (overrides, message) => {
+ const response = await createMonitor(await authenticatedCookie(), overrides);
+ expect(response.status).toBe(400);
+ expect(await response.json()).toEqual({ message });
+ });
+
+ it("patches only the requested fields", async () => {
+ const cookie = await authenticatedCookie();
+ const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
+ const response = await apiFetch(`/api/monitors/${created.monitor.id}`, "PATCH", cookie, {
+ name: "Renamed endpoint",
+ enabled: false,
+ });
+ const body = await response.json<{ monitor: { name: string; url: string; enabled: boolean } }>();
+
+ expect(response.status).toBe(200);
+ expect(body.monitor).toMatchObject({
+ name: "Renamed endpoint",
+ url: "https://example.com/health",
+ enabled: false,
+ });
+ });
+
+ it("deletes the monitor and its check records explicitly", async () => {
+ const cookie = await authenticatedCookie();
+ const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
+ await env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 12, ?)")
+ .bind(created.monitor.id, Date.now())
+ .run();
+
+ const response = await apiFetch(`/api/monitors/${created.monitor.id}`, "DELETE", cookie);
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ ok: true });
+
+ const monitorCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM monitors").first<{ count: number }>();
+ const checkCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM checks").first<{ count: number }>();
+ expect(monitorCount?.count).toBe(0);
+ expect(checkCount?.count).toBe(0);
+ });
+});