diff --git a/web/app/monitors/[id]/page.tsx b/web/app/monitors/[id]/page.tsx new file mode 100644 index 0000000..fd8aa31 --- /dev/null +++ b/web/app/monitors/[id]/page.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { api, Monitor, MonitorStatus, Rollup } from "@/lib/api"; +import { Badge, Button, Card, CardHeader, CardTitle, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; + +function statusVariant(status: MonitorStatus) { + switch (status) { + case "up": + return "success"; + case "down": + return "danger"; + default: + return "warning"; + } +} + +function uptimePct(rollups: Rollup[]): number { + const checks = rollups.reduce((a, r) => a + r.checks, 0); + const up = rollups.reduce((a, r) => a + r.up_count, 0); + return checks > 0 ? (up / checks) * 100 : 0; +} + +function Heartbeat({ rollups }: { rollups: Rollup[] }) { + const recent = rollups.slice(-48); + return ( +
+ {recent.map((r) => { + const pct = r.checks > 0 ? (r.up_count / r.checks) * 100 : 0; + const color = r.checks === 0 ? "bg-surface-2" : pct >= 99 ? "bg-success" : pct >= 80 ? "bg-warning" : "bg-danger"; + return ( +
+ ); + })} + {recent.length === 0 && No history yet.} +
+ ); +} + +export default function MonitorDetailPage() { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const monitorId = params.id as string; + const [confirmDelete, setConfirmDelete] = useState(false); + + const { data: monitor, isLoading } = useQuery({ + queryKey: ["monitors", monitorId], + queryFn: () => api.getMonitor(monitorId), + refetchInterval: 30_000, + }); + + const { data: rollups } = useQuery({ + queryKey: ["monitors", monitorId, "uptime"], + queryFn: () => api.getMonitorUptime(monitorId), + refetchInterval: 60_000, + }); + + const { data: incidents } = useQuery({ + queryKey: ["monitors", monitorId, "incidents"], + queryFn: () => api.getMonitorIncidents(monitorId), + refetchInterval: 60_000, + }); + + const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({ + mutationFn: () => api.deleteMonitor(monitorId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["monitors"] }); + router.push("/monitors"); + }, + }); + + const { mutate: toggleEnabled } = useMutation({ + mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }), + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!monitor) { + return ( +
+
Monitor not found.
+
+ ); + } + + const all = rollups ?? []; + const last24 = all.slice(-24); + + return ( +
+
+
+ + ← Monitors + +
+

{monitor.name}

+ {monitor.state.status} + {monitor.type} + {!monitor.enabled && disabled} +
+ {monitor.state.message &&

{monitor.state.message}

} +
+
+ + {!confirmDelete ? ( + + ) : ( +
+ Are you sure? + + +
+ )} +
+
+ +
+ +

Uptime (24h)

+

{uptimePct(last24).toFixed(1)}%

+
+ +

Uptime (30d)

+

{uptimePct(all).toFixed(1)}%

+
+ +

Latency

+

{monitor.state.latency_ms}ms

+
+ +

Cert expiry

+

+ {monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "—"} +

+
+
+ + + + Heartbeat (last 48h) + + + + +
+ +
+

Incidents

+
+ {!incidents || incidents.length === 0 ? ( +
No incidents recorded.
+ ) : ( + + + + + + + + + + {incidents.map((inc) => ( + + + + + + ))} + +
StartedResolvedCause
+ {new Date(inc.started_at).toLocaleString()} + + {inc.resolved_at ? ( + {new Date(inc.resolved_at).toLocaleString()} + ) : ( + ongoing + )} + + {inc.cause || "—"} +
+ )} +
+ + + + Configuration + +
+
+
Runner
+
{monitor.runner}
+
+
+
Interval
+
{monitor.interval_sec}s
+
+
+
Retries before down
+
{monitor.retries}
+
+
+
Target
+
+ {monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`} +
+
+
+
+
+
+ ); +} diff --git a/web/app/monitors/new/page.tsx b/web/app/monitors/new/page.tsx new file mode 100644 index 0000000..3f02ac3 --- /dev/null +++ b/web/app/monitors/new/page.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { api, MonitorInput, MonitorType } from "@/lib/api"; +import { Button, Card } from "@/components/ui"; + +const inputClass = + "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + +const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary"; + +export default function NewMonitorPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + + const [name, setName] = useState(""); + const [type, setType] = useState("http"); + const [url, setUrl] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState(443); + const [method, setMethod] = useState("GET"); + const [expectedStatus, setExpectedStatus] = useState(200); + const [keyword, setKeyword] = useState(""); + const [tlsWarnDays, setTlsWarnDays] = useState(14); + const [intervalSec, setIntervalSec] = useState(60); + const [retries, setRetries] = useState(1); + const [runner, setRunner] = useState("server"); + const [enabled, setEnabled] = useState(true); + + const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); + + const { mutate: create, isPending, error } = useMutation({ + mutationFn: (input: MonitorInput) => api.createMonitor(input), + onSuccess: (m) => { + queryClient.invalidateQueries({ queryKey: ["monitors"] }); + router.push(`/monitors/${m.monitor_id}`); + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const target: MonitorInput["target"] = {}; + if (type === "http") { + target.url = url; + target.method = method; + target.expected_status = expectedStatus; + if (keyword) target.keyword = keyword; + } else if (type === "tls") { + target.host = host; + target.port = port || 443; + target.tls_warn_days = tlsWarnDays; + } else if (type === "icmp") { + target.host = host; + } else { + target.host = host; + target.port = port; + } + create({ name, type, target, interval_sec: intervalSec, retries, runner, enabled }); + } + + return ( +
+ + ← Monitors + +

New Monitor

+ + +
+
+ + setName(e.target.value)} placeholder="e.g. API health" required /> +
+ +
+ +
+ {(["http", "tcp", "icmp", "tls"] as const).map((t) => ( + + ))} +
+
+ + {type === "http" && ( + <> +
+ + setUrl(e.target.value)} placeholder="https://example.com/health" required /> +
+
+
+ + +
+
+ + setExpectedStatus(Number(e.target.value))} /> +
+
+
+ + setKeyword(e.target.value)} placeholder="e.g. ok" /> +
+ + )} + + {(type === "tcp" || type === "tls" || type === "icmp") && ( +
+
+ + setHost(e.target.value)} placeholder="example.com" required /> +
+ {type !== "icmp" && ( +
+ + setPort(Number(e.target.value))} /> +
+ )} +
+ )} + + {type === "tls" && ( +
+ + setTlsWarnDays(Number(e.target.value))} /> +
+ )} + +
+
+ + setIntervalSec(Number(e.target.value))} min={10} /> +
+
+ + setRetries(Number(e.target.value))} min={1} /> +
+
+ +
+ + +

Agent-run monitors require the agent monitor scheduler (P2).

+
+ + + + {error &&

{(error as Error).message}

} + +
+ + + + +
+
+
+
+ ); +} diff --git a/web/app/monitors/page.tsx b/web/app/monitors/page.tsx new file mode 100644 index 0000000..f958857 --- /dev/null +++ b/web/app/monitors/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { api, Monitor, MonitorStatus } from "@/lib/api"; +import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; + +function statusVariant(status: MonitorStatus) { + switch (status) { + case "up": + return "success"; + case "down": + return "danger"; + default: + return "warning"; + } +} + +function targetSummary(m: Monitor): string { + if (m.type === "http") return m.target.url ?? ""; + if (m.type === "tls") return `${m.target.host ?? ""}:${m.target.port || 443}`; + if (m.type === "icmp") return m.target.host ?? ""; + return `${m.target.host ?? ""}:${m.target.port ?? ""}`; +} + +export default function MonitorsPage() { + const { data: monitors, isLoading } = useQuery({ + queryKey: ["monitors"], + queryFn: () => api.listMonitors(), + refetchInterval: 30_000, + }); + + return ( +
+
+
+

Monitors

+

Service uptime and latency checks.

+
+ + + +
+ + + {isLoading ? ( +
+
+
+ ) : !monitors || monitors.length === 0 ? ( +
+

No monitors yet.

+ + + +
+ ) : ( + + + + + + + + + + + + + {monitors.map((m) => ( + + + + + + + + + ))} + +
NameTypeTargetStatusLatencyLast check
+ + {m.name} + + + {m.type} + + {targetSummary(m)} + + {m.state.status} + + {m.state.latency_ms}ms + + + {m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "—"} + +
+ )} + +
+ ); +} diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 8f0e838..60d7520 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -60,6 +60,14 @@ function SettingsIcon() { ); } +function MonitorIcon() { + return ( + + + + ); +} + function StepsIcon() { return ( @@ -70,6 +78,7 @@ function StepsIcon() { const navItems: NavItem[] = [ { href: "/servers", label: "Servers", icon: }, + { href: "/monitors", label: "Monitors", icon: }, { href: "/keys", label: "SSH Keys", icon: }, { href: "/secrets", label: "Secrets", icon: }, { href: "/workflows", label: "Workflows", icon: }, diff --git a/web/lib/api.ts b/web/lib/api.ts index 92f89f8..c169483 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -34,6 +34,69 @@ export interface Server { inventory?: Inventory; } +export type MonitorType = "http" | "tcp" | "icmp" | "tls"; +export type MonitorStatus = "up" | "down" | "pending"; + +export interface MonitorTarget { + url?: string; + host?: string; + port?: number; + method?: string; + expected_status?: number; + keyword?: string; + tls_warn_days?: number; +} + +export interface MonitorState { + status: MonitorStatus; + last_check_at?: string; + latency_ms: number; + message?: string; + cert_expiry_at?: string; + fails: number; +} + +export interface Monitor { + monitor_id: string; + name: string; + type: MonitorType; + target: MonitorTarget; + interval_sec: number; + runner: string; // "server" or a server_id + retries: number; + enabled: boolean; + channel_ids?: string[]; + state: MonitorState; + created_at: string; +} + +export interface MonitorInput { + name: string; + type: MonitorType; + target: MonitorTarget; + interval_sec: number; + runner: string; + retries: number; + enabled: boolean; + channel_ids?: string[]; +} + +export interface Incident { + incident_id: string; + monitor_id: string; + started_at: string; + resolved_at?: string; + cause?: string; +} + +export interface Rollup { + monitor_id: string; + period_start: string; + checks: number; + up_count: number; + sum_latency: number; +} + export interface ConsoleConnectRequest { server_id: string; protocol: string; @@ -278,6 +341,35 @@ export const api = { return `curl -fsSL "${window.location.origin}/update" | bash`; }, + // Monitors + listMonitors(): Promise { + return request("/monitors"); + }, + + getMonitor(monitorId: string): Promise { + return request(`/monitors/${monitorId}`); + }, + + createMonitor(input: MonitorInput): Promise { + return request("/monitors", { method: "POST", body: JSON.stringify(input) }); + }, + + updateMonitor(monitorId: string, input: Partial): Promise { + return request(`/monitors/${monitorId}`, { method: "PUT", body: JSON.stringify(input) }); + }, + + deleteMonitor(monitorId: string): Promise { + return request(`/monitors/${monitorId}`, { method: "DELETE" }); + }, + + getMonitorIncidents(monitorId: string): Promise { + return request(`/monitors/${monitorId}/incidents`); + }, + + getMonitorUptime(monitorId: string): Promise { + return request(`/monitors/${monitorId}/uptime`); + }, + getLatestAgentVersion(): Promise<{ version: string }> { return request<{ version: string }>("/agent/latest-version"); },