From 6e2c3782448216d8610435537e657330d9592b57 Mon Sep 17 00:00:00 2001
From: aceforeverd
Date: Sat, 3 Oct 2026 01:12:53 +0000
Subject: [PATCH 1/5] deploy relax
---
.../swiss/components/DeploySettings.tsx | 10 +-
web/src/modules/swiss/lib/api.ts | 19 +-
web/src/modules/swiss/lib/upgrade.test.ts | 40 ++++
web/src/modules/swiss/lib/upgrade.ts | 16 ++
web/src/modules/swiss/routes/Upgrade.tsx | 214 +++++++++++++++---
5 files changed, 265 insertions(+), 34 deletions(-)
create mode 100644 web/src/modules/swiss/lib/upgrade.test.ts
create mode 100644 web/src/modules/swiss/lib/upgrade.ts
diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx
index 724d2c8..bcfa933 100644
--- a/web/src/modules/swiss/components/DeploySettings.tsx
+++ b/web/src/modules/swiss/components/DeploySettings.tsx
@@ -1320,7 +1320,14 @@ export function effective(f: Form) {
export function planRequest(
f: Form,
- opts: { model: string; version?: string; variant?: string; fromRelease?: string; catalog?: string },
+ opts: {
+ model: string;
+ version?: string;
+ variant?: string;
+ fromRelease?: string;
+ catalog?: string;
+ chartVersion?: string;
+ },
): PlanRequest {
const overrides: Record = {};
const on = effective(f);
@@ -1417,6 +1424,7 @@ export function planRequest(
catalog: opts.catalog || undefined,
model: opts.model,
fromRelease: opts.fromRelease,
+ chartVersion: opts.chartVersion || undefined,
version: opts.version || undefined,
editsYAML: f.edits.trim() || undefined,
gpuProducts: f.gpuProducts.length ? f.gpuProducts : undefined,
diff --git a/web/src/modules/swiss/lib/api.ts b/web/src/modules/swiss/lib/api.ts
index 9be3847..b963406 100644
--- a/web/src/modules/swiss/lib/api.ts
+++ b/web/src/modules/swiss/lib/api.ts
@@ -393,6 +393,10 @@ export const api = {
pathTemplate?: string;
imageRepository?: Record;
}>(`/api/catalog/${encodeURIComponent(name)}` + query({ version, catalog })),
+ // The chart versions a variant's chart.version allows, newest first. A pinned
+ // variant has its one; a range lists the chart repository, which can fail.
+ chartVersions: (name: string, opts: { catalog?: string; version?: string; variant?: string }) =>
+ get(`/api/catalog/${encodeURIComponent(name)}/chart-versions` + query(opts)),
nodes: () => get("/api/nodes"),
profile: () => get("/api/profile"),
runs: (f: RunFilter = {}) => {
@@ -444,6 +448,13 @@ export const api = {
put("/api/profile", body),
};
+export interface ChartVersions {
+ chart: string;
+ range: string;
+ variant: string;
+ versions: string[];
+}
+
export interface Plan {
apiVersion: string;
release: { name: string; namespace: string };
@@ -454,6 +465,8 @@ export interface Plan {
catalogName?: string;
ref?: string;
model: string;
+ // Model identity across catalogs. Absent from plans written before it was recorded.
+ hf?: string;
version?: string;
digest?: string;
variant: string;
@@ -489,7 +502,8 @@ export interface ApplyResult {
}
export interface PlanRequest {
- // The configured catalog to compose from; an upgrade stays on its own.
+ // The configured catalog to compose from. An upgrade naming none stays on its
+ // own; naming another moves it there, if the model there is the same one.
catalog?: string;
model?: string;
fromRelease?: string;
@@ -507,6 +521,9 @@ export interface PlanRequest {
// profile's own setting. helm creates the namespace; this is the plan saying
// it may.
createNamespace?: boolean;
+ // Must fall in the variant's chart.version. Empty keeps a running release's
+ // chart while in range, else takes the newest.
+ chartVersion?: string;
}
export interface Pod {
diff --git a/web/src/modules/swiss/lib/upgrade.test.ts b/web/src/modules/swiss/lib/upgrade.test.ts
new file mode 100644
index 0000000..36c434e
--- /dev/null
+++ b/web/src/modules/swiss/lib/upgrade.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import type { CatalogInfo } from "@swiss/lib/api";
+import { movableCatalogs } from "./upgrade";
+
+const catalogs: CatalogInfo[] = [
+ { name: "public", url: "https://p", source: "https://p/index.json", default: true },
+ { name: "internal", url: "https://i", source: "https://i/index.json" },
+ { name: "mirror", url: "https://m", source: "https://m/index.json" },
+];
+const hfs: Record = { public: "Qwen/Qwen3", internal: "Qwen/Qwen3", mirror: "acme/qwen3-finetune" };
+const hfIn = (c?: string) => (c ? hfs[c] : undefined);
+
+describe("movableCatalogs", () => {
+ it("offers only catalogs with the plan's HF repo", () => {
+ const got = movableCatalogs(catalogs, { catalogName: "public", hf: "Qwen/Qwen3" }, "public", hfIn);
+ expect(got).toEqual({ hf: "Qwen/Qwen3", movable: ["internal"] });
+ });
+
+ it("takes an old plan's HF repo from its own catalog, by name or location", () => {
+ expect(movableCatalogs(catalogs, { catalogName: "public" }, "public", hfIn).movable).toEqual(["internal"]);
+ expect(movableCatalogs(catalogs, { catalog: "https://p/index.json" }, "public", hfIn).movable).toEqual([
+ "internal",
+ ]);
+ });
+
+ it("never asks the default for an old plan's identity", () => {
+ // Unnamed, at a location the site does not list: the default is public, but
+ // the release may not have come from it.
+ expect(movableCatalogs(catalogs, { catalog: "/laptop/index.json" }, "public", hfIn)).toEqual({ movable: [] });
+ });
+
+ it("offers nothing when the own catalog cannot say", () => {
+ expect(movableCatalogs(catalogs, { catalogName: "gone" }, undefined, hfIn)).toEqual({ movable: [] });
+ });
+
+ it("offers every same-model catalog when the own one is unlisted", () => {
+ const got = movableCatalogs(catalogs, { catalogName: "gone", hf: "Qwen/Qwen3" }, undefined, hfIn);
+ expect(got.movable).toEqual(["public", "internal"]);
+ });
+});
diff --git a/web/src/modules/swiss/lib/upgrade.ts b/web/src/modules/swiss/lib/upgrade.ts
new file mode 100644
index 0000000..f9f6f78
--- /dev/null
+++ b/web/src/modules/swiss/lib/upgrade.ts
@@ -0,0 +1,16 @@
+import type { CatalogInfo } from "@swiss/lib/api";
+
+// The catalogs a release may move to, by swissd's checkMove: the same HF repo.
+// A plan without hf takes it from its own catalog by name or location, never
+// the default -- which may be another catalog with the same model name.
+export function movableCatalogs(
+ catalogs: CatalogInfo[],
+ source: { catalog?: string; catalogName?: string; hf?: string },
+ own: string | undefined,
+ hfIn: (catalog: string | undefined) => string | undefined,
+): { hf?: string; movable: string[] } {
+ const ownByIdentity = source.catalogName ?? catalogs.find((c) => c.source === source.catalog)?.name;
+ const hf = source.hf || hfIn(ownByIdentity);
+ if (!hf) return { movable: [] };
+ return { hf, movable: catalogs.map((c) => c.name).filter((n) => n !== own && hfIn(n) === hf) };
+}
diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx
index 7ae3d5e..6628834 100644
--- a/web/src/modules/swiss/routes/Upgrade.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.tsx
@@ -1,11 +1,18 @@
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "@swiss/lib/host";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useMutation, useQueries, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { ArrowRight, ChevronLeft, TriangleAlert } from "lucide-react";
-import { api, deployApi, type ApplyResult, type DiffResult, type Plan } from "@swiss/lib/api";
+import {
+ api,
+ deployApi,
+ type ApplyResult,
+ type ChartVersions,
+ type DiffResult,
+ type Plan,
+} from "@swiss/lib/api";
import { Badge } from "@swiss/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@swiss/components/ui/card";
-import { Field } from "@swiss/components/ui/input";
+import { Field, Input } from "@swiss/components/ui/input";
import {
DeploySettings,
EMPTY,
@@ -17,6 +24,7 @@ import {
import { Pipeline } from "@swiss/components/Pipeline";
import { ErrorState, Loading } from "@swiss/components/States";
import { CatalogBadge, releaseCatalog as catalogOfRelease } from "@swiss/components/CatalogChoice";
+import { movableCatalogs } from "@swiss/lib/upgrade";
export function Upgrade() {
const { namespace = "", release = "" } = useParams();
@@ -28,6 +36,9 @@ export function Upgrade() {
const rollbackTo = Number(params.get("rollback")) || 0;
const [version, setVersion] = useState("");
const [variant, setVariant] = useState("");
+ // "" stays on the release's own catalog; the server's default for both.
+ const [targetCatalog, setTargetCatalog] = useState("");
+ const [chartVersion, setChartVersion] = useState("");
const current = useQuery({
queryKey: ["release-plan", namespace, release],
@@ -45,23 +56,42 @@ export function Upgrade() {
});
const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster });
const nodes = useQuery({ queryKey: ["nodes"], queryFn: api.nodes });
- // An upgrade stays on the catalog the release belongs to -- swissd refuses
- // any other -- so there is nothing to choose, only a name to find, the way
- // swissd finds it.
const catalogs = cluster.data?.catalogs ?? [];
const recorded = current.data?.source;
const releaseCatalog = recorded ? catalogOfRelease(catalogs, recorded) : undefined;
- const catalog = useQuery({
- queryKey: ["catalog", releaseCatalog],
- queryFn: () => api.catalog(releaseCatalog),
- enabled: !!releaseCatalog,
+ const catalogName = targetCatalog || releaseCatalog;
+ const moving = !!targetCatalog && targetCatalog !== releaseCatalog;
+ const indexes = useQueries({
+ queries: catalogs.map((c) => ({
+ queryKey: ["catalog", c.name],
+ queryFn: () => api.catalog(c.name),
+ enabled: !rollbackTo,
+ })),
});
+ const modelIn = (name?: string) =>
+ indexes[catalogs.findIndex((c) => c.name === name)]?.data?.index.models.find(
+ (m) => m.name === recorded?.model,
+ );
+ const { hf, movable } = movableCatalogs(catalogs, recorded ?? {}, releaseCatalog, (c) => modelIn(c)?.source.hf);
// For the model path default, which is the site's template resolved against
// this model's hf -- the same value the deploy page shows.
const entry = useQuery({
- queryKey: ["model", releaseCatalog, current.data?.source.model ?? "", version],
- queryFn: () => api.model(current.data!.source.model, version || undefined, releaseCatalog),
- enabled: !!current.data && !!releaseCatalog,
+ queryKey: ["model", catalogName, current.data?.source.model ?? "", version],
+ queryFn: () => api.model(current.data!.source.model, version || undefined, catalogName),
+ enabled: !!current.data && !!catalogName,
+ });
+ const model = modelIn(catalogName);
+ const versions = model?.versions.map((v) => v.version) ?? [];
+ const variants = model?.versions.find((v) => v.version === (version || model.latest))?.variants ?? [];
+ const keptVariant = variant || recorded?.variant || "";
+ const variantMissing = !!model && !variants.some((v) => v.id === keptVariant);
+ const chartVersions = useQuery({
+ queryKey: ["chart-versions", catalogName, recorded?.model, version, keptVariant],
+ queryFn: () =>
+ api.chartVersions(recorded!.model, { catalog: catalogName, version: version || undefined, variant: keptVariant }),
+ enabled: !!recorded && !!catalogName && !variantMissing && !rollbackTo,
+ // A registry that refuses is not going to change its mind in a second.
+ retry: false,
});
const [form, setForm] = useState
{planM.error && }
+ {chartRange && (
+
+
+ Chart version
+
+ This model's catalog allows a range of {variant.chart.name} charts. The plan records the
+ exact version installed, and later upgrades keep it until asked to move.
+
+
+
+
+ {chart.control}
+
+
+
+ )}
+
{/* The settings are the page. Plan, diff and apply are what is done with
them, so they sit below as tabs rather than holding the form inside
one of them. */}
diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx
index 6628834..435a63d 100644
--- a/web/src/modules/swiss/routes/Upgrade.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.tsx
@@ -1,18 +1,10 @@
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "@swiss/lib/host";
-import { useMutation, useQueries, useQuery, type UseQueryResult } from "@tanstack/react-query";
+import { useMutation, useQueries, useQuery } from "@tanstack/react-query";
import { ArrowRight, ChevronLeft, TriangleAlert } from "lucide-react";
-import {
- api,
- deployApi,
- type ApplyResult,
- type ChartVersions,
- type DiffResult,
- type Plan,
-} from "@swiss/lib/api";
+import { api, deployApi, type ApplyResult, type DiffResult, type Plan } from "@swiss/lib/api";
import { Badge } from "@swiss/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@swiss/components/ui/card";
-import { Field, Input } from "@swiss/components/ui/input";
import {
DeploySettings,
EMPTY,
@@ -25,6 +17,7 @@ import { Pipeline } from "@swiss/components/Pipeline";
import { ErrorState, Loading } from "@swiss/components/States";
import { CatalogBadge, releaseCatalog as catalogOfRelease } from "@swiss/components/CatalogChoice";
import { movableCatalogs } from "@swiss/lib/upgrade";
+import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion";
export function Upgrade() {
const { namespace = "", release = "" } = useParams();
@@ -158,6 +151,16 @@ export function Upgrade() {
}
const cur = current.data;
+ const targetVersion = version || model?.latest;
+ const chart = chartVersionChoice({
+ running: cur.chart,
+ list: chartVersions,
+ value: chartVersion,
+ onChange: (v) => {
+ setChartVersion(v);
+ reset();
+ },
+ });
// What the pipeline acts on: a composed plan on an upgrade, the archive on a
// rollback. Nothing else about the page differs.
const target = rollbackTo ? (archived.data ?? null) : plan;
@@ -233,15 +236,21 @@ export function Upgrade() {
{!rollbackTo && (
- Catalog target
+ Upgrade target
- The catalog, model version, variant and chart this upgrade composes against. Engine
- flags, probes and the image move with them — that is what an upgrade is for.
+ Every row starts on what is deployed. Engine flags, probes and the image follow the
+ model version and variant.
-
+
{(catalogs.length > 1 || !releaseCatalog) && (
-
+
{
@@ -253,10 +262,15 @@ export function Upgrade() {
}}
options={movable}
emptyLabel={releaseCatalog ? `keep ${releaseCatalog}` : "choose a catalog"}
+ disabled={movable.length === 0}
/>
-
+
)}
-
+
{
@@ -265,16 +279,16 @@ export function Upgrade() {
reset();
}}
options={versions}
- emptyLabel="latest"
+ emptyLabel={model?.latest ? `latest (${model.latest})` : "latest"}
/>
-
-
+
({
value: v.id,
- label: moving && v.engine !== cur.engine ? `${v.id} (${v.engine}, not ${cur.engine})` : v.id,
+ label: moving && v.engine !== cur.engine ? `${v.id} (${v.engine})` : v.id,
disabled: moving && v.engine !== cur.engine,
}))}
emptyLabel={`keep ${cur.source.variant}`}
/>
-
- {
- setChartVersion(v);
- reset();
- }}
- />
+
+
+ {chart.control}
+
)}
@@ -422,103 +436,10 @@ function Change({ label, from, to }: { label: string; from?: string; to?: string
);
}
-function catalogHint(own: string | undefined, recordedName: string | undefined, hf: string | undefined, movable: string[]) {
- const from = `deployed from ${own ?? recordedName ?? "an unlisted catalog"}`;
- if (!hf) return `${from} · its model's HF repo is unknown, so no other catalog can be offered`;
- if (movable.length === 0) return `${from} · no other catalog has ${hf}`;
- return `${from} · offered: catalogs with ${hf}`;
-}
-
-// Empty means swissd's default: the running chart while the catalog's range
-// allows it, else the newest in range.
-function ChartVersionField({
- running,
- list,
- value,
- onChange,
-}: {
- running: Plan["chart"];
- list: UseQueryResult;
- value: string;
- onChange: (v: string) => void;
-}) {
- if (list.error) {
- return (
-
- onChange(e.target.value.trim())}
- placeholder={`keep ${running.version} if in range`}
- />
-
- );
- }
- const d = list.data;
- if (!d) {
- return (
-
-
-
- );
- }
- const pinned = d.versions.length === 1 && d.versions[0] === d.range;
- const keep = running.name === d.chart && d.versions.includes(running.version);
- const emptyLabel = keep
- ? `keep ${running.version}`
- : !d.versions[0]
- ? "none in range"
- : pinned
- ? `${d.versions[0]} (pinned)`
- : `newest: ${d.versions[0]}`;
- return (
-
-
-
- );
-}
-
-type Option = string | { value: string; label: string; disabled?: boolean };
-
-function Select({
- value,
- onChange,
- options,
- emptyLabel,
- disabled,
-}: {
- value: string;
- onChange: (v: string) => void;
- options: Option[];
- emptyLabel: string;
- disabled?: boolean;
-}) {
- return (
- onChange(e.target.value)}
- >
- {emptyLabel}
- {options.map((o) => {
- const opt = typeof o === "string" ? { value: o, label: o } : o;
- return (
-
- {opt.label}
-
- );
- })}
-
- );
+function catalogCaption(hf: string | undefined, movable: string[]) {
+ if (!hf) return "Its model's HF repo is unknown, so no other catalog can be offered.";
+ if (movable.length === 0) return `No other catalog has ${hf}.`;
+ return undefined;
}
function Back({ namespace }: { namespace: string }) {
From 74948fdeff548b665fb180d6b3d49e6135fe57fd Mon Sep 17 00:00:00 2001
From: aceforeverd
Date: Sat, 3 Oct 2026 07:23:48 +0000
Subject: [PATCH 3/5] feat(swiss): choose an upgrade's variant from the model
page's cards
The upgrade's variant was a bare select of ids, while install picks from cards
that carry the description, docs and perf report, the tuning uplift, GPU needs
and whether this cluster can run it. An upgrade can change the variant, so it
gets the same information.
- VariantCard moves to components/, with the footer action a prop: Deploy on
the model page, Select on an upgrade.
- Upgrade lists the target catalog and version's variants as cards: the
deployed one tagged, the chosen one ringed. On a catalog move, variants on
another engine are dimmed with the reason, as swissd refuses them.
- The target card's Variant row becomes a read-only summary of the choice.
- The card's chart line reads "chart sglang >=0.7.1", so a range is not read as
part of the name.
- Upgrade.test.tsx server-renders the page with seeded data: the swiss module's
first page-level test.
Co-Authored-By: Claude Opus 5.5
---
.../modules/swiss/components/VariantCard.tsx | 148 +++++++++++++++
web/src/modules/swiss/routes/Model.tsx | 170 ++----------------
web/src/modules/swiss/routes/Upgrade.test.tsx | 55 ++++++
web/src/modules/swiss/routes/Upgrade.tsx | 80 +++++++--
4 files changed, 283 insertions(+), 170 deletions(-)
create mode 100644 web/src/modules/swiss/components/VariantCard.tsx
create mode 100644 web/src/modules/swiss/routes/Upgrade.test.tsx
diff --git a/web/src/modules/swiss/components/VariantCard.tsx b/web/src/modules/swiss/components/VariantCard.tsx
new file mode 100644
index 0000000..89b403b
--- /dev/null
+++ b/web/src/modules/swiss/components/VariantCard.tsx
@@ -0,0 +1,148 @@
+import { ChartColumn, ExternalLink } from "lucide-react";
+import type { IndexVariant, Node } from "@swiss/lib/api";
+import { type Kind, httpLink, UPLIFT_HELP } from "@swiss/lib/catalog";
+import { gpuCount, matchesVendor, vendorLabel } from "@swiss/lib/gpu";
+import { Badge } from "@swiss/components/ui/badge";
+import { buttonVariants } from "@swiss/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@swiss/components/ui/card";
+import { WorkloadList } from "@swiss/components/Uplift";
+import { cn } from "@swiss/lib/utils";
+
+// A variant is a hardware and parallelism decision, so the card shows whether
+// this cluster can actually run it: at selection time, not forty minutes into
+// a load. The footer's action is the page's: Deploy on the model page, select
+// on an upgrade.
+export function VariantCard({
+ v,
+ kind,
+ uplift,
+ upliftTitle,
+ report,
+ workloads,
+ nodes,
+ tags,
+ selected,
+ disabled,
+ action,
+}: {
+ v: IndexVariant;
+ kind: Kind;
+ // The headline, "+58%", with "on v1.0.0" when measured on another version.
+ uplift?: string;
+ upliftTitle?: string;
+ report?: string;
+ workloads?: { name: string; uplift: number }[];
+ nodes?: Node[];
+ // Extra badges beside the id, e.g. "deployed".
+ tags?: React.ReactNode;
+ selected?: boolean;
+ // Why this variant cannot be chosen here; dims the card.
+ disabled?: string;
+ action?: React.ReactNode;
+}) {
+ const fit = fitness(v, nodes);
+ const link = httpLink(v.link);
+ return (
+
+
+
+
+ {v.id}
+ {tags}
+ {v.default && default }
+ {kind.optimized && (
+
+ optimized{uplift && ` ${uplift}`}
+
+ )}
+ {kind.baseline && baseline }
+ {v.engine}
+
+ {workloads && (
+
+ vs baseline:
+
+ )}
+ {v.description &&
{v.description}
}
+
+
+
+
+
+ {gpuCount(v.requires)}
+ {" · "}
+ {vendorLabel(v.requires.vendor)}
+ {" · "}
+ {v.requires.topology ?? "single-node"}
+ {v.requires.rdma && " · RDMA"}
+ {" · "}
+ chart {v.chart.name} {v.chart.version}
+
+
+ Runs on
+ {v.requires.gpuProduct?.length ? (
+ v.requires.gpuProduct.map((p) => (
+
+ {p}
+
+ ))
+ ) : (
+ any {vendorLabel(v.requires.vendor)}
+ )}
+
+
+ {disabled ? (
+
{disabled}
+ ) : (
+ fit &&
{fit.text}
+ )}
+ {action &&
{action}
}
+
+
+
+ );
+}
+
+function fitness(v: IndexVariant, nodes?: Node[]): { ok: boolean; text: string } | null {
+ if (!nodes) return null;
+ const matching = nodes.filter(
+ (n) =>
+ n.Schedulable &&
+ n.GPUs >= v.requires.gpus &&
+ matchesVendor(v.requires.vendor, n) &&
+ (!v.requires.gpuProduct?.length || v.requires.gpuProduct.includes(n.GPUProduct)),
+ );
+ const needed = v.requires.nodes ?? 1;
+ return matching.length >= needed
+ ? { ok: true, text: `${matching.length} matching node${matching.length === 1 ? "" : "s"}` }
+ : { ok: false, text: `needs ${needed}, ${matching.length} matching` };
+}
diff --git a/web/src/modules/swiss/routes/Model.tsx b/web/src/modules/swiss/routes/Model.tsx
index ef51783..a6bdfda 100644
--- a/web/src/modules/swiss/routes/Model.tsx
+++ b/web/src/modules/swiss/routes/Model.tsx
@@ -1,25 +1,12 @@
import { Link, useParams, useSearchParams } from "@swiss/lib/host";
import { useQuery } from "@tanstack/react-query";
-import { ChartColumn, ChevronLeft, ExternalLink } from "lucide-react";
-import { api, type Node, type Variant } from "@swiss/lib/api";
-import { Badge } from "@swiss/components/ui/badge";
-import {
- type Kind,
- comparison,
- formatUplift,
- httpLink,
- reportLink,
- UPLIFT_HELP,
- variantKind,
- workloadSummary,
-} from "@swiss/lib/catalog";
-import { gpuCount, matchesVendor, vendorLabel } from "@swiss/lib/gpu";
-import { Button, buttonVariants } from "@swiss/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@swiss/components/ui/card";
+import { ChevronLeft } from "lucide-react";
+import { api } from "@swiss/lib/api";
+import { comparison, formatUplift, reportLink, variantKind, workloadSummary } from "@swiss/lib/catalog";
+import { Button } from "@swiss/components/ui/button";
import { ErrorState, Loading } from "@swiss/components/States";
-import { WorkloadList } from "@swiss/components/Uplift";
+import { VariantCard } from "@swiss/components/VariantCard";
import { CatalogBadge, CatalogGate, useCatalogChoice, withCatalog } from "@swiss/components/CatalogChoice";
-import { cn } from "@swiss/lib/utils";
export function Model() {
const { name = "" } = useParams();
@@ -114,9 +101,17 @@ export function Model() {
report={cmp?.optimized === v.id ? report : undefined}
workloads={cmp?.optimized === v.id && cmp.workloads.length ? cmp.workloads : undefined}
nodes={nodes.data?.nodes}
- model={e.name}
- version={version}
- catalog={selected}
+ action={
+
+ Deploy
+
+ }
/>
))}
@@ -125,10 +120,6 @@ export function Model() {
);
}
-// A variant is a hardware and parallelism decision, so the card shows whether
-// this cluster can actually run it. Surfacing the fit check at selection time
-// beats discovering it at apply time, and far beats discovering it as an OOM
-// forty minutes into a load.
function VersionPicker({
name,
current,
@@ -163,135 +154,6 @@ function VersionPicker({
);
}
-function VariantCard({
- v,
- kind,
- uplift,
- upliftTitle,
- report,
- workloads,
- nodes,
- model,
- version,
- catalog,
-}: {
- v: Variant;
- kind: Kind;
- // The headline, "+58%", with "on v1.0.0" when measured on another version.
- // Which workload it is for is on the line below, with the others.
- uplift?: string;
- upliftTitle?: string;
- // The tuned variant's perf report, on the catalog's site.
- report?: string;
- workloads?: { name: string; uplift: number }[];
- nodes?: Node[];
- model: string;
- version: string;
- catalog: string;
-}) {
- const fit = fitness(v, nodes);
- const link = httpLink(v.link);
- return (
-
-
-
-
- {v.id}
- {v.default && default }
- {kind.optimized && (
-
- optimized{uplift && ` ${uplift}`}
-
- )}
- {kind.baseline && baseline }
- {v.engine}
-
- {workloads && (
-
- vs baseline:
-
- )}
- {v.description &&
{v.description}
}
-
-
-
-
-
- {gpuCount(v.requires)}
- {" · "}
- {vendorLabel(v.requires.vendor)}
- {" · "}
- {v.requires.topology ?? "single-node"}
- {v.requires.rdma && " · RDMA"}
- {" · "}
- chart {v.chart.name}-{v.chart.version}
-
-
- Runs on
- {v.requires.gpuProduct?.length ? (
- v.requires.gpuProduct.map((p) => (
- {p}
- ))
- ) : (
- any {vendorLabel(v.requires.vendor)}
- )}
-
-
- {fit && {fit.text} }
-
- Deploy
-
-
-
-
- );
-}
-
-function fitness(v: Variant, nodes?: Node[]): { ok: boolean; text: string } | null {
- if (!nodes) return null;
- const matching = nodes.filter(
- (n) =>
- n.Schedulable &&
- n.GPUs >= v.requires.gpus &&
- matchesVendor(v.requires.vendor, n) &&
- (!v.requires.gpuProduct?.length || v.requires.gpuProduct.includes(n.GPUProduct)),
- );
- const needed = v.requires.nodes ?? 1;
- return matching.length >= needed
- ? { ok: true, text: `${matching.length} matching node${matching.length === 1 ? "" : "s"}` }
- : { ok: false, text: `needs ${needed}, ${matching.length} matching` };
-}
-
function Field({ label, value }: { label: string; value: string }) {
return (
diff --git a/web/src/modules/swiss/routes/Upgrade.test.tsx b/web/src/modules/swiss/routes/Upgrade.test.tsx
new file mode 100644
index 0000000..ebad5d3
--- /dev/null
+++ b/web/src/modules/swiss/routes/Upgrade.test.tsx
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { renderToString } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { Upgrade } from "./Upgrade";
+import { ModuleProvider } from "@/shell/module";
+
+const variant = (id: string, engine: string, extra = {}) => ({
+ id, engine, chart: { name: engine, version: ">=0.7.1" }, requires: { gpus: 2, gpuProduct: ["H100"] }, ...extra,
+});
+const index = (hf: string) => ({
+ name: "x", ref: "r", source: "s",
+ index: { apiVersion: "v1", site: "https://site.example/", count: 1, models: [{
+ name: "m", source: { hf }, latest: "1.1.0",
+ tuning: [{ version: "1.1.0", baseline: "a", optimized: "c", uplift: 40, report: "perf.html" }],
+ versions: [
+ { version: "1.1.0", path: "p", digest: "d", variants: [
+ variant("a", "sglang", { default: true, description: "TP2 on H100", link: "https://docs.example/a" }),
+ variant("b", "vllm"), variant("c", "sglang", { description: "tuned" })] },
+ { version: "1.0.0", path: "p", digest: "d", variants: [variant("a", "sglang")] },
+ ] }] },
+});
+
+describe("Upgrade page", () => {
+ // No DOM here: a server render runs every component, which is what catches a
+ // page that compiles but throws.
+ it("renders the target rows and the variant cards the model page shows", () => {
+ const qc = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
+ qc.setQueryData(["release-plan", "models", "qwen"], {
+ apiVersion: "v1", release: { name: "qwen", namespace: "models" },
+ source: { catalogName: "public", model: "m", hf: "org/m", version: "1.0.0", variant: "a", digest: "sha256:0123456789abcdef0123" },
+ chart: { name: "sglang", version: "0.7.1" }, engine: "sglang", profile: "prod", layers: {}, hash: "h",
+ });
+ qc.setQueryData(["cluster"], { allowDeploy: true, namespace: "models", catalogs: [
+ { name: "public", url: "u1", source: "s1", ref: "r", default: true }, { name: "internal", url: "u2", source: "s2", ref: "r" }] });
+ qc.setQueryData(["nodes"], { nodes: [{ Name: "n1", GPUProduct: "H100", GPUs: 8, Schedulable: true }] });
+ qc.setQueryData(["catalog", "public"], index("org/m"));
+ qc.setQueryData(["catalog", "internal"], index("org/m"));
+ qc.setQueryData(["chart-versions", "public", "m", "", "a"], { chart: "sglang", range: ">=0.7.1", variant: "a", versions: ["0.8.6", "0.7.1"] });
+ const html = renderToString(
+
+
+
+ } />
+
+
+ ,
+ );
+ const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
+ for (const want of ["Upgrade target", "keep public", "latest (1.1.0)", "keep 0.7.1", "Catalog allows >=0.7.1",
+ "Variants in v1.1.0", "deployed", "Selected", "optimized +40%", "Report", "Docs", "1 matching node"]) {
+ expect(text).toContain(want);
+ }
+ });
+});
diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx
index 435a63d..5bc6dca 100644
--- a/web/src/modules/swiss/routes/Upgrade.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.tsx
@@ -18,6 +18,9 @@ import { ErrorState, Loading } from "@swiss/components/States";
import { CatalogBadge, releaseCatalog as catalogOfRelease } from "@swiss/components/CatalogChoice";
import { movableCatalogs } from "@swiss/lib/upgrade";
import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion";
+import { VariantCard } from "@swiss/components/VariantCard";
+import { Button } from "@swiss/components/ui/button";
+import { comparison, formatUplift, reportLink, variantKind, workloadSummary } from "@swiss/lib/catalog";
export function Upgrade() {
const { namespace = "", release = "" } = useParams();
@@ -152,6 +155,9 @@ export function Upgrade() {
const cur = current.data;
const targetVersion = version || model?.latest;
+ const cmp = targetVersion ? comparison(variants, targetVersion, model?.tuning) : null;
+ const site = indexes[catalogs.findIndex((c) => c.name === catalogName)]?.data?.index.site;
+ const report = cmp && model ? reportLink(site, model.name, cmp.report) : undefined;
const chart = chartVersionChoice({
running: cur.chart,
list: chartVersions,
@@ -287,23 +293,17 @@ export function Upgrade() {
hint="Parallelism and hardware. Moving to another catalog keeps the engine, so variants on another one are disabled."
from={cur.source.variant}
changing={!!variant && variant !== cur.source.variant}
- caption={variantMissing ? `${catalogName} has no ${keptVariant} in this version: pick a variant.` : undefined}
- tone="warning"
+ caption={
+ variantMissing
+ ? `${catalogName} has no ${keptVariant} in this version: pick a variant below.`
+ : "Chosen from the variants below."
+ }
+ tone={variantMissing ? "warning" : "muted"}
>
-
{
- setVariant(v);
- setChartVersion("");
- reset();
- }}
- options={variants.map((v) => ({
- value: v.id,
- label: moving && v.engine !== cur.engine ? `${v.id} (${v.engine})` : v.id,
- disabled: moving && v.engine !== cur.engine,
- }))}
- emptyLabel={`keep ${cur.source.variant}`}
- />
+
+ {keptVariant}
+ {!variant && kept }
+
)}
+ {!rollbackTo && variants.length > 0 && (
+
+
+ Variants{targetVersion && ` in v${targetVersion}`}
+ {catalogs.length > 1 && catalogName && ` · ${catalogName}`}
+
+
+ {variants.map((v) => {
+ const deployed = !moving && v.id === cur.source.variant;
+ const otherEngine = moving && v.engine !== cur.engine;
+ const pick = () => {
+ setVariant(v.id === cur.source.variant ? "" : v.id);
+ setChartVersion("");
+ reset();
+ };
+ return (
+ deployed}
+ selected={v.id === keptVariant}
+ disabled={otherEngine ? `Runs on ${v.engine}; a catalog move keeps ${cur.engine}` : undefined}
+ action={
+ v.id === keptVariant ? (
+ Selected
+ ) : (
+
+ Select
+
+ )
+ }
+ />
+ );
+ })}
+
+
+ )}
+
{/* The same form as the deploy page, seeded from the release. Editing it
here means the upgrade moves the catalog and the settings together,
which is two changes in one diff -- so the diff is the thing that has
From 3c5a8cd16b22884ef74ccd52f98cb4c7b04f37e1 Mon Sep 17 00:00:00 2001
From: aceforeverd
Date: Sat, 3 Oct 2026 07:29:12 +0000
Subject: [PATCH 4/5] fix(swiss): choose an upgrade's variant in a dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The variant cards sat inline under the target card, pushing the settings and
the pipeline a screen down for a choice most upgrades do not make.
- The Variant row shows the choice (id, engine, GPUs) and a Change… button
that opens the cards in a dialog; picking one selects it and closes it.
- The dialog's footer keeps the deployed variant when another is chosen.
- VariantChoices is its own component, so the tests render the cards directly,
the catalog-move case included.
Co-Authored-By: Claude Opus 5.5
---
web/src/modules/swiss/routes/Upgrade.test.tsx | 50 ++++-
web/src/modules/swiss/routes/Upgrade.tsx | 195 ++++++++++++------
2 files changed, 185 insertions(+), 60 deletions(-)
diff --git a/web/src/modules/swiss/routes/Upgrade.test.tsx b/web/src/modules/swiss/routes/Upgrade.test.tsx
index ebad5d3..5fa21f5 100644
--- a/web/src/modules/swiss/routes/Upgrade.test.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.test.tsx
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import { renderToString } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router";
-import { Upgrade } from "./Upgrade";
+import { Upgrade, VariantChoices } from "./Upgrade";
+import type { Plan } from "@swiss/lib/api";
import { ModuleProvider } from "@/shell/module";
const variant = (id: string, engine: string, extra = {}) => ({
@@ -21,6 +22,49 @@ const index = (hf: string) => ({
] }] },
});
+const deployed = {
+ apiVersion: "v1", release: { name: "qwen", namespace: "models" },
+ source: { catalogName: "public", model: "m", hf: "org/m", version: "1.0.0", variant: "a", digest: "sha256:0123456789abcdef0123" },
+ chart: { name: "sglang", version: "0.7.1" }, engine: "sglang", profile: "prod", layers: {}, hash: "h",
+} as Plan;
+
+const cards = (moving: boolean, selected: string) => {
+ const m = index("org/m").index.models[0];
+ const html = renderToString(
+
+
+ {}}
+ tuning={m.tuning}
+ version="1.1.0"
+ report="https://site.example/models/m/perf.html"
+ nodes={[{ Name: "n1", GPUProduct: "H100", GPUs: 8, Schedulable: true } as never]}
+ />
+
+ ,
+ );
+ return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
+};
+
+describe("VariantChoices", () => {
+ it("shows what the model page shows, with the deployed and chosen variants marked", () => {
+ const text = cards(false, "a");
+ for (const want of ["deployed", "Selected", "optimized +40%", "Report", "Docs", "TP2 on H100", "1 matching node", "Select"]) {
+ expect(text).toContain(want);
+ }
+ });
+
+ it("dims variants on another engine when moving catalogs", () => {
+ const text = cards(true, "a");
+ expect(text).toContain("Runs on vllm; a catalog move keeps sglang");
+ expect(text).not.toContain("deployed");
+ });
+});
+
describe("Upgrade page", () => {
// No DOM here: a server render runs every component, which is what catches a
// page that compiles but throws.
@@ -48,8 +92,10 @@ describe("Upgrade page", () => {
);
const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
for (const want of ["Upgrade target", "keep public", "latest (1.1.0)", "keep 0.7.1", "Catalog allows >=0.7.1",
- "Variants in v1.1.0", "deployed", "Selected", "optimized +40%", "Report", "Docs", "1 matching node"]) {
+ "sglang · 2 GPU", "kept", "Change…"]) {
expect(text).toContain(want);
}
+ // The cards live in the dialog, closed until Change… is pressed.
+ expect(text).not.toContain("Choose a variant");
});
});
diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx
index 5bc6dca..120b2a1 100644
--- a/web/src/modules/swiss/routes/Upgrade.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.tsx
@@ -2,7 +2,16 @@ import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "@swiss/lib/host";
import { useMutation, useQueries, useQuery } from "@tanstack/react-query";
import { ArrowRight, ChevronLeft, TriangleAlert } from "lucide-react";
-import { api, deployApi, type ApplyResult, type DiffResult, type Plan } from "@swiss/lib/api";
+import {
+ api,
+ deployApi,
+ type ApplyResult,
+ type DiffResult,
+ type IndexVariant,
+ type Node,
+ type Plan,
+ type Tuning,
+} from "@swiss/lib/api";
import { Badge } from "@swiss/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@swiss/components/ui/card";
import {
@@ -19,6 +28,8 @@ import { CatalogBadge, releaseCatalog as catalogOfRelease } from "@swiss/compone
import { movableCatalogs } from "@swiss/lib/upgrade";
import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion";
import { VariantCard } from "@swiss/components/VariantCard";
+import { Dialog } from "@swiss/components/ui/dialog";
+import { gpuCount } from "@swiss/lib/gpu";
import { Button } from "@swiss/components/ui/button";
import { comparison, formatUplift, reportLink, variantKind, workloadSummary } from "@swiss/lib/catalog";
@@ -35,6 +46,7 @@ export function Upgrade() {
// "" stays on the release's own catalog; the server's default for both.
const [targetCatalog, setTargetCatalog] = useState("");
const [chartVersion, setChartVersion] = useState("");
+ const [picking, setPicking] = useState(false);
const current = useQuery({
queryKey: ["release-plan", namespace, release],
@@ -155,9 +167,16 @@ export function Upgrade() {
const cur = current.data;
const targetVersion = version || model?.latest;
- const cmp = targetVersion ? comparison(variants, targetVersion, model?.tuning) : null;
const site = indexes[catalogs.findIndex((c) => c.name === catalogName)]?.data?.index.site;
- const report = cmp && model ? reportLink(site, model.name, cmp.report) : undefined;
+ const optimized = targetVersion ? comparison(variants, targetVersion, model?.tuning) : null;
+ const report = optimized && model ? reportLink(site, model.name, optimized.report) : undefined;
+ const keptInfo = variants.find((v) => v.id === keptVariant);
+ const pickVariant = (id: string) => {
+ setVariant(id === cur.source.variant ? "" : id);
+ setChartVersion("");
+ setPicking(false);
+ reset();
+ };
const chart = chartVersionChoice({
running: cur.chart,
list: chartVersions,
@@ -293,16 +312,26 @@ export function Upgrade() {
hint="Parallelism and hardware. Moving to another catalog keeps the engine, so variants on another one are disabled."
from={cur.source.variant}
changing={!!variant && variant !== cur.source.variant}
- caption={
- variantMissing
- ? `${catalogName} has no ${keptVariant} in this version: pick a variant below.`
- : "Chosen from the variants below."
- }
- tone={variantMissing ? "warning" : "muted"}
+ caption={variantMissing ? `${catalogName} has no ${keptVariant} in this version: choose another.` : undefined}
+ tone="warning"
>
-
- {keptVariant}
- {!variant &&
kept }
+
+ {keptVariant}
+ {keptInfo && (
+
+ {keptInfo.engine} · {gpuCount(keptInfo.requires)}
+
+ )}
+ {!variant && kept }
+ setPicking(true)}
+ >
+ Change…
+
)}
- {!rollbackTo && variants.length > 0 && (
-
-
- Variants{targetVersion && ` in v${targetVersion}`}
- {catalogs.length > 1 && catalogName && ` · ${catalogName}`}
-
-
- {variants.map((v) => {
- const deployed = !moving && v.id === cur.source.variant;
- const otherEngine = moving && v.engine !== cur.engine;
- const pick = () => {
- setVariant(v.id === cur.source.variant ? "" : v.id);
- setChartVersion("");
- reset();
- };
- return (
- deployed}
- selected={v.id === keptVariant}
- disabled={otherEngine ? `Runs on ${v.engine}; a catalog move keeps ${cur.engine}` : undefined}
- action={
- v.id === keptVariant ? (
- Selected
- ) : (
-
- Select
-
- )
- }
- />
- );
- })}
-
-
+ {!rollbackTo && (
+ setPicking(false)}
+ title="Choose a variant"
+ subtitle={
+ <>
+ {cur.source.model}
+ {targetVersion && ` v${targetVersion}`}
+ {catalogs.length > 1 && catalogName && ` · ${catalogName}`}
+ {moving && ` · a catalog move keeps ${cur.engine}`}
+ >
+ }
+ footer={
+ variant && (
+ pickVariant(cur.source.variant)}>
+ Keep {cur.source.variant}
+
+ )
+ }
+ >
+
+
)}
{/* The same form as the deploy page, seeded from the release. Editing it
@@ -419,6 +435,69 @@ export function Upgrade() {
);
}
+// The cards the model page shows, as an upgrade's choice: the deployed one
+// tagged, the chosen one ringed, and on a catalog move the variants on another
+// engine dimmed, since swissd refuses them.
+export function VariantChoices({
+ variants,
+ current,
+ moving,
+ selected,
+ onPick,
+ tuning,
+ version,
+ report,
+ nodes,
+}: {
+ variants: IndexVariant[];
+ current: Plan;
+ moving: boolean;
+ selected: string;
+ onPick: (id: string) => void;
+ tuning?: Tuning[];
+ version?: string;
+ report?: string;
+ nodes?: Node[];
+}) {
+ const cmp = version ? comparison(variants, version, tuning) : null;
+ return (
+
+ {variants.map((v) => {
+ const otherEngine = moving && v.engine !== current.engine;
+ const optimized = cmp?.optimized === v.id;
+ return (
+ deployed}
+ selected={v.id === selected}
+ disabled={otherEngine ? `Runs on ${v.engine}; a catalog move keeps ${current.engine}` : undefined}
+ action={
+ v.id === selected ? (
+ Selected
+ ) : (
+ onPick(v.id)}>
+ Select
+
+ )
+ }
+ />
+ );
+ })}
+
+ );
+}
+
// WhatMoves names the catalog change before anything else on the Plan tab. The
// settings above are editable now, so this is also where an operator sees that
// an upgrade they meant as a version bump is carrying a form change with it.
From 9bedcf5fe6095884ff9ff5daf6a91d5480eec525 Mon Sep 17 00:00:00 2001
From: aceforeverd
Date: Sat, 3 Oct 2026 07:42:27 +0000
Subject: [PATCH 5/5] fix(swiss): open the variant dialog from the whole field,
and colour the target rows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- The Variant field is one button: a click anywhere in it opens the dialog,
with a chevron where the Change… button was.
- Target rows that will change what is deployed get a green wash and a green
left edge; the deployed value sits in a neutral chip; warnings and errors
are amber and red callouts with an icon instead of coloured text. The Deploy
page's chart row shares the treatment.
Co-Authored-By: Claude Opus 5.5
---
.../modules/swiss/components/ChartVersion.tsx | 40 +++++++++++--------
web/src/modules/swiss/routes/Upgrade.test.tsx | 4 +-
web/src/modules/swiss/routes/Upgrade.tsx | 22 +++++-----
3 files changed, 36 insertions(+), 30 deletions(-)
diff --git a/web/src/modules/swiss/components/ChartVersion.tsx b/web/src/modules/swiss/components/ChartVersion.tsx
index ad1596a..2ba7d04 100644
--- a/web/src/modules/swiss/components/ChartVersion.tsx
+++ b/web/src/modules/swiss/components/ChartVersion.tsx
@@ -1,4 +1,4 @@
-import { ArrowRight } from "lucide-react";
+import { ArrowRight, CircleX, TriangleAlert } from "lucide-react";
import type { UseQueryResult } from "@tanstack/react-query";
import type { ChartVersions, Plan } from "@swiss/lib/api";
import { cn } from "@swiss/lib/utils";
@@ -25,19 +25,25 @@ export function TargetRow({
children: React.ReactNode;
}) {
const upgrade = from !== undefined;
+ const Icon = tone === "destructive" ? CircleX : TriangleAlert;
return (
+ {changing &&
}
{label}
{upgrade && (
<>
-
+
{from || "—"}
)}
-
{children}
- {caption && (
-
- {caption}
-
- )}
+ {children}
+ {caption &&
+ (tone === "muted" ? (
+
{caption}
+ ) : (
+
+
+ {caption}
+
+ ))}
);
diff --git a/web/src/modules/swiss/routes/Upgrade.test.tsx b/web/src/modules/swiss/routes/Upgrade.test.tsx
index 5fa21f5..7e0e059 100644
--- a/web/src/modules/swiss/routes/Upgrade.test.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.test.tsx
@@ -92,10 +92,10 @@ describe("Upgrade page", () => {
);
const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
for (const want of ["Upgrade target", "keep public", "latest (1.1.0)", "keep 0.7.1", "Catalog allows >=0.7.1",
- "sglang · 2 GPU", "kept", "Change…"]) {
+ "sglang · 2 GPU", "kept"]) {
expect(text).toContain(want);
}
- // The cards live in the dialog, closed until Change… is pressed.
+ // The cards live in the dialog, closed until the variant field is clicked.
expect(text).not.toContain("Choose a variant");
});
});
diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx
index 120b2a1..093fefd 100644
--- a/web/src/modules/swiss/routes/Upgrade.tsx
+++ b/web/src/modules/swiss/routes/Upgrade.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "@swiss/lib/host";
import { useMutation, useQueries, useQuery } from "@tanstack/react-query";
-import { ArrowRight, ChevronLeft, TriangleAlert } from "lucide-react";
+import { ArrowRight, ChevronLeft, ChevronsUpDown, TriangleAlert } from "lucide-react";
import {
api,
deployApi,
@@ -315,7 +315,13 @@ export function Upgrade() {
caption={variantMissing ? `${catalogName} has no ${keptVariant} in this version: choose another.` : undefined}
tone="warning"
>
-
+ setPicking(true)}
+ aria-haspopup="dialog"
+ className="flex h-9 w-full items-center gap-2 rounded-md border bg-background px-3 text-left text-sm hover:bg-muted/40 focus-visible:outline-2 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
+ >
{keptVariant}
{keptInfo && (
@@ -323,16 +329,8 @@ export function Upgrade() {
)}
{!variant && kept }
- setPicking(true)}
- >
- Change…
-
-
+
+