Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/src/modules/docs/content/en-US/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ From a row's actions in the list, or the top of the detail page:
- **Roll back**: pick a revision under Versions to restore its plan as it was. To change settings, upgrade instead.
- **Edit SLO**: the latency and throughput targets and the replica bounds the scaler works with. Services deployed without SLO need it switched on in an upgrade first.
- **Uninstall**: stops the service and deletes its pods; the activity log is kept. Reinstalling loads the weights again.
- While pods are still terminating the service stays listed as **Uninstalling**; upgrade, roll back, SLO edits and uninstall stay disabled until it disappears.

"Behind catalog" in the list means the catalog has a newer version to upgrade to; "Untracked" means the release was not deployed by Swiss and cannot be upgraded here.

Expand Down
1 change: 1 addition & 0 deletions web/src/modules/docs/content/zh-CN/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
- **回滚**:在「版本管理」里选一个历史版本,原样恢复当时的计划。要改设置请用升级。
- **编辑 SLO**:调整扩缩容依据的延迟、吞吐目标和副本数上下限。部署时没开 SLO 的服务需要先在升级时打开。
- **卸载**:停止服务并删除 Pod,操作记录保留。重新安装需要重新加载权重。
- Pod 还在退出时,服务会以 **卸载中** 留在列表里;在它消失之前,升级、回滚、编辑 SLO 和卸载都不可用。

列表上的「落后目录」表示目录里有更新的版本,可以升级;「未纳管」表示这个 release 不是通过 swiss 部署的,不能在这里升级。

Expand Down
15 changes: 9 additions & 6 deletions web/src/modules/inferences/InferenceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Alert, AlertDescription, DetailHeader, PanelTabs, SectionCard, Skeleton
import { ArrowLeft, TriangleAlert } from "lucide-react";
import { useModulePath } from "@/shell";
import { api } from "@swiss/lib/api";
import { isUninstalling } from "@swiss/lib/phase";
import { useReleaseObjects } from "@swiss/components/ReleaseObjects";
import { ReleaseObjects } from "@swiss/components/ReleaseObjects";
import { Endpoint } from "@swiss/components/Endpoint";
Expand Down Expand Up @@ -100,11 +101,13 @@ export function InferenceDetail() {
}

const readOnly = cluster.data ? !cluster.data.allowDeploy : false;
const removing = isUninstalling(s);
const blockedWrite = readOnly ? t("disabled.readOnly") : removing ? t("disabled.uninstalling") : false;
const source = plan.data?.source;
const model = modelLine(source ?? {}) || s.model;
const pods = s.pods.map((p) => p.name);
const blocked = sloBlocked(s, plan.data);
const sloOpen = editingSLO || (sloSetup && blocked !== "off" && s.planStatus?.phase !== "failed");
const sloOpen = !removing && (editingSLO || (sloSetup && blocked !== "off" && s.planStatus?.phase !== "failed"));
const closeSLO = () => {
setEditingSLO(false);
if (!sloSetup) return;
Expand All @@ -127,20 +130,20 @@ export function InferenceDetail() {
{
key: "slo",
label: t("actions.slo"),
disabled: blocked === "off" ? t("disabled.sloOff") : blocked === "pending" ? t("disabled.sloPending") : false,
disabled: blockedWrite || (blocked === "off" ? t("disabled.sloOff") : blocked === "pending" ? t("disabled.sloPending") : false),
onClick: () => setEditingSLO(true),
},
{
key: "upgrade",
label: t("actions.upgrade"),
disabled: readOnly ? t("disabled.readOnly") : !plan.data ? t("disabled.untracked") : false,
disabled: blockedWrite || (!plan.data ? t("disabled.untracked") : false),
onClick: () => setUpgrading(true),
},
{
key: "uninstall",
label: t("actions.uninstall"),
danger: true,
disabled: readOnly ? t("disabled.readOnly") : !s.exists,
disabled: blockedWrite || !s.exists,
onClick: () => setUninstalling(true),
},
]}
Expand Down Expand Up @@ -173,7 +176,7 @@ export function InferenceDetail() {
<Endpoint namespace={namespace} release={release} status={s} access={false} />
</TabsContent>
<TabsContent value="versions">
<Versions namespace={namespace} release={release} canRollBack={!readOnly && !!plan.data} />
<Versions namespace={namespace} release={release} rollbackBlocked={removing ? t("disabled.uninstalling") : readOnly || !plan.data ? t("disabled.readOnly") : false} />
</TabsContent>
<TabsContent value="runs">
<Runs namespace={namespace} release={release} />
Expand All @@ -187,7 +190,7 @@ export function InferenceDetail() {
)}
</Tabs>

<SLOSheet target={sloOpen ? { namespace, release } : null} canEdit={!readOnly} setup={sloSetup} waiting={blocked === "pending"} onClose={closeSLO} />
<SLOSheet target={sloOpen ? { namespace, release } : null} canEdit={!readOnly && !removing} setup={sloSetup} waiting={blocked === "pending"} onClose={closeSLO} />
<DeploySheet target={upgrading ? { kind: "upgrade", namespace, release } : null} onClose={() => setUpgrading(false)} />
<UninstallDialog
target={uninstalling ? { namespace, release } : null}
Expand Down
11 changes: 6 additions & 5 deletions web/src/modules/inferences/InferenceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { Bot, Plus } from "lucide-react";
import { formatDateTime, useModulePath } from "@/shell";
import { api, type Deployment } from "@swiss/lib/api";
import { isUninstalling } from "@swiss/lib/phase";
import { useT } from "@/modules/inferences/i18n";
import { detailPath, modelLine, rowState } from "@/modules/inferences/lib";
import { StatusDot } from "@/modules/inferences/components/StatusDot";
Expand Down Expand Up @@ -72,7 +73,7 @@ export function InferenceList() {
render: (d) => (d.route ? <span className="font-mono text-xs">/{d.route}/</span> : <span className="text-muted-foreground">-</span>),
},
{ key: "chart", title: t("list.columns.chart"), width: 180, render: (d) => <span className="text-sm">{d.chart || "-"}</span> },
{ key: "revision", title: t("list.columns.revision"), width: 70, align: "right", render: (d) => <span className="tabular-nums">{d.revision}</span> },
{ key: "revision", title: t("list.columns.revision"), width: 70, align: "right", render: (d) => <span className="tabular-nums">{d.revision ?? "-"}</span> },
{
key: "updated",
title: t("list.columns.updated"),
Expand All @@ -97,19 +98,19 @@ export function InferenceList() {
},
];

const writeBlocked = (d: Deployment): boolean | string =>
readOnly ? t("disabled.readOnly") : !d.model ? t("disabled.untracked") : false;
const writeBlocked = (d: Deployment, untracked = false): boolean | string =>
readOnly ? t("disabled.readOnly") : isUninstalling(d) ? t("disabled.uninstalling") : untracked && !d.model ? t("disabled.untracked") : false;

const rowActions: ResourceRowAction<Row>[] = [
{ key: "detail", label: t("actions.detail"), onClick: open },
{ key: "upgrade", label: t("actions.upgrade"), onClick: (d) => setUpgrading({ kind: "upgrade", namespace: d.namespace, release: d.release }), disabled: writeBlocked },
{ key: "upgrade", label: t("actions.upgrade"), onClick: (d) => setUpgrading({ kind: "upgrade", namespace: d.namespace, release: d.release }), disabled: (d) => writeBlocked(d, true) },
{ key: "versions", label: t("actions.versions"), onClick: (d) => navigate(p(detailPath(d.release, d.namespace, "versions"))) },
{
key: "uninstall",
label: t("actions.uninstall"),
danger: true,
onClick: (d) => setUninstalling({ namespace: d.namespace, release: d.release }),
disabled: () => (readOnly ? t("disabled.readOnly") : false),
disabled: (d) => writeBlocked(d),
},
];

Expand Down
3 changes: 2 additions & 1 deletion web/src/modules/inferences/components/LoadHint.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Button } from "@modelsphere/ui";
import { TriangleAlert } from "lucide-react";
import type { ReleaseStatus } from "@swiss/lib/api";
import { isUninstalling } from "@swiss/lib/phase";
import { useT } from "@/modules/inferences/i18n";
import { stalled } from "@/modules/inferences/lib";

export function LoadHint({ status: s, onViewInstances }: { status: ReleaseStatus; onViewInstances?: () => void }) {
const t = useT();
if (s.total === 0 || s.ready >= s.total) return null;
if (isUninstalling(s) || s.total === 0 || s.ready >= s.total) return null;
if (!stalled(s)) return <p className="text-sm text-muted-foreground">{t("overview.coldLoad")}</p>;
return (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
Expand Down
6 changes: 4 additions & 2 deletions web/src/modules/inferences/components/StatusDot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ const VARIANT: Record<Tone, StatusVariant> = {
const label = (state: State, t: TFn, suffix?: string) =>
[t(`state.${state.key}`, { raw: state.raw ?? "" }), suffix].filter(Boolean).join(" ");

const inFlight = (key: string) => key === "applying" || key === "uninstalling";

export function statusOf(state: State, t: TFn, suffix?: string) {
return { tone: VARIANT[state.tone], label: label(state, t, suffix), animated: state.key === "applying" };
return { tone: VARIANT[state.tone], label: label(state, t, suffix), animated: inFlight(state.key) };
}

export function StatusDot({ state, suffix }: { state: State; suffix?: string }) {
const t = useT();
return <StatusIndicator variant={VARIANT[state.tone]} animated={state.key === "applying"} label={label(state, t, suffix)} />;
return <StatusIndicator variant={VARIANT[state.tone]} animated={inFlight(state.key)} label={label(state, t, suffix)} />;
}
24 changes: 22 additions & 2 deletions web/src/modules/inferences/dashboard-lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CatalogResponse, Run } from "@swiss/lib/api";
import { HISTORY_LIMIT, recordedTimeline, supportedTotals, timelineTotals } from "./dashboard-lib";
import type { CatalogResponse, Deployment, ReleaseStatus, Run } from "@swiss/lib/api";
import { currentState, healthBucket, HISTORY_LIMIT, recordedTimeline, supportedTotals, timelineTotals } from "./dashboard-lib";

const from = "2026-10-10T12:15:00.000Z";
const to = "2026-10-11T12:15:00.000Z";
Expand Down Expand Up @@ -53,6 +53,26 @@ describe("supported catalog totals", () => {
});
});

describe("service health while uninstalling", () => {
const row = (over: Partial<Deployment> = {}): Deployment => ({ release: "q", namespace: "models", revision: 1, ...over });
const live = (over: Partial<ReleaseStatus> = {}): ReleaseStatus => ({
release: "q",
namespace: "models",
exists: true,
revision: 1,
pods: [],
ready: 0,
total: 0,
...over,
});

it("counts an uninstalling release as degraded, from the list row or the live status", () => {
expect(healthBucket(currentState(row({ phase: "uninstalling", status: "failed" })))).toBe("degraded");
expect(healthBucket(currentState(row({ status: "deployed", phase: "applied" }), { data: live({ exists: false, helmStatus: "failed", planStatus: { phase: "uninstalling" } }) }))).toBe("degraded");
expect(healthBucket(currentState(row({ status: "deployed" }), { data: live({ ready: 1, total: 1, llmservice: { phase: "uninstalling" } }) }))).toBe("degraded");
});
});

describe("recorded deployment timeline", () => {
it("zero-fills a complete 24-hour window including partial edge hours", () => {
const data = recordedTimeline([], from, to, true);
Expand Down
34 changes: 34 additions & 0 deletions web/src/modules/inferences/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,36 @@ describe("releaseState", () => {
});
});

describe("uninstalling wins over the states a disappearing release would otherwise show", () => {
const rows: { name: string; d: Parameters<typeof rowState>[0]; key: string }[] = [
{ name: "a row phase of uninstalling", d: { phase: "uninstalling" }, key: "uninstalling" },
{ name: "a row phase of uninstalling ahead of helm failed", d: { phase: "uninstalling", status: "failed" }, key: "uninstalling" },
{ name: "a row phase of uninstalling ahead of helm pending", d: { phase: "uninstalling", status: "pending-upgrade" }, key: "uninstalling" },
{ name: "helm's own uninstalling status", d: { status: "uninstalling" }, key: "uninstalling" },
{ name: "helm's uninstalling status ahead of a failed plan", d: { status: "uninstalling", phase: "failed" }, key: "uninstalling" },
{ name: "helm's uninstalling status ahead of an applying plan", d: { status: "uninstalling", phase: "applying" }, key: "uninstalling" },
];
for (const tc of rows) {
it(`reads ${tc.name} as uninstalling`, () => {
expect(rowState(tc.d)).toEqual({ key: tc.key, tone: "warning" });
});
}

const releases: { name: string; s: Partial<ReleaseStatus>; key: string }[] = [
{ name: "planStatus.phase uninstalling with the helm release already gone", s: { exists: false, ready: 0, total: 0, planStatus: { phase: "uninstalling" } }, key: "uninstalling" },
{ name: "planStatus.phase uninstalling ahead of a failed helm status", s: { exists: false, ready: 0, total: 0, helmStatus: "failed", planStatus: { phase: "uninstalling" } }, key: "uninstalling" },
{ name: "planStatus.phase uninstalling ahead of an applying helm status", s: { helmStatus: "pending-upgrade", planStatus: { phase: "uninstalling" } }, key: "uninstalling" },
{ name: "llmservice.phase uninstalling while pods are still ready", s: { ready: 1, total: 1, llmservice: { phase: "uninstalling" } }, key: "uninstalling" },
{ name: "llmservice.phase uninstalling ahead of a failed plan and no pods", s: { exists: false, ready: 0, total: 0, planStatus: { phase: "failed" }, llmservice: { phase: "uninstalling" } }, key: "uninstalling" },
{ name: "helm status uninstalling ahead of no pods", s: { exists: true, ready: 0, total: 0, helmStatus: "uninstalling" }, key: "uninstalling" },
];
for (const tc of releases) {
it(`reads ${tc.name} as uninstalling`, () => {
expect(releaseState(status(tc.s))).toEqual({ key: tc.key, tone: "warning" });
});
}
});

describe("tabs", () => {
it("drops the plan when the release has none", () => {
expect(visibleTabs({ plan: false })).toEqual(["overview", "instances", "logs", "events", "resources", "check", "versions", "runs"]);
Expand Down Expand Up @@ -143,6 +173,10 @@ describe("installSteps", () => {
const steps = installSteps(status({ exists: false, total: 0, ready: 0 }), {}, {});
expect(steps.map((s) => s.state)).toEqual(["off", "off", "off", "off"]);
});
it("reports uninstalling on the applied step while pods are still leaving", () => {
const steps = installSteps(status({ exists: false, total: 0, ready: 0, helmStatus: "failed", planStatus: { phase: "uninstalling" } }), {}, {});
expect(steps[0]).toEqual({ key: "applied", state: "wait", detail: "uninstalling" });
});
it("reports a routed release with its backends and route", () => {
const steps = installSteps(status(), route({ ready: true }) as never, {});
expect(steps[2]).toEqual({ key: "route", state: "ok", detail: "ready", params: { n: 2, route: "/qwen/" } });
Expand Down
7 changes: 6 additions & 1 deletion web/src/modules/inferences/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
Revision,
Run,
} from "@swiss/lib/api";
import { isUninstalling } from "@swiss/lib/phase";

export type Tone = "success" | "info" | "warning" | "error" | "muted";

Expand All @@ -22,15 +23,18 @@ export interface State {

// A list row has no pod counts: those cost a status read per row.
export function rowState(d: Pick<Deployment, "status" | "phase">): State {
// Ahead of failed and applying: a helm row can already have no release status
// while its pods are still terminating.
if (isUninstalling(d)) return { key: "uninstalling", tone: "warning" };
if (d.phase === "failed" || d.status === "failed") return { key: "failed", tone: "error" };
if (d.phase === "applying" || d.status?.startsWith("pending")) return { key: "applying", tone: "info" };
if (d.status === "uninstalling") return { key: "uninstalling", tone: "warning" };
if (d.status === "deployed") return { key: "applied", tone: "success" };
if (!d.status) return { key: "unknown", tone: "muted" };
return { key: "other", tone: "muted", raw: d.status };
}

export function releaseState(s: ReleaseStatus): State {
if (isUninstalling(s)) return { key: "uninstalling", tone: "warning" };
const p = s.planStatus;
if (!s.exists && !p) return { key: "notInstalled", tone: "muted" };
if (p?.phase === "failed" || s.helmStatus === "failed") return { key: "failed", tone: "error" };
Expand Down Expand Up @@ -133,6 +137,7 @@ export function installSteps(

function appliedStep(s: ReleaseStatus, p?: PlanStatus): Step {
const key = "applied";
if (isUninstalling(s)) return { key, state: "wait", detail: "uninstalling" };
if (p?.phase === "failed" || s.helmStatus === "failed") return { key, state: "bad", detail: "failed" };
if (p?.phase === "applying" || s.helmStatus?.startsWith("pending")) return { key, state: "wait", detail: "applying" };
if (s.exists) return { key, state: "ok", detail: "revision", params: { n: s.revision } };
Expand Down
5 changes: 4 additions & 1 deletion web/src/modules/inferences/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@
"untracked": "A release with no plan beside it cannot be upgraded here",
"current": "This is already the current revision",
"sloOff": "SLO was not turned on at deploy; turn it on in an upgrade",
"sloPending": "SLO shows once the deploy has applied"
"sloPending": "SLO shows once the deploy has applied",
"uninstalling": "This release is uninstalling; wait until its pods are gone"
},
"state": {
"applied": "Applied",
Expand Down Expand Up @@ -163,6 +164,7 @@
"noRuns": "No activity yet",
"noStore": "This swissd has no database, so the activity log is empty",
"applyingHint": "An apply was started and never recorded a result. It may still be running, or swissd may have been restarted mid-apply.",
"uninstallingHint": "Uninstall has started. This service stays listed until its pods are gone.",
"coldLoad": "A cold load takes 20–40 minutes. A pod that is scheduled and not ready has most likely been reading weights, not failing.",
"stalled": "A pod has not been ready for over an hour, well past a normal load. Check the instances.",
"viewInstances": "View instances"
Expand All @@ -172,6 +174,7 @@
"label": "Applied",
"failed": "The last apply failed",
"applying": "Applying",
"uninstalling": "Uninstalling",
"revision": "Revision {n}",
"notInstalled": "Not installed"
},
Expand Down
5 changes: 4 additions & 1 deletion web/src/modules/inferences/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@
"untracked": "没有纳管计划的 release 不能在这里升级",
"current": "这已经是当前版本",
"sloOff": "部署时没有开启 SLO,可在升级时打开",
"sloPending": "部署完成后才能查看 SLO"
"sloPending": "部署完成后才能查看 SLO",
"uninstalling": "这个服务正在卸载,等 Pod 消失后再操作"
},
"state": {
"applied": "已应用",
Expand Down Expand Up @@ -152,6 +153,7 @@
"noRuns": "还没有操作记录",
"noStore": "这个 swissd 没有数据库,操作记录为空",
"applyingHint": "部署已开始但还没有记录结果:可能仍在执行,也可能 swissd 在部署中途重启过。",
"uninstallingHint": "卸载已开始。Pod 消失之前,这个服务仍会留在列表里。",
"coldLoad": "冷启动加载需要 20–40 分钟。已调度但未就绪的 Pod 多半是在读取权重,而不是出错。",
"stalled": "实例超过 1 小时仍未就绪,已超出正常加载时间,请查看实例。",
"viewInstances": "查看实例"
Expand All @@ -161,6 +163,7 @@
"label": "已应用",
"failed": "上次部署失败",
"applying": "部署中",
"uninstalling": "卸载中",
"revision": "版本 {n}",
"notInstalled": "未安装"
},
Expand Down
Loading
Loading