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" },