feat(web): patching API client, status vocabulary and navigation

This commit is contained in:
2026-09-15 09:41:07 +00:00
parent b5bbf28c63
commit 59e7ef63fe
4 changed files with 231 additions and 2 deletions
+9
View File
@@ -176,6 +176,14 @@ function TokenIcon() {
);
}
function PatchIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6l-1.5 3h3l-1.5 3" />
</svg>
);
}
const navGroups: NavGroup[] = [
{
label: "Fleet",
@@ -184,6 +192,7 @@ const navGroups: NavGroup[] = [
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/patching", label: "Patching", icon: <PatchIcon /> },
],
},
{
+68
View File
@@ -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<PatchRunStatus, { label: string; variant: Variant }> = {
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<PatchServerStatus, { label: string; variant: Variant }> = {
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`;
}
+153 -2
View File
@@ -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<string, string>;
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<MaintenanceWindow[]> {
return request<MaintenanceWindow[]>("/maintenance-windows");
},
createMaintenanceWindow(input: MaintenanceWindowInput): Promise<MaintenanceWindow> {
return request<MaintenanceWindow>("/maintenance-windows", { method: "POST", body: JSON.stringify(input) });
},
updateMaintenanceWindow(windowId: string, input: MaintenanceWindowInput): Promise<MaintenanceWindow> {
return request<MaintenanceWindow>(`/maintenance-windows/${windowId}`, { method: "PUT", body: JSON.stringify(input) });
},
deleteMaintenanceWindow(windowId: string): Promise<void> {
return request<void>(`/maintenance-windows/${windowId}`, { method: "DELETE" });
},
previewMaintenanceWindow(input: Omit<MaintenanceWindowInput, "name">): Promise<WindowSpan[]> {
return request<WindowSpan[]>("/maintenance-windows/preview", { method: "POST", body: JSON.stringify(input) });
},
listPatchPolicies(): Promise<PatchPolicy[]> {
return request<PatchPolicy[]>("/patch-policies");
},
createPatchPolicy(input: PatchPolicyInput): Promise<PatchPolicy> {
return request<PatchPolicy>("/patch-policies", { method: "POST", body: JSON.stringify(input) });
},
updatePatchPolicy(policyId: string, input: PatchPolicyInput): Promise<PatchPolicy> {
return request<PatchPolicy>(`/patch-policies/${policyId}`, { method: "PUT", body: JSON.stringify(input) });
},
deletePatchPolicy(policyId: string): Promise<void> {
return request<void>(`/patch-policies/${policyId}`, { method: "DELETE" });
},
runPatchPolicyNow(policyId: string): Promise<PatchRun> {
return request<PatchRun>(`/patch-policies/${policyId}/run-now`, { method: "POST" });
},
listPatchRuns(params: { policy_id?: string; server_id?: string; limit?: number } = {}): Promise<PatchRun[]> {
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<PatchRun[]>(`/patch-runs${suffix ? `?${suffix}` : ""}`);
},
getPatchRun(runId: string): Promise<PatchRun> {
return request<PatchRun>(`/patch-runs/${runId}`);
},
cancelPatchRun(runId: string): Promise<void> {
return request<void>(`/patch-runs/${runId}/cancel`, { method: "POST" });
},
listAuditEvents(params: AuditQuery = {}): Promise<AuditPage> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
+1
View File
@@ -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" },