diff --git a/migrations/0002_lean_mac_gargan.sql b/migrations/0002_lean_mac_gargan.sql new file mode 100644 index 0000000..5f5762d --- /dev/null +++ b/migrations/0002_lean_mac_gargan.sql @@ -0,0 +1,31 @@ +CREATE TABLE `checks` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `monitor_id` integer NOT NULL, + `ok` integer NOT NULL, + `status_code` integer, + `latency_ms` integer, + `error` text, + `checked_at` integer NOT NULL, + FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `checks_monitor_id_checked_at_idx` ON `checks` (`monitor_id`,`checked_at`);--> statement-breakpoint +CREATE TABLE `monitors` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `url` text NOT NULL, + `method` text DEFAULT 'GET' NOT NULL, + `expected_status` integer DEFAULT 200 NOT NULL, + `interval_seconds` integer DEFAULT 300 NOT NULL, + `timeout_ms` integer DEFAULT 10000 NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `last_ok` integer, + `last_status_code` integer, + `last_latency_ms` integer, + `last_error` text, + `last_checked_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `monitors_enabled_last_checked_at_idx` ON `monitors` (`enabled`,`last_checked_at`); \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..23a128d --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,353 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92127a68-ea12-46dd-bab7-1d7d91259fd4", + "prevId": "7773aaeb-a97b-4dd2-b5c7-312c83e1d6fb", + "tables": { + "admin_credentials": { + "name": "admin_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checks": { + "name": "checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "checks_monitor_id_checked_at_idx": { + "name": "checks_monitor_id_checked_at_idx", + "columns": [ + "monitor_id", + "checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checks_monitor_id_monitors_id_fk": { + "name": "checks_monitor_id_monitors_id_fk", + "tableFrom": "checks", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_attempts": { + "name": "login_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "login_attempts_ip_attempted_at_idx": { + "name": "login_attempts_ip_attempted_at_idx", + "columns": [ + "ip_address", + "attempted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitors": { + "name": "monitors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "expected_status": { + "name": "expected_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 200 + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10000 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_ok": { + "name": "last_ok", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status_code": { + "name": "last_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_latency_ms": { + "name": "last_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitors_enabled_last_checked_at_idx": { + "name": "monitors_enabled_last_checked_at_idx", + "columns": [ + "enabled", + "last_checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 93d5869..7b4810f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1787837780070, "tag": "0001_new_betty_brant", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1787839000288, + "tag": "0002_lean_mac_gargan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/App.tsx b/src/client/App.tsx index 8372ef4..1c90a72 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,225 +1,16 @@ -import { useHealthQuery } from "./queries/health"; -import { navigate, usePathname } from "./lib/router"; +import { RequireAuth } from "./components/RequireAuth"; +import { usePathname } from "./lib/router"; +import { DashboardPage } from "./pages/DashboardPage"; import { LoginPage } from "./pages/LoginPage"; -import { useLogoutMutation, useSessionQuery } from "./queries/auth"; - -type IconProps = { - className?: string; -}; - -function LogoMark({ className }: IconProps) { - return ( - - ); -} - -function ArrowIcon({ className }: IconProps) { - return ( - - ); -} - -function RefreshIcon({ className }: IconProps) { - return ( - - ); -} - -function DatabaseIcon({ className }: IconProps) { - return ( - - ); -} - -function WorkerIcon({ className }: IconProps) { - return ( - - ); -} - -function formatCheckedAt(timestamp: number) { - return new Intl.DateTimeFormat("en", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).format(new Date(timestamp)); -} - -export function LandingPage() { - const { data: health, error, isError, isFetching, isPending, refetch } = useHealthQuery(); - const sessionQuery = useSessionQuery(); - const logoutMutation = useLogoutMutation(); - const hasHealth = health !== undefined; - const isHealthy = hasHealth && health.ok && health.db?.ok === 1; - const statusLabel = isPending ? "Checking" : isHealthy ? "Operational" : "Degraded"; - const errorMessage = error instanceof Error ? error.message : "Unknown request error"; - - return ( -
-
- -
- -
-
-
-
- Live - Monitoring from Cloudflare's edge - -
- -

Uptime monitoring
that stays out of the way.

-

- Fast, dependable checks for every service you run. See what is healthy, - catch what is not, and get back to building. -

- -
- - Explore live status - - - See how it works -
- -
- -

Edge-native by design.
Worker, D1, and scheduled checks in one stack.

-
-
- -
-
-
-
upwatch
-
Production
- -
- -
- - -
-
-
-

Project health

-

Infrastructure

-
- - {statusLabel} - -
- -
-
-

System status

- {isPending ? "—" : isHealthy ? "100%" : "0%"} - Current availability -
-
-

Checks

- Every 5 min - -
-
- -
-
ServicesResponseStatus
-
-
Edge Worker/api/health
- {isPending ? "checking" : hasHealth ? "200 OK" : "failed"} - {isPending ? "Checking" : hasHealth ? "Online" : "Offline"} -
-
-
D1 Databaseuptime / sqlite
- {isPending ? "checking" : hasHealth ? `ok: ${health.db?.ok ?? 0}` : "unreachable"} - {isPending ? "Checking" : isHealthy ? "Online" : "Offline"} -
-
- -
- {hasHealth ? `Last checked at ${formatCheckedAt(health.ts)}` : isError ? errorMessage : "Running health check…"} - -
-
-
-
- - -
-
-
- -
-

Built on Cloudflare Workers and D1.

-
{statusLabel}© 2026 Upwatch
-
-
- ); -} function App() { const pathname = usePathname(); - return pathname === "/login" ? : ; + if (pathname === "/login") return ; + return ( + + + + ); } export default App; diff --git a/src/client/api/http.ts b/src/client/api/http.ts index 0bd943f..0244a66 100644 --- a/src/client/api/http.ts +++ b/src/client/api/http.ts @@ -40,7 +40,8 @@ export async function getJson( return response.json() as Promise; } -export function postJson( +function sendJson( + method: "POST" | "PATCH" | "DELETE", input: RequestInfo | URL, body?: unknown, init: RequestInit = {}, @@ -50,9 +51,32 @@ export function postJson( return getJson(input, { ...init, - method: "POST", + method, headers, credentials: "same-origin", body: body === undefined ? undefined : JSON.stringify(body), }); } + +export function postJson( + input: RequestInfo | URL, + body?: unknown, + init: RequestInit = {}, +) { + return sendJson("POST", input, body, init); +} + +export function patchJson( + input: RequestInfo | URL, + body?: unknown, + init: RequestInit = {}, +) { + return sendJson("PATCH", input, body, init); +} + +export function deleteJson( + input: RequestInfo | URL, + init: RequestInit = {}, +) { + return sendJson("DELETE", input, undefined, init); +} diff --git a/src/client/api/monitors.ts b/src/client/api/monitors.ts new file mode 100644 index 0000000..8f791b9 --- /dev/null +++ b/src/client/api/monitors.ts @@ -0,0 +1,61 @@ +import { deleteJson, getJson, patchJson, postJson } from "./http"; + +export type MonitorMethod = "GET" | "HEAD" | "POST"; + +export type Monitor = { + id: number; + name: string; + url: string; + method: MonitorMethod; + expectedStatus: number; + intervalSeconds: number; + timeoutMs: number; + enabled: boolean; + lastOk: boolean | null; + lastStatusCode: number | null; + lastLatencyMs: number | null; + lastError: string | null; + lastCheckedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type MonitorInput = { + name: string; + url: string; + method: MonitorMethod; + expectedStatus: number; + intervalSeconds: number; + timeoutMs: number; + enabled?: boolean; +}; + +export type CheckResult = { + ok: boolean; + statusCode: number | null; + latencyMs: number; + error: string | null; +}; + +export function listMonitors(signal?: AbortSignal) { + return getJson<{ monitors: Monitor[] }>("/api/monitors", { + signal, + credentials: "same-origin", + }); +} + +export function createMonitor(input: MonitorInput) { + return postJson<{ monitor: Monitor }>("/api/monitors", input); +} + +export function updateMonitor(id: number, input: Partial) { + return patchJson<{ monitor: Monitor }>(`/api/monitors/${id}`, input); +} + +export function deleteMonitor(id: number) { + return deleteJson<{ ok: true }>(`/api/monitors/${id}`); +} + +export function runMonitorCheck(id: number) { + return postJson<{ result: CheckResult; monitor: Monitor }>(`/api/monitors/${id}/check`); +} diff --git a/src/client/components/RequireAuth.tsx b/src/client/components/RequireAuth.tsx new file mode 100644 index 0000000..fbd165a --- /dev/null +++ b/src/client/components/RequireAuth.tsx @@ -0,0 +1,28 @@ +import { type ReactNode, useEffect } from "react"; +import { LogoMark } from "./icons"; +import { navigate } from "../lib/router"; +import { useSessionQuery } from "../queries/auth"; + +function FullPageLoading() { + return ( +
+ +

Checking session…

+
+ ); +} + +export function RequireAuth({ children }: { children: ReactNode }) { + const sessionQuery = useSessionQuery(); + const authenticated = sessionQuery.data?.authenticated ?? false; + + useEffect(() => { + if (!sessionQuery.isPending && !authenticated) { + navigate("/login", { replace: true }); + } + }, [sessionQuery.isPending, authenticated]); + + if (sessionQuery.isPending) return ; + if (!authenticated) return null; + return <>{children}; +} diff --git a/src/client/components/icons.tsx b/src/client/components/icons.tsx new file mode 100644 index 0000000..31b3375 --- /dev/null +++ b/src/client/components/icons.tsx @@ -0,0 +1,36 @@ +type IconProps = { + className?: string; +}; + +export function LogoMark({ className }: IconProps) { + return ( + + ); +} + +export function ArrowIcon({ className }: IconProps) { + return ( + + ); +} + +export function RefreshIcon({ className }: IconProps) { + return ( + + ); +} + +export function DatabaseIcon({ className }: IconProps) { + return ( + + ); +} diff --git a/src/client/lib/query-client.ts b/src/client/lib/query-client.ts index 8e1bb19..180783d 100644 --- a/src/client/lib/query-client.ts +++ b/src/client/lib/query-client.ts @@ -1,6 +1,20 @@ -import { QueryClient } from "@tanstack/react-query"; +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; +import { ApiError } from "../api/http"; +import type { SessionResponse } from "../api/auth"; -export const queryClient = new QueryClient({ +const sessionQueryKey = ["auth", "session"] as const; + +function handleAuthError(error: unknown) { + if (error instanceof ApiError && error.status === 401) { + queryClient.setQueryData(sessionQueryKey, { + authenticated: false, + }); + } +} + +export const queryClient: QueryClient = new QueryClient({ + queryCache: new QueryCache({ onError: handleAuthError }), + mutationCache: new MutationCache({ onError: handleAuthError }), defaultOptions: { queries: { retry: 1, diff --git a/src/client/pages/DashboardPage.tsx b/src/client/pages/DashboardPage.tsx new file mode 100644 index 0000000..d6d39b0 --- /dev/null +++ b/src/client/pages/DashboardPage.tsx @@ -0,0 +1,198 @@ +import { type FormEvent, useState } from "react"; +import type { Monitor, MonitorInput, MonitorMethod } from "../api/monitors"; +import { ArrowIcon, DatabaseIcon, LogoMark, RefreshIcon } from "../components/icons"; +import { useLogoutMutation } from "../queries/auth"; +import { + useCreateMonitorMutation, + useDeleteMonitorMutation, + useMonitorsQuery, + useRunCheckMutation, + useUpdateMonitorMutation, +} from "../queries/monitors"; + +const DEFAULT_INPUT: MonitorInput = { + name: "", + url: "https://", + method: "GET", + expectedStatus: 200, + intervalSeconds: 300, + timeoutMs: 10_000, + enabled: true, +}; + +function formatCheckedAt(value: string | null) { + if (!value) return "Not checked yet"; + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +function monitorStatus(monitor: Monitor) { + if (monitor.lastOk === true) return { label: "Up", className: "online" }; + if (monitor.lastOk === false) return { label: "Down", className: "offline" }; + return { label: "Not checked", className: "checking" }; +} + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export function DashboardPage() { + const monitorsQuery = useMonitorsQuery(); + const createMutation = useCreateMonitorMutation(); + const updateMutation = useUpdateMonitorMutation(); + const deleteMutation = useDeleteMonitorMutation(); + const checkMutation = useRunCheckMutation(); + const logoutMutation = useLogoutMutation(); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(DEFAULT_INPUT); + + const monitors = monitorsQuery.data?.monitors ?? []; + const up = monitors.filter((monitor) => monitor.lastOk === true).length; + const down = monitors.filter((monitor) => monitor.lastOk === false).length; + const formMutation = editing ? updateMutation : createMutation; + + function openCreateForm() { + setEditing(null); + setForm(DEFAULT_INPUT); + setFormOpen(true); + } + + function openEditForm(monitor: Monitor) { + setEditing(monitor); + setForm({ + name: monitor.name, + url: monitor.url, + method: monitor.method, + expectedStatus: monitor.expectedStatus, + intervalSeconds: monitor.intervalSeconds, + timeoutMs: monitor.timeoutMs, + enabled: monitor.enabled, + }); + setFormOpen(true); + } + + function closeForm() { + if (formMutation.isPending) return; + setFormOpen(false); + setEditing(null); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const onSuccess = () => closeForm(); + if (editing) { + updateMutation.mutate({ id: editing.id, input: form }, { onSuccess }); + } else { + createMutation.mutate(form, { onSuccess }); + } + } + + function handleDelete(monitor: Monitor) { + if (window.confirm(`Delete ${monitor.name} and its check history?`)) { + deleteMutation.mutate(monitor.id); + } + } + + return ( +
+
+
+ + + upwatch + +
+ Production monitors + +
+
+
+ +
+
+
+

Infrastructure

+

Monitors

+

Track endpoint availability from Cloudflare's edge every five minutes.

+
+ {formOpen ? ( + + ) : monitorsQuery.isSuccess && monitors.length > 0 ? ( + + ) : null} +
+ +
+

Total monitors

{monitors.length}{monitors.filter((monitor) => monitor.enabled).length} enabled
+

Currently up

{up}Latest checks succeeded
+

Currently down

{down}Needs attention
+
+ + {formOpen && ( +
+
+

Configuration

{editing ? `Edit ${editing.name}` : "Add a monitor"}

+

Checks run on the configured schedule, with a minimum interval of five minutes.

+
+
+ + + + + + + +
+ + +
+ {formMutation.isError &&

{errorMessage(formMutation.error, "Unable to save monitor")}

} +
+
+ )} + +
+
+

Configured sites

Latest result for each monitored endpoint.

+ +
+ + {monitorsQuery.isPending ? ( +
{[0, 1, 2].map((item) =>
)}
+ ) : monitorsQuery.isError ? ( +
Monitors could not be loaded

{errorMessage(monitorsQuery.error, "Unknown request error")}

+ ) : monitors.length === 0 ? ( +
No monitors yet

Add the first endpoint to start collecting availability checks.

{!formOpen && }
+ ) : ( +
+
MonitorLatest resultActions
+ {monitors.map((monitor) => { + const status = monitorStatus(monitor); + const checking = checkMutation.isPending && checkMutation.variables === monitor.id; + const deleting = deleteMutation.isPending && deleteMutation.variables === monitor.id; + const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id; + return ( +
+
{monitor.name}{monitor.url}{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
+
{checking ? "Checking" : status.label}{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}
+
+
+ ); + })} +
+ )} +
+
+
Cloudflare Workers + D1Automatic refresh every minute
+
+ ); +} diff --git a/src/client/pages/LandingPage.tsx b/src/client/pages/LandingPage.tsx deleted file mode 100644 index 4d9eeef..0000000 --- a/src/client/pages/LandingPage.tsx +++ /dev/null @@ -1 +0,0 @@ -export { LandingPage } from "../App"; diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx index 6f1878e..3f916f7 100644 --- a/src/client/pages/LoginPage.tsx +++ b/src/client/pages/LoginPage.tsx @@ -1,4 +1,5 @@ import { type FormEvent, useEffect, useState } from "react"; +import { LogoMark } from "../components/icons"; import { navigate } from "../lib/router"; import { useLoginMutation, useSessionQuery } from "../queries/auth"; @@ -25,13 +26,10 @@ export function LoginPage() { return (
- { - event.preventDefault(); - navigate("/"); - }} aria-label="Upwatch home"> - +
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); + }); +});