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
(EMPTY); @@ -97,6 +127,8 @@ export function Upgrade() { model: current.data!.source.model, version: version || undefined, variant: variant || undefined, + catalog: moving ? targetCatalog : undefined, + chartVersion: chartVersion || undefined, // The server carries forward anything the form does not cover, so a // value set once from a flag survives the upgrade instead of being // dropped by a form that never knew about it. @@ -129,9 +161,6 @@ export function Upgrade() { // 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; - const model = catalog.data?.index.models.find((m) => m.name === cur.source.model); - const versions = model?.versions.map((v) => v.version) ?? []; - const variants = model?.versions.find((v) => v.version === (version || model.latest))?.variants ?? []; if (!cluster.data?.allowDeploy) { return ( @@ -180,8 +209,8 @@ export function Upgrade() { {recorded?.catalogName ? (

It was deployed from catalog {recorded.catalogName}, which this site no - longer lists. An upgrade stays on a release's catalog, so add it back to the site - profile's catalogs to upgrade. Rollbacks still work. + longer lists. Add it back to the site profile, or upgrade from another catalog with + the same model below. Rollbacks still work.

) : (

@@ -192,8 +221,8 @@ export function Upgrade() { (it records {recorded.catalog}) )} - , and there is no default to stand in. Mark one catalog default in the site profile to - upgrade it from there. Rollbacks still work. + , and there is no default to stand in. Mark one catalog default in the site profile, + or upgrade from another catalog with the same model below. Rollbacks still work.

)} @@ -206,33 +235,72 @@ export function Upgrade() { Catalog target

- The model version and variant this upgrade composes against. Engine flags, probes - and the image move with them — that is what an upgrade is for. + 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.

+ {(catalogs.length > 1 || !releaseCatalog) && ( + + { setVersion(v); + setChartVersion(""); reset(); }} options={versions} emptyLabel="latest" /> - + onChange(e.target.value.trim())} + placeholder={`keep ${running.version} if in range`} + /> + + ); + } + const d = list.data; + if (!d) { + return ( + + + + ); +} + +type Option = string | { value: string; label: string; disabled?: boolean }; + function Select({ value, onChange, options, emptyLabel, + disabled, }: { value: string; onChange: (v: string) => void; - options: string[]; + options: Option[]; emptyLabel: string; + disabled?: boolean; }) { return ( ); } From cb938b43a7c1e0721ae6592c2e12d3a5c61809a2 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Sat, 3 Oct 2026 05:02:55 +0000 Subject: [PATCH 2/5] feat(swiss): pick the chart version on install, and restyle the upgrade target A catalog variant may now name a chart range (>=0.7.1). swissd installs the newest version in range when none is named, but the Deploy page had no way to name one -- which is the only way past a registry swissd cannot list. - Deploy: a Chart version card when the variant's chart.version is a range, listing the versions in range (default: newest). A listing error is shown inline and the field takes a typed version instead. - Upgrade: the target card uses the settings rows' layout -- label with a hover hint, the deployed value, an arrow, the control -- and marks the rows that will change what is deployed. - TargetRow, chartVersionChoice and Select are shared in components/ChartVersion.tsx; isChartRange reads a pin the way swissd does (strict semver, no "v"). Co-Authored-By: Claude Opus 5.5 --- .../modules/swiss/components/ChartVersion.tsx | 162 +++++++++++++++ web/src/modules/swiss/lib/upgrade.test.ts | 11 +- web/src/modules/swiss/lib/upgrade.ts | 5 + web/src/modules/swiss/routes/Deploy.tsx | 54 ++++- web/src/modules/swiss/routes/Upgrade.tsx | 189 +++++------------- 5 files changed, 282 insertions(+), 139 deletions(-) create mode 100644 web/src/modules/swiss/components/ChartVersion.tsx diff --git a/web/src/modules/swiss/components/ChartVersion.tsx b/web/src/modules/swiss/components/ChartVersion.tsx new file mode 100644 index 0000000..ad1596a --- /dev/null +++ b/web/src/modules/swiss/components/ChartVersion.tsx @@ -0,0 +1,162 @@ +import { ArrowRight } from "lucide-react"; +import type { UseQueryResult } from "@tanstack/react-query"; +import type { ChartVersions, Plan } from "@swiss/lib/api"; +import { cn } from "@swiss/lib/utils"; +import { HoverHint } from "@swiss/components/ui/hint"; +import { Input } from "@swiss/components/ui/input"; + +// One row of a target card: what is deployed, and what this will move it to. +// Without from (an install) the row is just label and control. +export function TargetRow({ + label, + hint, + from, + changing, + caption, + tone = "muted", + children, +}: { + label: string; + hint?: string; + from?: string; + changing?: boolean; + caption?: React.ReactNode; + tone?: "muted" | "warning" | "destructive"; + children: React.ReactNode; +}) { + const upgrade = from !== undefined; + return ( +
+ + {label} + + {upgrade && ( + <> + + {from || "—"} + + + + )} +
+
{children}
+ {caption && ( +

+ {caption} +

+ )} +
+
+ ); +} + +// The chart version control and what it resolves to. Empty means swissd's +// default: on an upgrade the running chart while the catalog's range allows it, +// else (and on an install) the newest in range. +export function chartVersionChoice({ + running, + list, + value, + onChange, +}: { + running?: Plan["chart"]; + list: UseQueryResult; + value: string; + onChange: (v: string) => void; +}): { control: React.ReactNode; caption?: string; tone: "muted" | "destructive"; resolved?: string } { + if (list.error) { + return { + control: ( + onChange(e.target.value.trim())} + placeholder={running ? `keep ${running.version} if in range` : "type a version, e.g. 0.8.6"} + /> + ), + caption: `Could not list versions: ${list.error.message}`, + tone: "destructive", + resolved: value || running?.version, + }; + } + const d = list.data; + if (!d) { + return { + control: + ), + caption: pinned ? "Pinned by the catalog" : `Catalog allows ${d.range}`, + tone: "muted", + resolved: value || fallback, + }; +} + +type Option = string | { value: string; label: string; disabled?: boolean }; + +export function Select({ + value, + onChange, + options, + emptyLabel, + disabled, +}: { + value: string; + onChange: (v: string) => void; + options: Option[]; + emptyLabel: string; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/web/src/modules/swiss/lib/upgrade.test.ts b/web/src/modules/swiss/lib/upgrade.test.ts index 36c434e..05dbf81 100644 --- a/web/src/modules/swiss/lib/upgrade.test.ts +++ b/web/src/modules/swiss/lib/upgrade.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { CatalogInfo } from "@swiss/lib/api"; -import { movableCatalogs } from "./upgrade"; +import { isChartRange, movableCatalogs } from "./upgrade"; const catalogs: CatalogInfo[] = [ { name: "public", url: "https://p", source: "https://p/index.json", default: true }, @@ -38,3 +38,12 @@ describe("movableCatalogs", () => { expect(got.movable).toEqual(["public", "internal"]); }); }); + +describe("isChartRange", () => { + it("reads a bare version as a pin and anything else as a range", () => { + for (const pin of ["0.7.1", "0.8.0-rc1", "1.0.0+build.1"]) expect(isChartRange(pin)).toBe(false); + for (const range of ["v0.7.1", ">=0.7.1", "^0.7.1", "~0.7", "0.7.x", ">=0.7.1 <0.9.0", "^0.7 || ^0.8"]) { + expect(isChartRange(range)).toBe(true); + } + }); +}); diff --git a/web/src/modules/swiss/lib/upgrade.ts b/web/src/modules/swiss/lib/upgrade.ts index f9f6f78..2ef7f7c 100644 --- a/web/src/modules/swiss/lib/upgrade.ts +++ b/web/src/modules/swiss/lib/upgrade.ts @@ -14,3 +14,8 @@ export function movableCatalogs( if (!hf) return { movable: [] }; return { hf, movable: catalogs.map((c) => c.name).filter((n) => n !== own && hfIn(n) === hf) }; } + +// A bare version is a pin, as swissd reads one (strict semver, no "v"); anything else is a range. +export function isChartRange(spec: string): boolean { + return !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(spec.trim()); +} diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index 6f096ed..62a9ba3 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -1,14 +1,16 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Link, useParams, useSearchParams } from "@swiss/lib/host"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ChevronLeft, TriangleAlert } from "lucide-react"; import { api, deployApi, type ApplyResult, type DiffResult, type Plan } from "@swiss/lib/api"; import { Badge } from "@swiss/components/ui/badge"; -import { Card, CardContent } from "@swiss/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@swiss/components/ui/card"; import { DeploySettings, EMPTY, imageOf, planRequest, type Form } from "@swiss/components/DeploySettings"; import { Pipeline } from "@swiss/components/Pipeline"; import { ErrorState, Loading } from "@swiss/components/States"; import { CatalogBadge, CatalogGate, useCatalogChoice, withCatalog } from "@swiss/components/CatalogChoice"; +import { chartVersionChoice, TargetRow } from "@swiss/components/ChartVersion"; +import { isChartRange } from "@swiss/lib/upgrade"; export function Deploy() { const { name = "" } = useParams(); @@ -27,6 +29,16 @@ export function Deploy() { }); const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster }); const nodes = useQuery({ queryKey: ["nodes"], queryFn: api.nodes }); + const chartVariant = model.data?.entry.variants.find((v) => v.id === variantId) ?? model.data?.entry.variants[0]; + const chartRange = !!chartVariant && isChartRange(chartVariant.chart.version); + const chartVersions = useQuery({ + queryKey: ["chart-versions", catalog, name, version, chartVariant?.id], + queryFn: () => api.chartVersions(name, { catalog, version: version || undefined, variant: chartVariant?.id }), + enabled: chartRange, + retry: false, + }); + const [chartVersion, setChartVersion] = useState(""); + useEffect(() => setChartVersion(""), [catalog, name, version, variantId]); const [form, setForm] = useState({ ...EMPTY, serviceId: name }); // serviceId is the identity everything else is named after: the helm release, @@ -51,7 +63,9 @@ export function Deploy() { const planM = useMutation({ mutationFn: () => - deployApi.plan(planRequest(form, { model: name, version, variant: variantId, catalog })), + deployApi.plan( + planRequest(form, { model: name, version, variant: variantId, catalog, chartVersion: chartVersion || undefined }), + ), // The previous plan is superseded the moment a recompose starts. Dropping // it here rather than on the way back means a failed compose leaves // nothing to act on, instead of a stale plan the error message sits behind. @@ -73,6 +87,14 @@ export function Deploy() { if (model.error) return ; const entry = model.data.entry; + const chart = chartVersionChoice({ + list: chartVersions, + value: chartVersion, + onChange: (v) => { + setChartVersion(v); + update({}); + }, + }); const variant = entry.variants.find((v) => v.id === variantId) ?? entry.variants[0]; if (!cluster.data?.allowDeploy) { @@ -100,12 +122,36 @@ export function Deploy() { {variant.requires.nodes && variant.requires.nodes > 1 ? ` × ${variant.requires.nodes} nodes` : ""}{" "} - · chart {variant.chart.name}-{variant.chart.version} + · chart {variant.chart.name} + {chartRange ? ` ${variant.chart.version}` : `-${variant.chart.version}`}

{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) && ( - + { @@ -265,16 +279,16 @@ export function Upgrade() { reset(); }} options={versions} - emptyLabel="latest" + emptyLabel={model?.latest ? `latest (${model.latest})` : "latest"} /> - - + onChange(e.target.value.trim())} - placeholder={`keep ${running.version} if in range`} - /> - - ); - } - const d = list.data; - if (!d) { - 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 ( - - ); +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}

} +
+
+ {report && ( + + + Report + + )} + {link && ( + + + Docs + + )} +
+
+ +
+ {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={ + + + + } /> ))} @@ -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}

} -
-
- {report && ( - - - Report - - )} - {link && ( - - - Docs - - )} -
-
- -
- {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}} - - - -
-
-
- ); -} - -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"} > -