- {p &&
}
- {status.helmStatus &&
helm {status.helmStatus}}
+ {isUninstalling(status) ? (
+
uninstalling
+ ) : (
+ p &&
+ )}
+ {status.helmStatus && status.helmStatus !== "uninstalling" &&
helm {status.helmStatus}}
{status.backend && (
{METHODS[status.backend].label}
@@ -248,6 +259,9 @@ function InstallStatus({ status, objects }: { status: Status; objects?: ObjectRe
{p?.error && {p.error}}
+ {isUninstalling(status) && (
+ Uninstall has started. This release stays listed until its pods are gone.
+ )}
{p?.phase === "applying" && (
An apply was started and never recorded a result. It may still be running, or
@@ -298,6 +312,7 @@ interface Step {
function appliedStep(s: Status, p?: PlanStatus): Step {
const label = "Applied";
+ if (isUninstalling(s)) return { label, state: "wait", detail: "uninstalling" };
if (p?.phase === "failed" || s.helmStatus === "failed") return { label, state: "bad", detail: "the last apply failed" };
if (p?.phase === "applying" || s.helmStatus?.startsWith("pending"))
return { label, state: "wait", detail: s.helmStatus ?? "applying" };
@@ -401,10 +416,12 @@ function Uninstall({
namespace,
release,
exists,
+ uninstalling,
}: {
namespace: string;
release: string;
exists: boolean;
+ uninstalling: boolean;
}) {
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
@@ -432,15 +449,19 @@ function Uninstall({
Uninstall
- {exists
- ? "Removes this release from the cluster. Reloading the weights afterwards takes 20–40 minutes."
- : "There is no live release to remove."}
+ {uninstalling
+ ? "This release is uninstalling; wait until its pods are gone."
+ : exists
+ ? "Removes this release from the cluster. Reloading the weights afterwards takes 20–40 minutes."
+ : "There is no live release to remove."}
-
+
+
+
;
if (phase === "applied") return
applied;
if (phase === "failed") return
failed;
if (phase === "applying") return
applying;
diff --git a/web/src/modules/swiss/routes/Deployments.tsx b/web/src/modules/swiss/routes/Deployments.tsx
index 3cdfe0e..dd36a35 100644
--- a/web/src/modules/swiss/routes/Deployments.tsx
+++ b/web/src/modules/swiss/routes/Deployments.tsx
@@ -3,6 +3,7 @@ import { Link } from "@swiss/lib/host";
import { ChevronLeft, ChevronRight, Eye, Plus, Settings2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api, type Deployment } from "@swiss/lib/api";
+import { isUninstalling } from "@swiss/lib/phase";
import { Badge } from "@swiss/components/ui/badge";
import { Button } from "@swiss/components/ui/button";
import { Card, CardContent } from "@swiss/components/ui/card";
@@ -141,15 +142,15 @@ function Row({ d, onConfigureSLO }: { d: Deployment; onConfigureSLO: () => void
{d.route || "—"}
{d.chart}
-
{d.revision}
+
{d.revision ?? "—"}
-
+
{/* Applied is when the chart has created the requirement; whether
this release has one at all, the popup asks slo-api. */}
- {d.phase === "applied" && (
+ {d.phase === "applied" && !isUninstalling(d) && (
@@ -167,7 +168,9 @@ function Row({ d, onConfigureSLO }: { d: Deployment; onConfigureSLO: () => void
// "pending-upgrade" on this workload usually means a 20-40 minute model load in
// progress, not a failure -- so it reads as in-flight rather than as an error.
-function StatusBadge({ status }: { status?: string }) {
+function StatusBadge({ d }: { d: Pick }) {
+ if (isUninstalling(d)) return uninstalling;
+ const status = d.status;
if (!status) return null;
if (status === "deployed") return deployed;
if (status.startsWith("pending")) return {status};
diff --git a/web/src/modules/swiss/routes/Runs.tsx b/web/src/modules/swiss/routes/Runs.tsx
index 5996e82..71305f9 100644
--- a/web/src/modules/swiss/routes/Runs.tsx
+++ b/web/src/modules/swiss/routes/Runs.tsx
@@ -3,6 +3,7 @@ import { Link, useSearchParams } from "@swiss/lib/host";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, Undo2 } from "lucide-react";
import { api, type Revision, type Run } from "@swiss/lib/api";
+import { isUninstalling } from "@swiss/lib/phase";
import { Badge } from "@swiss/components/ui/badge";
import { Button } from "@swiss/components/ui/button";
import { Card } from "@swiss/components/ui/card";
@@ -47,6 +48,12 @@ export function Runs() {
queryFn: () => api.revisions(namespace, release),
enabled: oneRelease,
});
+ const status = useQuery({
+ queryKey: ["status", namespace, release],
+ queryFn: () => api.status(namespace, release),
+ enabled: oneRelease,
+ });
+ const removing = !!status.data && isUninstalling(status.data);
const setFilter = (k: string, v: string) => {
const next = new URLSearchParams(params);
@@ -166,6 +173,7 @@ export function Runs() {
revisions={revisions}
pending={revs.isPending}
error={revs.error}
+ uninstalling={removing}
/>
)}
@@ -181,12 +189,14 @@ function RevisionsTab({
revisions,
pending,
error,
+ uninstalling,
}: {
namespace: string;
release: string;
revisions: Revision[];
pending: boolean;
error: unknown;
+ uninstalling: boolean;
}) {
if (error) return ;
if (pending) return ;
@@ -222,6 +232,7 @@ function RevisionsTab({
revision={r}
namespace={namespace}
release={release}
+ uninstalling={uninstalling}
/>
))}
@@ -328,10 +339,12 @@ function RevisionRow({
revision,
namespace,
release,
+ uninstalling,
}: {
revision: Revision;
namespace: string;
release: string;
+ uninstalling: boolean;
}) {
const [open, setOpen] = useState(false);
@@ -359,7 +372,7 @@ function RevisionRow({
{revision.current ? (
live
) : (
-
+
)}
@@ -387,19 +400,31 @@ function RollBack({
namespace,
release,
to,
+ disabled,
}: {
namespace: string;
release: string;
to: number;
+ disabled?: boolean;
}) {
+ const button = (
+
+ );
+ if (disabled) {
+ return (
+
e.stopPropagation()}>
+ {button}
+
+ );
+ }
return (
e.stopPropagation()}
>
-
+ {button}
);
}
diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx
index 299e535..b7ddb5b 100644
--- a/web/src/modules/swiss/routes/Upgrade.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.tsx
@@ -35,6 +35,7 @@ import { Dialog } from "@swiss/components/ui/dialog";
import { gpuCount, unlistedProducts, vendorProducts } from "@swiss/lib/gpu";
import { Button } from "@swiss/components/ui/button";
import { comparison, formatUplift, reportLink, sharedVersion, variantKind, workloadSummary } from "@swiss/lib/catalog";
+import { isUninstalling } from "@swiss/lib/phase";
export function Upgrade() {
const { namespace = "", release = "" } = useParams();
@@ -67,6 +68,11 @@ export function Upgrade() {
staleTime: Infinity,
});
const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster });
+ const live = useQuery({
+ queryKey: ["status", namespace, release],
+ queryFn: () => api.status(namespace, release),
+ refetchInterval: 15_000,
+ });
const nodes = useQuery({ queryKey: ["nodes"], queryFn: api.nodes });
const catalogs = cluster.data?.catalogs ?? [];
const recorded = current.data?.source;
@@ -177,6 +183,19 @@ export function Upgrade() {
return
;
}
}
+ if (live.data && isUninstalling(live.data)) {
+ return (
+
+
+
+
+
+ This release is uninstalling; wait until its pods are gone.
+
+
+
+ );
+ }
const cur = current.data;
const targetVersion = version || keptPin?.version;
diff --git a/web/src/modules/swiss/routes/uninstalling.test.tsx b/web/src/modules/swiss/routes/uninstalling.test.tsx
new file mode 100644
index 0000000..a31d476
--- /dev/null
+++ b/web/src/modules/swiss/routes/uninstalling.test.tsx
@@ -0,0 +1,140 @@
+import { type ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { Deployment, Plan, ReleaseStatus } from "@swiss/lib/api";
+import { ModuleProvider } from "@/shell/module";
+import { DeploymentDetail } from "@swiss/routes/DeploymentDetail";
+import { Deployments } from "@swiss/routes/Deployments";
+import { Upgrade } from "@swiss/routes/Upgrade";
+
+const module = { id: "swiss", title: "s", basePath: "/swiss", pages: [] };
+const plan = {
+ apiVersion: "v1",
+ release: { name: "qwen", namespace: "models" },
+ source: { catalogName: "public", model: "qwen", version: "1", variant: "a" },
+ chart: { name: "vllm", version: "1" },
+ engine: "vllm",
+ profile: "p",
+ layers: { form: { sloRequirement: { enabled: true } } },
+ hash: "h",
+} as Plan;
+
+const deployment = (over: Partial
): Deployment => ({ release: "qwen", namespace: "models", revision: 3, model: "qwen", ...over });
+const release = (over: Partial = {}): ReleaseStatus => ({
+ release: "qwen",
+ namespace: "models",
+ exists: true,
+ revision: 3,
+ helmStatus: "deployed",
+ pods: [],
+ ready: 1,
+ total: 1,
+ planStatus: { phase: "applied" },
+ ...over,
+});
+
+function client() {
+ return new QueryClient({ defaultOptions: { queries: { retry: false, retryOnMount: false } } });
+}
+
+function buttons(html: string) {
+ return [...html.matchAll(/