diff --git a/web/app/(app)/patching/page.tsx b/web/app/(app)/patching/page.tsx
new file mode 100644
index 0000000..60581ca
--- /dev/null
+++ b/web/app/(app)/patching/page.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { Suspense } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useAuth } from "@/components/AuthProvider";
+import { Card, CenteredSpinner } from "@/components/ui";
+import { PolicyList } from "@/components/patching/PolicyList";
+import { WindowList } from "@/components/patching/WindowList";
+import { RunList } from "@/components/patching/RunList";
+
+const TABS = [
+ { id: "policies", label: "Policies" },
+ { id: "windows", label: "Windows" },
+ { id: "runs", label: "Runs" },
+] as const;
+
+type TabId = (typeof TABS)[number]["id"];
+
+// useSearchParams is only allowed inside a Suspense boundary, as on the
+// servers page.
+export default function PatchingPage() {
+ return (
+
+ Patch servers inside maintenance windows. Every patch Vantage performs, scheduled or clicked, is recorded as a run with a result for each server. +
+| Policy | +Next window | +Targets | +Installs | +Last run | + {canEdit &&} + |
|---|---|---|---|---|---|
|
+
+ {p.name}
+ {!p.enabled && (
+
+ Disabled{p.disabled_reason ? `: ${p.disabled_reason}` : ""}
+
+ )}
+ {p.last_skipped && (
+
+ Skipped {new Date(p.last_skipped.due).toLocaleString()}: {SKIP_REASON[p.last_skipped.reason] ?? p.last_skipped.reason}
+
+ )}
+
+ |
+ + {p.enabled && p.next_run_at ? ( + + {new Date(p.next_run_at).toLocaleString(undefined, { timeZone: w?.tz, dateStyle: "medium", timeStyle: "short" })} + {w?.name} + + ) : ( + none + )} + | ++ {count} + | +
+
+
+ |
+
+ {last ? (
+
+ |
+ {canEdit && (
+
+
+
+
+
+
+ |
+ )}
+
| Started | +Policy | +Status | +Servers | +Started by | +
|---|---|---|---|---|
| + + {new Date(r.started_at).toLocaleString()} + + | +{r.policy_name ?? manual} | +
+ |
+ + + {ok}/{r.servers.length} + + | ++ + {r.triggered_by} via {SOURCE_LABEL[r.source] ?? r.source} + + | +
| Name | +When | +Length | +Used by | + {canEdit &&} + |
|---|---|---|---|---|
| {w.name} | ++ {describeCron(w.cron)} ({w.tz}) + | +{formatDuration(w.duration_minutes)} | ++ {usedBy(w.window_id)} {usedBy(w.window_id) === 1 ? "policy" : "policies"} + | + {canEdit && ( +
+
+
+
+
+ |
+ )}
+