From 59e7ef63fe1764078a5f3ab317ea157c251b9efe Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 09:41:07 +0000 Subject: [PATCH] feat(web): patching API client, status vocabulary and navigation --- web/components/Sidebar.tsx | 9 ++ web/components/patching/status.ts | 68 +++++++++++++ web/lib/api.ts | 155 +++++++++++++++++++++++++++++- web/lib/auditEvents.ts | 1 + 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 web/components/patching/status.ts diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index eb74662..f696ac8 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -176,6 +176,14 @@ function TokenIcon() { ); } +function PatchIcon() { + return ( + + + + ); +} + const navGroups: NavGroup[] = [ { label: "Fleet", @@ -184,6 +192,7 @@ const navGroups: NavGroup[] = [ { href: "/workloads", label: "Workloads", icon: }, { href: "/monitors", label: "Monitors", icon: }, { href: "/vulnerabilities", label: "Vulnerabilities", icon: }, + { href: "/patching", label: "Patching", icon: }, ], }, { diff --git a/web/components/patching/status.ts b/web/components/patching/status.ts new file mode 100644 index 0000000..1dd0b7d --- /dev/null +++ b/web/components/patching/status.ts @@ -0,0 +1,68 @@ +import type { PatchRunStatus, PatchServerStatus } from "@/lib/api"; + +type Variant = "success" | "warning" | "danger" | "neutral" | "accent"; + +/* + * One place that says how every patch state reads. Badge adds a dot to the + * three state variants, so each label here is also readable without colour. + */ +export const RUN_STATUS: Record = { + running: { label: "running", variant: "accent" }, + succeeded: { label: "succeeded", variant: "success" }, + partial: { label: "partial", variant: "warning" }, + failed: { label: "failed", variant: "danger" }, + cancelled: { label: "cancelled", variant: "neutral" }, +}; + +export const SERVER_STATUS: Record = { + queued: { label: "queued", variant: "neutral" }, + waiting_offline: { label: "waiting for agent", variant: "warning" }, + patching: { label: "patching", variant: "accent" }, + rebooting: { label: "rebooting", variant: "accent" }, + succeeded: { label: "succeeded", variant: "success" }, + failed: { label: "failed", variant: "danger" }, + unsupported: { label: "unsupported", variant: "warning" }, + agent_too_old: { label: "agent too old", variant: "warning" }, + missed_offline: { label: "missed, offline", variant: "danger" }, + window_closed: { label: "window closed", variant: "danger" }, + cancelled: { label: "cancelled", variant: "neutral" }, +}; + +/* + * Mirrors patchrun.MinAgentVersion and AgentSupportsPatchResults in the + * server. The server is the authority; this only lets the policy editor warn + * before saving. Change both together. + */ +export const MIN_AGENT_VERSION = "1.4.0"; + +export function agentSupportsPatchResults(version?: string): boolean { + const m = /^v?(\d+)\.(\d+)\.(\d+)(-[^+]+)?/.exec((version ?? "").trim()); + if (!m) return false; + const have = [Number(m[1]), Number(m[2]), Number(m[3])]; + const want = MIN_AGENT_VERSION.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (have[i] !== want[i]) return have[i] > want[i]; + } + return !m[4]; +} + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/** Plain words for the common shapes; anything else shows the expression itself. */ +export function describeCron(cron: string): string { + const f = cron.trim().split(/\s+/); + if (f.length !== 5 || !/^\d+$/.test(f[0]) || !/^\d+$/.test(f[1])) return cron; + const at = `${f[1].padStart(2, "0")}:${f[0].padStart(2, "0")}`; + const [, , dom, mon, dow] = f; + if (dom === "*" && mon === "*" && dow === "*") return `Daily at ${at}`; + if (dom === "*" && mon === "*" && /^[0-6]$/.test(dow)) return `${DAYS[Number(dow)]}s at ${at}`; + if (/^\d+$/.test(dom) && mon === "*" && dow === "*") return `Day ${dom} of each month at ${at}`; + return cron; +} + +export function formatDuration(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m} min`; + return m === 0 ? `${h} h` : `${h} h ${m} min`; +} diff --git a/web/lib/api.ts b/web/lib/api.ts index cc4edfc..75060cd 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -417,6 +417,99 @@ export interface WorkflowRun { server_runs: ServerRun[]; } +export type PatchScope = "all" | "security"; +export type PatchReboot = "never" | "if_required"; +export type PatchRunStatus = "running" | "succeeded" | "partial" | "failed" | "cancelled"; +export type PatchServerStatus = + | "queued" + | "waiting_offline" + | "patching" + | "rebooting" + | "succeeded" + | "failed" + | "unsupported" + | "agent_too_old" + | "missed_offline" + | "window_closed" + | "cancelled"; + +export interface MaintenanceWindow { + window_id: string; + name: string; + cron: string; + tz: string; + duration_minutes: number; + created_at: string; + updated_at: string; +} + +export interface MaintenanceWindowInput { + name: string; + cron: string; + tz: string; + duration_minutes: number; +} + +export interface WindowSpan { + start: string; + end: string; +} + +export interface PatchPolicy { + policy_id: string; + name: string; + enabled: boolean; + window_id: string; + target_server_ids: string[]; + target_tags?: Record; + scope: PatchScope; + reboot: PatchReboot; + max_concurrent: number; + notify_channel_ids?: string[]; + next_run_at?: string; + last_run_at?: string; + last_skipped?: Skip; + disabled_reason?: string; + created_at: string; + updated_at: string; +} + +export type PatchPolicyInput = Pick< + PatchPolicy, + "name" | "enabled" | "window_id" | "target_server_ids" | "target_tags" | "scope" | "reboot" | "max_concurrent" | "notify_channel_ids" +>; + +export interface PatchServerRun { + server_id: string; + hostname: string; + status: PatchServerStatus; + pending_before: number; + pending_after?: number; + rebooted_at?: string; + verified_at?: string; + output?: string; + error?: string; + started_at?: string; + finished_at?: string; +} + +export interface PatchRun { + run_id: string; + policy_id?: string; + policy_name?: string; + triggered_by: string; + source: "schedule" | "run_now" | "server" | "vulnerabilities" | "mcp"; + scope: PatchScope; + reboot: PatchReboot; + max_concurrent: number; + window_end?: string; + status: PatchRunStatus; + cancelled_at?: string; + started_at: string; + finished_at?: string; + servers: PatchServerRun[]; +} + export type Role = "owner" | "admin" | "member"; /** The session as returned by GET /auth/me mirrors auth.Session on the server. */ @@ -842,12 +935,70 @@ export const api = { }); }, - applyUpdates(serverId: string): Promise<{ message: string }> { - return request<{ message: string }>(`/servers/${serverId}/apply-updates`, { + applyUpdates(serverId: string, source?: "vulnerabilities"): Promise<{ message: string; run_id?: string }> { + const qs = source ? `?source=${source}` : ""; + return request<{ message: string; run_id?: string }>(`/servers/${serverId}/apply-updates${qs}`, { method: "POST", }); }, + listMaintenanceWindows(): Promise { + return request("/maintenance-windows"); + }, + + createMaintenanceWindow(input: MaintenanceWindowInput): Promise { + return request("/maintenance-windows", { method: "POST", body: JSON.stringify(input) }); + }, + + updateMaintenanceWindow(windowId: string, input: MaintenanceWindowInput): Promise { + return request(`/maintenance-windows/${windowId}`, { method: "PUT", body: JSON.stringify(input) }); + }, + + deleteMaintenanceWindow(windowId: string): Promise { + return request(`/maintenance-windows/${windowId}`, { method: "DELETE" }); + }, + + previewMaintenanceWindow(input: Omit): Promise { + return request("/maintenance-windows/preview", { method: "POST", body: JSON.stringify(input) }); + }, + + listPatchPolicies(): Promise { + return request("/patch-policies"); + }, + + createPatchPolicy(input: PatchPolicyInput): Promise { + return request("/patch-policies", { method: "POST", body: JSON.stringify(input) }); + }, + + updatePatchPolicy(policyId: string, input: PatchPolicyInput): Promise { + return request(`/patch-policies/${policyId}`, { method: "PUT", body: JSON.stringify(input) }); + }, + + deletePatchPolicy(policyId: string): Promise { + return request(`/patch-policies/${policyId}`, { method: "DELETE" }); + }, + + runPatchPolicyNow(policyId: string): Promise { + return request(`/patch-policies/${policyId}/run-now`, { method: "POST" }); + }, + + listPatchRuns(params: { policy_id?: string; server_id?: string; limit?: number } = {}): Promise { + const qs = new URLSearchParams(); + if (params.policy_id) qs.set("policy_id", params.policy_id); + if (params.server_id) qs.set("server_id", params.server_id); + if (params.limit) qs.set("limit", String(params.limit)); + const suffix = qs.toString(); + return request(`/patch-runs${suffix ? `?${suffix}` : ""}`); + }, + + getPatchRun(runId: string): Promise { + return request(`/patch-runs/${runId}`); + }, + + cancelPatchRun(runId: string): Promise { + return request(`/patch-runs/${runId}/cancel`, { method: "POST" }); + }, + listAuditEvents(params: AuditQuery = {}): Promise { const qs = new URLSearchParams(); if (params.q) qs.set("q", params.q); diff --git a/web/lib/auditEvents.ts b/web/lib/auditEvents.ts index 9897159..1dfd5c9 100644 --- a/web/lib/auditEvents.ts +++ b/web/lib/auditEvents.ts @@ -36,6 +36,7 @@ export const AUDIT_CATEGORIES: { value: string; label: string }[] = [ { value: "console", label: "Console" }, { value: "agent", label: "Agents" }, { value: "updates", label: "OS updates" }, + { value: "patch", label: "Patching" }, { value: "auth_provider", label: "Single sign-on" }, { value: "settings", label: "Settings" }, { value: "token", label: "API tokens" },