diff --git a/.gitignore b/.gitignore index 7d0f326..add58ca 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ cscope.po.out # End of https://www.toptal.com/developers/gitignore/api/tags + +.DS_Store diff --git a/web/src/modules/inferences/CatalogPage.tsx b/web/src/modules/inferences/CatalogPage.tsx index bdefa40..66ff695 100644 --- a/web/src/modules/inferences/CatalogPage.tsx +++ b/web/src/modules/inferences/CatalogPage.tsx @@ -24,7 +24,7 @@ import { } from "@modelsphere/ui"; import { Boxes, Library, MoreHorizontal, TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; -import { api } from "@swiss/lib/api"; +import { api, type CatalogInfo } from "@swiss/lib/api"; import { facetsOf, formatUplift, sorters, type Facets } from "@swiss/lib/catalog"; import { useCatalogChoice } from "@swiss/components/CatalogChoice"; import { useT } from "@/modules/inferences/i18n"; @@ -41,7 +41,7 @@ export function CatalogPage() { queryFn: () => api.catalog(choice.selected), enabled: !!choice.selected, }); - const all = useMemo(() => catalog.data?.index.models.map(facetsOf) ?? [], [catalog.data]); + const all = useMemo(() => catalog.data?.models.map(facetsOf) ?? [], [catalog.data]); const options = useMemo(() => facetOptions(all), [all]); const f = filtersFrom(params); const set = (patch: Partial) => setParams((prev) => withFilters(prev, patch), { replace: true }); @@ -70,11 +70,16 @@ export function CatalogPage() { {choice.several && ( ({ value: c.name, label: c.name }))} + options={choice.catalogs.map((c) => ({ value: c.name, label: c.name, description: c.schema === "v1" ? t("catalog.legacy") : undefined }))} value={choice.selected} onValueChange={(v) => v && choice.choose(v)} /> )} + {catalog.data?.schema === "v1" && ( + + {t("catalog.legacy")} + + )} {facet("family")} {facet("engine")} {facet("hardware")} @@ -181,7 +186,7 @@ function ModelCard({ x, catalog, to, onDeploy }: { x: Facets; catalog: string; t {m.source.hf} - v{m.latest} + {x.version && v{x.version}} {t("catalog.variants", { n: x.variants.length })} @@ -207,7 +212,7 @@ function ModelCard({ x, catalog, to, onDeploy }: { x: Facets; catalog: string; t ); } -function NoCatalog({ catalogs, choose }: { catalogs: { name: string; source: string; default?: boolean }[]; choose: (name: string) => void }) { +function NoCatalog({ catalogs, choose }: { catalogs: CatalogInfo[]; choose: (name: string) => void }) { const t = useT(); const p = useModulePath(); if (catalogs.length === 0) { @@ -231,6 +236,11 @@ function NoCatalog({ catalogs, choose }: { catalogs: { name: string; source: str ))} diff --git a/web/src/modules/inferences/ModelPage.tsx b/web/src/modules/inferences/ModelPage.tsx index 40adeb4..8856dfc 100644 --- a/web/src/modules/inferences/ModelPage.tsx +++ b/web/src/modules/inferences/ModelPage.tsx @@ -6,7 +6,6 @@ import { AlertDescription, Badge, Button, - DataSelect, DetailHeader, Empty, EmptyHeader, @@ -21,8 +20,8 @@ import { } from "@modelsphere/ui"; import { TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; -import { api, type Node, type Variant } from "@swiss/lib/api"; -import { comparison, formatUplift, httpLink, reportLink, variantKind, workloadSummary, type Kind } from "@swiss/lib/catalog"; +import { api, type ModelVariant, type Node } from "@swiss/lib/api"; +import { comparison, formatUplift, httpLink, reportLink, sharedVersion, variantKind, workloadSummary, type Kind } from "@swiss/lib/catalog"; import { gpuModels, vendorLabel } from "@swiss/lib/gpu"; import { useCatalogChoice } from "@swiss/components/CatalogChoice"; import { useT } from "@/modules/inferences/i18n"; @@ -35,19 +34,19 @@ export function ModelPage() { const navigate = useNavigate(); const { name = "" } = useParams(); const [params, setParams] = useSearchParams(); - const version = params.get("version") ?? undefined; const tab = params.get("tab") === "info" ? "info" : "variants"; const choice = useCatalogChoice(); + // The default variant resolved, for what only its file says: the served name. const model = useQuery({ - queryKey: ["model", name, version, choice.selected], - queryFn: () => api.model(name, version, choice.selected), + queryKey: ["model", choice.selected, name, "", ""], + queryFn: () => api.model(name, { catalog: choice.selected }), enabled: !!choice.selected, }); const index = useQuery({ queryKey: ["catalog", choice.selected], queryFn: () => api.catalog(choice.selected), enabled: !!choice.selected }); const nodes = useQuery({ queryKey: ["nodes"], queryFn: api.nodes, retry: false }); const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster }); const [deploying, setDeploying] = useState(null); - const onDeploy = cluster.data?.allowDeploy ? (x: { model: string; catalog: string; version?: string; variant?: string }) => setDeploying({ kind: "deploy", ...x }) : undefined; + const onDeploy = cluster.data?.allowDeploy ? (x: { model: string; catalog: string; variant?: string }) => setDeploying({ kind: "deploy", ...x }) : undefined; const setParam = (k: string, v?: string) => setParams((prev) => { @@ -74,43 +73,41 @@ export function ModelPage() { ); } - const e = model.data.entry; - const im = index.data?.index.models.find((m) => m.name === name); - const versions = im?.versions.map((v) => v.version) ?? [e.version]; - const deprecated = im?.deprecated; - const cmp = comparison(e.variants, e.version, im?.tuning); - const site = index.data?.index.site; - const variants = [...e.variants].sort((a, b) => Number(!!b.default) - Number(!!a.default)); + const { model: m, variant: resolved, schema } = model.data; + const deprecated = m.deprecated; + const cmp = comparison(m.variants, m.tuning); + const site = index.data?.site; + const variants = [...m.variants].sort((a, b) => Number(!!b.default) - Number(!!a.default)); + // On v1 every variant is at its entry's version; on v2 each is at its own. + const shared = sharedVersion(variants); + const digest = shared ? variants[0].digest : undefined; return page( setParam("tab", v === "info" ? "info" : undefined)} className="flex-col gap-4"> onDeploy({ model: name, catalog: choice.selected, version }) }] + ? [{ key: "deploy", label: t("model.deploy"), onClick: () => onDeploy({ model: name, catalog: choice.selected }) }] : undefined } meta={[ - { label: t("model.fields.family"), value: e.family || "-" }, - { label: t("model.fields.source"), value: {e.source.hf} }, - { label: t("model.fields.license"), value: e.license || "-" }, + { label: t("model.fields.family"), value: m.family || "-" }, + { label: t("model.fields.source"), value: {m.source.hf} }, + { label: t("model.fields.version"), value: shared ?? t("model.perVariant") }, { - label: t("model.fields.version"), + label: t("model.fields.schema"), value: - versions.length > 1 ? ( - setParam("version", v === im?.latest ? undefined : v)} - options={versions.map((v) => ({ value: v, label: v === im?.latest ? `${v} (${t("model.latest")})` : v }))} - /> + schema === "v1" ? ( + + {t("catalog.legacy")} + ) : ( - e.version + schema ), }, ]} @@ -136,11 +133,12 @@ export function ModelPage() { onDeploy({ model: name, catalog: choice.selected, version, variant: v.id }) : undefined} + onDeploy={onDeploy ? () => onDeploy({ model: name, catalog: choice.selected, variant: v.id }) : undefined} /> ))} @@ -153,13 +151,12 @@ export function ModelPage() { columns={1} labelClassName="w-28 shrink-0" items={[ - { label: t("model.fields.source"), value: [e.source.hf, e.source.revision].filter(Boolean).join(" @ ") }, - { label: t("model.fields.servedName"), value: e.servedName }, - { label: t("model.fields.license"), value: e.license }, - { label: t("model.fields.version"), value: e.version }, - { label: t("model.fields.digest"), value: e.digest && {e.digest} }, - { label: t("model.fields.tags"), value: e.tags?.length ? e.tags.join(", ") : undefined }, - { label: t("model.fields.sizeLabel"), value: e.source.sizeGiB ? t("model.fields.size", { n: e.source.sizeGiB }) : undefined }, + { label: t("model.fields.source"), value: [m.source.hf, m.source.revision].filter(Boolean).join(" @ ") }, + { label: t("model.fields.servedName"), value: resolved.servedName }, + { label: t("model.fields.version"), value: shared }, + { label: t("model.fields.digest"), value: digest && {digest} }, + { label: t("model.fields.tags"), value: m.tags?.length ? m.tags.join(", ") : undefined }, + { label: t("model.fields.sizeLabel"), value: m.source.sizeGiB ? t("model.fields.size", { n: m.source.sizeGiB }) : undefined }, ].filter((i) => i.value)} /> @@ -172,13 +169,16 @@ export function ModelPage() { function VariantCard({ v, kind, + version, uplift, report, nodes, onDeploy, }: { - v: Variant; + v: ModelVariant; kind: Kind; + // The variant's own version, when the model's variants are not all at one. + version?: string; uplift: ReturnType; report?: string; nodes?: Node[]; @@ -193,7 +193,13 @@ function VariantCard({ title={ {v.id} + {version && v{version}} {v.default && {t("model.default")}} + {v.deprecated && ( + + {t("catalog.deprecated")} + + )} {kind.optimized && {t("model.optimized")}} {kind.baseline && {t("model.baseline")}} {v.engine} diff --git a/web/src/modules/inferences/SetupPage.tsx b/web/src/modules/inferences/SetupPage.tsx index 545b9ef..e391a29 100644 --- a/web/src/modules/inferences/SetupPage.tsx +++ b/web/src/modules/inferences/SetupPage.tsx @@ -1,36 +1,31 @@ import { useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; -import { Alert, AlertDescription, PageBanner, Skeleton } from "@modelsphere/ui"; -import { Settings2, TriangleAlert } from "lucide-react"; +import { PageBanner, Skeleton } from "@modelsphere/ui"; +import { Settings2 } from "lucide-react"; import { useModulePath } from "@/shell"; import { api, type ProfileResponse } from "@swiss/lib/api"; import { useT } from "@/modules/inferences/i18n"; import { ProfileEditorBody, SaveButton, useProfileEditor } from "@/modules/inferences/components/ProfileEditor"; +// The first run only: the gate sends a site that has a profile to the site +// profile page, so this always starts from swissd's default. export function SetupPage() { const t = useT(); - const existing = useQuery({ queryKey: ["profile"], queryFn: api.profile, retry: false }); - const template = useQuery({ queryKey: ["profile-template"], queryFn: api.profileTemplate, enabled: existing.isSuccess && !existing.data.profile }); - const start = existing.data?.profile ? existing.data : template.data; + const template = useQuery({ queryKey: ["profile-template"], queryFn: api.profileTemplate }); return (
} />
- {existing.data?.error && ( - - - {t("profile.parseError", { error: existing.data.error })} - - )} - {start?.profile ? : } + {template.data?.profile ? : }
); } -function Setup({ start, saveLabel }: { start: ProfileResponse; saveLabel: string }) { +function Setup({ start }: { start: ProfileResponse }) { + const t = useT(); const p = useModulePath(); const navigate = useNavigate(); const editor = useProfileEditor(start.profile!, start.yaml ?? "", () => navigate(p(""), { replace: true })); @@ -38,7 +33,7 @@ function Setup({ start, saveLabel }: { start: ProfileResponse; saveLabel: string <>
- +
); diff --git a/web/src/modules/inferences/SiteProfilePage.tsx b/web/src/modules/inferences/SiteProfilePage.tsx index 2bb69aa..8f01922 100644 --- a/web/src/modules/inferences/SiteProfilePage.tsx +++ b/web/src/modules/inferences/SiteProfilePage.tsx @@ -22,7 +22,7 @@ import { } from "@modelsphere/ui"; import { SlidersHorizontal, TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; -import { api, type ProfileResponse, type SiteProfile } from "@swiss/lib/api"; +import { api, type SiteProfile } from "@swiss/lib/api"; import { toYaml } from "@swiss/lib/yaml"; import { useT } from "@/modules/inferences/i18n"; import { ProfileEditorBody, SaveButton, useProfileEditor } from "@/modules/inferences/components/ProfileEditor"; @@ -31,10 +31,15 @@ export function SiteProfilePage() { const t = useT(); const p = useModulePath(); const profile = useQuery({ queryKey: ["profile"], queryFn: api.profile }); + // A stored profile that does not parse has nothing for the form to bind to: + // the form starts from the default, and the YAML tab holds the stored text, + // which is what has to be fixed. + const template = useQuery({ queryKey: ["profile-template"], queryFn: api.profileTemplate, enabled: !!profile.data && !profile.data.profile }); const [editing, setEditing] = useState(false); if (profile.data && !profile.data.profile && !profile.data.error) return ; const d = profile.data; + const start = d?.profile ?? template.data?.profile; return (
@@ -42,7 +47,7 @@ export function SiteProfilePage() { title={t("profile.title")} description={t("profile.description")} icon={} - actions={d && } + actions={start && } />
{profile.isPending ? ( @@ -57,7 +62,10 @@ export function SiteProfilePage() { {d?.error && ( - {t("profile.parseError", { error: d.error })} + +

{t("profile.parseError", { error: d.error })}

+

{t("profile.repairHint")}

+
)} {d?.source &&

{t("profile.source", { source: d.source })}

} @@ -65,7 +73,7 @@ export function SiteProfilePage() { )}
- {editing && d && setEditing(false)} />} + {editing && d && start && setEditing(false)} />}
); } @@ -75,6 +83,23 @@ function ProfileView({ p }: { p: SiteProfile }) { const f = (k: string) => t(`profile.fields.${k}`); const v = (x: unknown) => (x === undefined || x === null || x === "" ? {t("profile.unset")} : String(x)); const mono = (x?: string) => (x ? {x} : v(x)); + const aliases = (a?: Record) => + a && Object.keys(a).length ? ( + + {Object.entries(a).map(([product, sels]) => ( + + {product} + {sels.map((sel) => ( + + {sel} + + ))} + + ))} + + ) : ( + v(undefined) + ); const kv = (r?: Record) => (r && Object.keys(r).length ? {Object.entries(r).map(([k, x]) => {k}={x})} : v(undefined)); const card = (g: string, items: { label: string; value: React.ReactNode }[]) => ( @@ -89,6 +114,7 @@ function ProfileView({ p }: { p: SiteProfile }) { { label: f("scheduler"), value: v(p.schedule?.schedulerName) }, { label: f("priorityClass"), value: v(p.schedule?.priorityClassName) }, { label: f("gpusPerNode"), value: v(p.nodes?.gpusPerNode) }, + { label: f("gpuAliases"), value: aliases(p.nodes?.gpuProductAliases) }, ])} {card("catalog", [ { @@ -99,6 +125,13 @@ function ProfileView({ p }: { p: SiteProfile }) { {c.name} {c.default && {f("catalogDefault")}} + {c.schemaVersion === "v2" ? ( + v2 + ) : ( + + {t("catalog.legacy")} + + )} {c.url} ))} @@ -152,9 +185,9 @@ function ProfileView({ p }: { p: SiteProfile }) { ); } -function EditSheet({ data, onClose }: { data: ProfileResponse; onClose: () => void }) { +function EditSheet({ profile, yaml, onClose }: { profile: SiteProfile; yaml: string; onClose: () => void }) { const t = useT(); - const editor = useProfileEditor(data.profile!, data.yaml ?? "", onClose); + const editor = useProfileEditor(profile, yaml, onClose); return ( !o && onClose()} dirty={editor.dirty} onDiscard={onClose}> diff --git a/web/src/modules/inferences/catalog-lib.test.ts b/web/src/modules/inferences/catalog-lib.test.ts index 454ae75..83c0814 100644 --- a/web/src/modules/inferences/catalog-lib.test.ts +++ b/web/src/modules/inferences/catalog-lib.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { IndexModel, Node } from "@swiss/lib/api"; +import type { CatalogModel, Node } from "@swiss/lib/api"; import { facetsOf } from "@swiss/lib/catalog"; import { activeFilters, @@ -13,22 +13,15 @@ import { withoutFilters, } from "@/modules/inferences/catalog-lib"; -const model = (over: Partial): IndexModel => ({ +const model = (over: Partial): CatalogModel => ({ name: "qwen3-8b", family: "qwen", tags: ["chat"], source: { hf: "Qwen/Qwen3-8B" }, - latest: "1.0", - versions: [ - { - version: "1.0", - path: "p", - digest: "d", - variants: [ - { id: "h100x1", engine: "sglang", default: true, chart: { name: "sglang", version: "0.7.1" }, requires: { gpus: 1, gpuProduct: ["NVIDIA-H100-80GB-HBM3"] } }, - { id: "v100x2", engine: "vllm", chart: { name: "vllm", version: "0.6.2" }, requires: { gpus: 2 } }, - ], - }, + defaultVariant: "h100x1", + variants: [ + { id: "h100x1", version: "1.0", digest: "d", engine: "sglang", default: true, chart: { name: "sglang", version: "0.7.1" }, requires: { gpus: 1, gpuProduct: ["NVIDIA-H100-80GB-HBM3"] } }, + { id: "v100x2", version: "1.0", digest: "d", engine: "vllm", chart: { name: "vllm", version: "0.6.2" }, requires: { gpus: 2 } }, ], ...over, }); @@ -78,7 +71,7 @@ describe("facetOptions", () => { }); describe("fitness", () => { - const v = model({}).versions[0]!.variants[0]!; + const v = model({}).variants[0]!; it("is unknown without the node list", () => { expect(fitness(v, undefined)).toBeNull(); }); diff --git a/web/src/modules/inferences/catalog-lib.ts b/web/src/modules/inferences/catalog-lib.ts index cd67aba..42ec5ab 100644 --- a/web/src/modules/inferences/catalog-lib.ts +++ b/web/src/modules/inferences/catalog-lib.ts @@ -1,4 +1,4 @@ -import type { IndexVariant, Node } from "@swiss/lib/api"; +import type { ModelVariant, Node } from "@swiss/lib/api"; import type { Facets, SortKey } from "@swiss/lib/catalog"; import { matchesVendor } from "@swiss/lib/gpu"; @@ -92,7 +92,7 @@ export interface Fit { // Whether the cluster has nodes a variant can land on right now: schedulable, // enough GPUs of the right vendor and product, as many as it spans. -export function fitness(v: Pick, nodes?: Node[]): Fit | null { +export function fitness(v: Pick, nodes?: Node[]): Fit | null { if (!nodes) return null; const matching = nodes.filter( (n) => diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index 4d592e5..a4a4f42 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -24,19 +24,21 @@ import { useAnchorNav, useDirty, } from "@modelsphere/ui"; -import { ArrowRight, TriangleAlert } from "lucide-react"; +import { ArrowRight, FlaskConical, TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; -import { api, deployApi, type ApplyResult, type DiffResult, type IndexModel, type Plan } from "@swiss/lib/api"; +import { api, deployApi, type ApplyResult, type ApplyWith, type CatalogModel, type DiffResult, type Plan } from "@swiss/lib/api"; import { EMPTY, formFromPlan, imageOf, planRequest, type Form } from "@swiss/components/DeploySettings"; import { releaseCatalog } from "@swiss/components/CatalogChoice"; import { Provenance } from "@swiss/components/Provenance"; import { DiffView } from "@swiss/components/DiffView"; import { gpuShort } from "@swiss/lib/gpu"; import { isChartRange, movableCatalogs } from "@swiss/lib/upgrade"; +import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { useT } from "@/modules/inferences/i18n"; import { detailPath } from "@/modules/inferences/lib"; import { SECTIONS, chartChoice, defaultServiceId, gpuOptions, pipelineState, serviceIdError, whatMoves, type Section } from "@/modules/inferences/deploy-lib"; import { DeployForm } from "@/modules/inferences/components/DeployForm"; +import { ExperimentalBadge } from "@/modules/inferences/components/InstallMethodLabel"; import { readRouteOptions } from "@/modules/inferences/api"; export type DeployTarget = @@ -76,25 +78,35 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const [variant, setVariant] = useState(upgrade ? "" : (target.variant ?? "")); const [moveTo, setMoveTo] = useState(""); const [chartVersion, setChartVersion] = useState(""); + const [picked, setPicked] = useState(); const catalogName = moveTo || ownCatalog; const indexes = useQueries({ queries: catalogs.map((c) => ({ queryKey: ["catalog", c.name], queryFn: () => api.catalog(c.name), enabled: upgrade || c.name === ownCatalog })), }); - const modelIn = (name?: string): IndexModel | undefined => - indexes[catalogs.findIndex((c) => c.name === name)]?.data?.index.models.find((m) => m.name === modelName); + const modelIn = (name?: string): CatalogModel | undefined => + indexes[catalogs.findIndex((c) => c.name === name)]?.data?.models.find((m) => m.name === modelName); const { movable } = upgrade ? movableCatalogs(catalogs, recorded ?? {}, ownCatalog, (c) => modelIn(c)?.source.hf) : { movable: [] as string[] }; const im = modelIn(catalogName); - const versions = im?.versions.map((v) => v.version) ?? []; - const listed = im?.versions.find((v) => v.version === (version || (upgrade ? recorded?.version : undefined) || im.latest))?.variants ?? []; - const keptVariant = variant || (upgrade ? recorded?.variant : listed.find((v) => v.default)?.id ?? listed[0]?.id) || ""; + // The variants as listed now; a version is the chosen variant's, from its + // history: on v2 versions are per variant, on v1 the model's. + const listed = im?.variants ?? []; + const keptVariant = variant || (upgrade ? recorded?.variant : im?.defaultVariant ?? listed[0]?.id) || ""; + const latest = listed.find((x) => x.id === keptVariant)?.version; + const history = useQuery({ + queryKey: ["history", catalogName, modelName, keptVariant], + queryFn: () => api.history(modelName!, { catalog: catalogName, variant: keptVariant }), + enabled: !!modelName && !!catalogName && !!latest, + retry: false, + }); + const versions = history.data?.revisions.map((r) => r.version) ?? (latest ? [latest] : []); const entry = useQuery({ - queryKey: ["model", modelName, version, catalogName], - queryFn: () => api.model(modelName!, version || undefined, catalogName), + queryKey: ["model", catalogName, modelName, keptVariant, version], + queryFn: () => api.model(modelName!, { catalog: catalogName, variant: keptVariant || undefined, version: version || undefined }), enabled: !!modelName && !!catalogName, }); - const v = entry.data?.entry.variants.find((x) => x.id === keptVariant) ?? entry.data?.entry.variants[0]; + const v = entry.data?.variant; const chartRange = !!v && isChartRange(v.chart.version); const chartList = useQuery({ queryKey: ["chart-versions", catalogName, modelName, version, keptVariant], @@ -103,6 +115,9 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( retry: false, }); const chart = chartChoice(chartList, chartVersion, upgrade ? current.data?.chart : undefined); + const llm = cluster.data?.llmservice; + const block = llmsvcBlock(llm, entry.data?.schema); + const method = installMethod(picked, llm, entry.data?.schema); const initial = useMemo
(() => (upgrade ? (current.data ? formFromPlan(current.data) : EMPTY) : { ...EMPTY, serviceId: defaultServiceId(target.model) }), [upgrade, current.data, target]); const [form, setForm] = useState(initial); @@ -152,7 +167,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const dryRun = useMutation({ mutationFn: (pl: Plan) => deployApi.diff({ planHash: pl.hash }), onSuccess: setDiff }); const state = pipelineState({ diff, exists: live.data?.exists, applied: !!applied }); const apply = useMutation({ - mutationFn: () => (state.action === "install" ? deployApi.install(plan!.hash, note.trim()) : deployApi.apply(plan!.hash, diff?.revision, note.trim(), force)), + mutationFn: () => (state.action === "install" ? deployApi.install(plan!.hash, note.trim(), method) : deployApi.apply(plan!.hash, diff?.revision, note.trim(), force)), onSuccess: (r) => { setApplied(r); for (const key of REFRESH) qc.invalidateQueries({ queryKey: key }); @@ -195,26 +210,17 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( )} {!upgrade && ( - + )}
- - { - setVersion(x === KEEP || (!upgrade && x === im?.latest) ? "" : x); - setVariant(""); - setChartVersion(""); - }} - options={[...(upgrade ? [keepOption(t("deploy.fields.keep", { v: recorded?.version ?? "-" }))] : []), ...versions.map((x) => ({ value: x, label: x === im?.latest ? `${x} (${t("model.latest")})` : x }))]} - /> - { setVariant(x === KEEP ? "" : x); + // On v2 another variant has versions of its own. + setVersion(""); setChartVersion(""); }} options={[ @@ -223,6 +229,28 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( ]} /> + {/* Empty is the variant's latest, on an upgrade as on a deploy. */} + + { + setVersion(x === latest ? "" : x); + setChartVersion(""); + }} + options={versions.map((x) => ({ + value: x, + label: + x === latest + ? `${x} (${t("model.latest")})` + : upgrade && x === recorded?.version && keptVariant === recorded?.variant + ? `${x} (${t("deploy.fields.deployed")})` + : x, + }))} + /> +
{chartRange && ( )} + {!upgrade && ( + + x && setPicked(x as ApplyWith)} + options={(["helm", "llmsvc"] as const).map((m) => { + const isDefault = m === installMethod(undefined, llm, "v2"); + return { + value: m, + label: t(`deploy.fields.applyWithMethod.${m}`), + description: t(`deploy.fields.applyWithDesc.${m}`), + badge: + m === "llmsvc" ? ( + + + {isDefault && t("deploy.fields.applyWithDefault")} + + ) : isDefault ? ( + t("deploy.fields.applyWithDefault") + ) : undefined, + disabled: m === "llmsvc" && !!block, + }; + })} + /> + + )} + {/* The select shows only the label once closed, so the mark is repeated here. */} + {!upgrade && method === "llmsvc" && ( +

+ + {t("deploy.fields.applyWithExperimentalNote")} +

+ )} ); @@ -326,6 +387,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( onNote={setNote} force={force} onForce={setForce} + method={state.action === "install" ? method : live.data?.backend} /> ) )} @@ -369,6 +431,7 @@ export function Review({ onNote, force, onForce, + method, }: { upgrade: boolean; rollback?: boolean; @@ -383,6 +446,8 @@ export function Review({ onNote: (v: string) => void; force: boolean; onForce: (v: boolean) => void; + // The install method: the one picked on an install, the release's own otherwise. + method?: ApplyWith; }) { const t = useT(); const moves = current && (upgrade || rollback) ? whatMoves(current, plan, currentCatalog) : null; @@ -429,6 +494,12 @@ export function Review({ )} + {method && ( +

+ {t(state.action === "install" ? "deploy.review.applyWith" : "deploy.review.applyWithKept", { method: t(`deploy.fields.applyWithMethod.${method}`) })} + {method === "llmsvc" && } +

+ )} onNote(e.target.value)} rows={3} /> diff --git a/web/src/modules/inferences/components/InstallMethodLabel.tsx b/web/src/modules/inferences/components/InstallMethodLabel.tsx new file mode 100644 index 0000000..b05a451 --- /dev/null +++ b/web/src/modules/inferences/components/InstallMethodLabel.tsx @@ -0,0 +1,19 @@ +import { Badge } from "@modelsphere/ui"; +import type { ApplyWith } from "@swiss/lib/api"; +import { useT } from "@/modules/inferences/i18n"; + +export function ExperimentalBadge() { + const t = useT(); + return {t("deploy.fields.applyWithExperimental")}; +} + +// The install method's name, marked when it is still experimental. +export function InstallMethodLabel({ method }: { method: ApplyWith }) { + const t = useT(); + return ( + + {t(`deploy.fields.applyWithMethod.${method}`)} + {method === "llmsvc" && } + + ); +} diff --git a/web/src/modules/inferences/components/ProfileEditor.tsx b/web/src/modules/inferences/components/ProfileEditor.tsx index ca942d6..89aa4e8 100644 --- a/web/src/modules/inferences/components/ProfileEditor.tsx +++ b/web/src/modules/inferences/components/ProfileEditor.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Alert, AlertDescription, Button, FieldTextarea, FloatingField, Tabs, TabsList, TabsTrigger } from "@modelsphere/ui"; import { TriangleAlert } from "lucide-react"; import { api, type ProfileResponse, type SiteProfile } from "@swiss/lib/api"; +import { markSetUp } from "@swiss/components/Session"; import { useT } from "@/modules/inferences/i18n"; import { profileErrors } from "@/modules/inferences/profile-lib"; import { ProfileForm } from "@/modules/inferences/components/ProfileForm"; @@ -18,7 +19,8 @@ export function useProfileEditor(profile: SiteProfile, yaml: string, onSaved: (r const save = useMutation({ mutationFn: () => (mode === "form" ? api.saveProfile({ profile: form }) : api.saveProfile({ yaml: text })), onSuccess: (r) => { - for (const key of [["profile"], ["cluster"], ["catalog"], ["session"]]) qc.invalidateQueries({ queryKey: key }); + markSetUp(qc); + for (const key of [["profile"], ["cluster"], ["catalog"]]) qc.invalidateQueries({ queryKey: key }); onSaved(r); }, }); diff --git a/web/src/modules/inferences/components/ProfileForm.tsx b/web/src/modules/inferences/components/ProfileForm.tsx index 70dcff7..50123a7 100644 --- a/web/src/modules/inferences/components/ProfileForm.tsx +++ b/web/src/modules/inferences/components/ProfileForm.tsx @@ -1,9 +1,12 @@ import { useState, type ReactNode } from "react"; -import { Button, Checkbox, FieldHint, FieldInput, FloatingField, FormSection, Input, Switch } from "@modelsphere/ui"; +import { Button, Checkbox, FieldHint, FieldInput, FieldSelect, FloatingField, FormSection, Input, Switch } from "@modelsphere/ui"; import { Plus, X } from "lucide-react"; import type { RouteAuth, SiteProfile } from "@swiss/lib/api"; import { useT } from "@/modules/inferences/i18n"; -import { fromRows, markDefault, toRows, type Row } from "@/modules/inferences/profile-lib"; +import { fromRows, markDefault, toRows, withNodes, type Row } from "@/modules/inferences/profile-lib"; +import { aliasErrors, aliasMap, aliasRows, catalogErrors, type AliasError, type AliasRow, type CatalogError } from "@swiss/lib/profile"; + +type CatalogRepo = NonNullable[number]; const GROUPS = ["placement", "catalog", "model", "routing", "auth", "scaler", "sites"] as const; @@ -16,6 +19,8 @@ export function ProfileForm({ value: p, onChange, submitted, collapsible }: { va const auth = (patch: RouteAuth) => route({ auth: { ...p.route?.auth, ...patch } }); const scaler = (patch: NonNullable) => set({ scaler: { ...p.scaler, ...patch } }); const str = (v: string) => v || undefined; + const catalogs = p.catalogs ?? []; + const catalogErrs = catalogErrors(catalogs); const group = (g: (typeof GROUPS)[number], children: ReactNode) => ( set({ schedule: { ...p.schedule, schedulerName: str(v) } }), { placeholder: f("chartDefault") })} {input(f("priorityClass"), p.schedule?.priorityClassName, (v) => set({ schedule: { ...p.schedule, priorityClassName: str(v) } }), { placeholder: f("chartDefault") })} - {input(f("gpusPerNode"), p.nodes?.gpusPerNode, (v) => set({ nodes: v.replace(/\D/g, "") ? { gpusPerNode: Number(v.replace(/\D/g, "")) } : undefined }), { hint: f("gpusPerNodeHint") })} + {input(f("gpusPerNode"), p.nodes?.gpusPerNode, (v) => set({ nodes: withNodes(p.nodes, { gpusPerNode: v.replace(/\D/g, "") ? Number(v.replace(/\D/g, "")) : undefined }) }), { hint: f("gpusPerNodeHint") })} + set({ nodes: withNodes(p.nodes, { gpuProductAliases: a }) })} /> , )} @@ -51,18 +57,17 @@ export function ProfileForm({ value: p, onChange, submitted, collapsible }: { va "catalog", <> - {(p.catalogs ?? []).map((c, i) => ( -
- set({ catalogs: p.catalogs!.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })} placeholder={f("catalogName")} aria-label={f("catalogName")} /> - set({ catalogs: p.catalogs!.map((x, j) => (j === i ? { ...x, url: e.target.value } : x)) })} placeholder={f("catalogUrl")} aria-label={f("catalogUrl")} className="font-mono text-xs" /> - - set({ catalogs: p.catalogs!.filter((_, j) => j !== i) })} /> -
+ {catalogs.map((c, i) => ( + set({ catalogs: catalogs.map((x, j) => (j === i ? { ...x, ...patch } : x)) })} + onDefault={(on) => set({ catalogs: markDefault(catalogs, i, on) })} + onRemove={() => set({ catalogs: catalogs.filter((_, j) => j !== i) })} + /> ))} - set({ catalogs: [...(p.catalogs ?? []), { name: "", url: "" }] })} /> + set({ catalogs: [...catalogs, { name: "", url: "", schemaVersion: "v2" }] })} />
{input(f("chartRepo"), p.chartRepo, (v) => set({ chartRepo: str(v) }), { hint: f("chartRepoHint"), placeholder: "https://modelsphere.github.io/helm-charts", mono: true })} {input(f("chartPath"), p.chartPath, (v) => set({ chartPath: str(v) }), { placeholder: f("chartPathHint"), mono: true })} @@ -155,6 +160,92 @@ export function ProfileForm({ value: p, onChange, submitted, collapsible }: { va ); } +function CatalogRow({ + value: c, + errors, + onChange, + onDefault, + onRemove, +}: { + value: CatalogRepo; + errors: { name?: CatalogError; url?: CatalogError }; + onChange: (patch: Partial) => void; + onDefault: (on: boolean) => void; + onRemove: () => void; +}) { + const t = useT(); + const f = (k: string) => t(`profile.fields.${k}`); + const err = (field: "name" | "url", e?: CatalogError) => e && t(`profile.errors.catalog.${field}.${e}`); + return ( +
+ + onChange({ name: e.target.value })} placeholder="public" /> + + + onChange({ url: e.target.value })} placeholder="https://modelsphere.github.io/model-catalog" className="font-mono text-xs" /> + + {/* Unset is v1, so v1 is written as unset. */} + + v && onChange({ schemaVersion: v === "v2" ? "v2" : undefined })} + options={[ + { value: "v2", label: f("catalogSchemaV2") }, + { value: "v1", label: f("catalogSchemaV1") }, + ]} + /> + +
+ + + + +
+
+ ); +} + +function GpuAliases({ value, submitted, onChange }: { value?: Record; submitted: boolean; onChange: (v?: Record) => void }) { + const t = useT(); + const f = (k: string) => t(`profile.fields.${k}`); + // Rows live here while typed; see aliasRows. + const [rows, setRows] = useState(() => aliasRows(value)); + const errs = aliasErrors(rows); + const err = (field: "product" | "selector", e?: AliasError) => (submitted && e ? t(`profile.errors.gpuAlias.${field}.${e}`) : undefined); + const update = (next: AliasRow[]) => { + setRows(next); + onChange(aliasMap(next)); + }; + const edit = (i: number, patch: Partial) => update(rows.map((r, j) => (j === i ? { ...r, ...patch } : r))); + return ( + + {/* An error line hangs below its field, so rows showing one need the room. */} +
e.product || e.selector) ? "sm:gap-5" : "sm:gap-3"}`}> + {rows.map((r, i) => ( + // Narrow, the remove button sits beside the product and the labels take the next line. +
+ + edit(i, { product: e.target.value })} placeholder="Ascend-910B3" /> + + + update(rows.filter((_, j) => j !== i))} /> + +
+ + edit(i, { selector: e.target.value })} placeholder="accelerator=huawei-Ascend910" className="font-mono text-xs" /> + +
+
+ ))} +
+ setRows([...rows, { product: "", selector: "" }])} /> +
+ ); +} + function KV({ label, hint, value, onChange }: { label: string; hint?: string; value?: Record; onChange: (v?: Record) => void }) { const t = useT(); // Rows live here while typed: a row with no key yet is not in the profile. diff --git a/web/src/modules/inferences/dashboard-lib.test.ts b/web/src/modules/inferences/dashboard-lib.test.ts index b9b9895..aabf526 100644 --- a/web/src/modules/inferences/dashboard-lib.test.ts +++ b/web/src/modules/inferences/dashboard-lib.test.ts @@ -19,54 +19,37 @@ const catalog: CatalogResponse = { name: "default", ref: "main", source: "https://catalog.example", - index: { - apiVersion: "catalog.swiss/v1", - count: 1, - models: [ - { - name: "alpha", - source: { hf: "org/alpha" }, - latest: "2", - versions: [ - { - version: "1", - path: "alpha-1.yaml", - digest: "old", - variants: [{ id: "base", engine: "vllm", chart: { name: "vllm", version: "1" }, requires: { gpus: 1 } }], - }, - { - version: "2", - path: "alpha-2.yaml", - digest: "new", - variants: [ - { id: "base", engine: "vllm", chart: { name: "vllm", version: "2" }, requires: { gpus: 1 } }, - { id: "tuned", engine: "sglang", chart: { name: "sglang", version: "2" }, requires: { gpus: 2 } }, - ], - }, - ], - }, - ], - }, + schema: "v2", + models: [ + { + name: "alpha", + source: { hf: "org/alpha" }, + defaultVariant: "base", + variants: [ + { id: "base", version: "2", digest: "b2", engine: "vllm", chart: { name: "vllm", version: "2" }, requires: { gpus: 1 } }, + { id: "tuned", version: "1", digest: "t1", engine: "sglang", chart: { name: "sglang", version: "2" }, requires: { gpus: 2 } }, + ], + }, + ], }; describe("supported catalog totals", () => { - it("counts every supported version recipe, not a sample or only the latest", () => - expect(supportedTotals([catalog])).toEqual({ models: 1, recipes: 3 })); + it("counts every variant at the pin its catalog lists now", () => + expect(supportedTotals([catalog])).toEqual({ models: 1, recipes: 2 })); it("does not double-count mirrored catalog entries", () => - expect(supportedTotals([catalog, { ...catalog, name: "mirror" }])).toEqual({ models: 1, recipes: 3 })); + expect(supportedTotals([catalog, { ...catalog, name: "mirror" }])).toEqual({ models: 1, recipes: 2 })); it("does not count deprecated models as current support", () => { - const old = { - ...catalog, - index: { ...catalog.index, models: catalog.index.models.map((m) => ({ ...m, deprecated: "retired" })) }, - }; + const old = { ...catalog, models: catalog.models.map((m) => ({ ...m, deprecated: "retired" })) }; expect(supportedTotals([old])).toEqual({ models: 0, recipes: 0 }); }); - it("distinguishes different model sources and recipe revisions", () => { - const other = { + it("distinguishes different model sources and recipe pins", () => { + const other = { ...catalog, models: catalog.models.map((m) => ({ ...m, source: { hf: "other/alpha" } })) }; + const moved = { ...catalog, - index: { ...catalog.index, models: catalog.index.models.map((m) => ({ ...m, source: { hf: "other/alpha" } })) }, + models: catalog.models.map((m) => ({ ...m, variants: m.variants.map((v) => ({ ...v, version: "3", digest: "x" })) })), }; - expect(supportedTotals([catalog, other])).toEqual({ models: 2, recipes: 6 }); + expect(supportedTotals([catalog, other])).toEqual({ models: 2, recipes: 4 }); + expect(supportedTotals([catalog, moved])).toEqual({ models: 1, recipes: 4 }); }); }); diff --git a/web/src/modules/inferences/dashboard-lib.ts b/web/src/modules/inferences/dashboard-lib.ts index 203d531..e777f03 100644 --- a/web/src/modules/inferences/dashboard-lib.ts +++ b/web/src/modules/inferences/dashboard-lib.ts @@ -19,18 +19,19 @@ export interface DeploymentTimeline { complete: boolean; } +// A recipe is a variant at the pin its catalog lists now. Older versions are +// left out: a v2 index lists only current pins, and counting history would +// cost a fetch per variant. export function supportedTotals(catalogs: CatalogResponse[]): { models: number; recipes: number } { const models = new Set(); const recipes = new Set(); for (const catalog of catalogs) { - for (const model of catalog.index.models) { + for (const model of catalog.models) { if (model.deprecated) continue; const identity = [model.name, model.source.hf, model.source.revision ?? ""]; models.add(JSON.stringify(identity)); - for (const version of model.versions) { - for (const variant of version.variants) { - recipes.add(JSON.stringify([...identity, version.version, version.digest, variant.id])); - } + for (const variant of model.variants) { + recipes.add(JSON.stringify([...identity, variant.id, variant.version, variant.digest])); } } } diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 0bc65d6..5cffd27 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -278,6 +278,8 @@ "noCatalog": "No model catalog is configured.", "noCatalogAction": "Add one in the site profile", "choose": "Several catalogs are configured. Pick one:", + "legacy": "v1 legacy", + "legacyHint": "Catalog schema v1 is the read-only legacy index, and what a catalog with no schemaVersion reads. Set schemaVersion: v2 on it in the site profile.", "default": "Default" }, "model": { @@ -293,12 +295,14 @@ "license": "License", "version": "Version", "digest": "Digest", + "schema": "Schema", "tags": "Tags", "size": "{n} GiB", "sizeLabel": "Size" }, "version": "Version", "latest": "latest", + "perVariant": "per variant", "default": "Default", "optimized": "Tuned", "baseline": "Baseline", @@ -401,7 +405,28 @@ "catalog": "Catalog", "version": "Model version", "variant": "Variant", + "variantVersion": "Variant version", + "versionError": "Could not list versions: {error}", + "deployed": "deployed", "chartVersion": "Chart version", + "applyWith": "Install method", + "applyWithHint": "Fixed at install: upgrades and rollbacks stay on it", + "applyWithDefault": "default", + "applyWithExperimental": "Experimental", + "applyWithExperimentalNote": "LLMService is experimental", + "applyWithMethod": { + "helm": "Plan ConfigMap", + "llmsvc": "LLMService" + }, + "applyWithDesc": { + "helm": "swissd runs helm; the plan sits in a ConfigMap beside the release", + "llmsvc": "llm-operator runs helm from an LLMService swissd writes" + }, + "applyWithBlocked": { + "unserved": "LLMService unavailable: the CRD is not served on this cluster", + "unavailable": "LLMService unavailable: swissd cannot use it here; see the cluster warnings", + "schema": "LLMService unavailable: it needs a catalog schema v2 model" + }, "serviceId": "Service name", "serviceIdHint": "Names the helm release, the route, the scaler and the SLO", "serviceIdLocked": "Fixed on an upgrade: the route, the scaler and the SLO are named after it", @@ -503,6 +528,8 @@ "noteHint": "Why this is done; kept with the run and beside the release", "force": "Force conflicting fields", "forceHint": "Takes over fields helm sees owned by someone else, for this apply only", + "applyWith": "Installs with {method}", + "applyWithKept": "Stays on {method}: an existing release keeps its install method", "back": "Back to edit", "compose": "Compose plan", "composing": "Composing…", @@ -618,6 +645,7 @@ "saving": "Saving…", "unset": "Not set", "parseError": "The site profile does not parse: {error}", + "repairHint": "Open Edit and fix it in the YAML tab, which holds the stored document. The form starts from the default, and saving from it replaces the whole profile.", "groups": { "placement": "Placement", "catalog": "Catalogs, charts and images", @@ -645,13 +673,21 @@ "priorityClass": "Priority class", "gpusPerNode": "GPUs per node", "gpusPerNodeHint": "A full GPU node, used to derive cache.maxSlotsPerNode", + "gpuAliases": "GPU product aliases", + "gpuAliasesHint": "For nodes a vendor install does not label with the catalog GPU product name. Each row names a catalog product and a node label selector; every key=value must match. A node matching two products takes the one with more pairs", + "gpuAliasProduct": "Catalog GPU product", + "gpuAliasSelector": "Node labels", + "gpuAliasAdd": "Add alias", "chartDefault": "the chart's default", "catalogs": "Catalogs", - "catalogsHint": "Each an https base or an absolute path, under a name. With several, pages open in the default one. Set here they win over swissd's config file", + "catalogsHint": "Each an https base or an absolute path, under a name. With several, pages open in the default one. Set here they win over swissd's config file. Schema v2 is recommended; v1 is the read-only legacy index, and what a catalog with no schema set reads", "catalogName": "Name", "catalogUrl": "URL", "catalogDefault": "Default", "catalogAdd": "Add catalog", + "catalogSchema": "Schema", + "catalogSchemaV1": "v1 · legacy", + "catalogSchemaV2": "v2 · recommended", "chartRepo": "Chart repo", "chartRepoHint": "An https helm repo or oci:// registry, e.g. https://modelsphere.github.io/helm-charts; empty means a local chart path", "chartPath": "Chart path", @@ -701,12 +737,33 @@ "setup": { "title": "Set up this site", "description": "This swissd has no site profile yet. Describe the cluster first: the model catalog, the chart repo, the model paths. Deploying starts once it is saved.", - "save": "Save and start", - "saveExisting": "Save profile" + "save": "Save and start" }, "errors": { "name": "The cluster name is required", - "pathTemplate": "The path template is required" + "pathTemplate": "The path template is required", + "catalog": { + "name": { + "required": "Name is required", + "format": "Only lowercase letters, digits, . - _", + "duplicate": "Another catalog has this name" + }, + "url": { + "required": "URL is required", + "format": "An http:// or https:// URL, or an absolute path", + "duplicate": "Already listed with this schema" + } + }, + "gpuAlias": { + "product": { + "required": "Product is required" + }, + "selector": { + "required": "Labels are required", + "format": "key=value pairs, comma separated", + "conflict": "A node could match another product as well; add a label" + } + } } }, "events": { diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index cab3e6e..b18876b 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -267,6 +267,8 @@ "noCatalog": "还没有配置模型目录。", "noCatalogAction": "去站点配置添加", "choose": "已配置多个目录,请选择一个:", + "legacy": "v1 旧版", + "legacyHint": "目录 schema v1 是只读的旧版索引,未设置 schemaVersion 的目录按 v1 读取。请在站点配置里把它设为 schemaVersion: v2。", "default": "默认" }, "model": { @@ -282,12 +284,14 @@ "license": "许可证", "version": "版本", "digest": "Digest", + "schema": "Schema", "tags": "标签", "size": "{n} GiB", "sizeLabel": "大小" }, "version": "版本", "latest": "最新", + "perVariant": "按变体", "default": "默认", "optimized": "调优", "baseline": "基线", @@ -390,7 +394,28 @@ "catalog": "目录", "version": "模型版本", "variant": "变体", + "variantVersion": "变体版本", + "versionError": "无法列出版本:{error}", + "deployed": "已部署", "chartVersion": "Chart 版本", + "applyWith": "安装方式", + "applyWithHint": "安装时确定:之后的升级和回滚都沿用", + "applyWithDefault": "默认", + "applyWithExperimental": "实验性", + "applyWithExperimentalNote": "LLMService 为实验性功能", + "applyWithMethod": { + "helm": "Plan ConfigMap", + "llmsvc": "LLMService" + }, + "applyWithDesc": { + "helm": "swissd 执行 helm,计划存放在 release 旁的 ConfigMap", + "llmsvc": "swissd 写入 LLMService,由 llm-operator 执行 helm" + }, + "applyWithBlocked": { + "unserved": "LLMService 不可用:集群未提供该 CRD", + "unavailable": "LLMService 不可用:swissd 无法使用,见集群告警", + "schema": "LLMService 不可用:需要 v2 schema 目录中的模型" + }, "serviceId": "服务名", "serviceIdHint": "作为 helm release、路由、伸缩器和 SLO 的名字", "serviceIdLocked": "升级时不能改:路由、伸缩器和 SLO 都以它命名", @@ -492,6 +517,8 @@ "noteHint": "为什么做这次变更;会记入操作记录并写在 release 旁", "force": "强制接管冲突字段", "forceHint": "helm 字段归属冲突时强制覆盖,只对这一次生效", + "applyWith": "安装方式:{method}", + "applyWithKept": "沿用 {method}:已有 release 不改变安装方式", "back": "返回修改", "compose": "生成计划", "composing": "生成中…", @@ -607,6 +634,7 @@ "saving": "保存中…", "unset": "未设置", "parseError": "站点配置解析失败:{error}", + "repairHint": "点击编辑,在 YAML 标签页中修正已存储的文档。表单从默认配置开始,从表单保存会替换整个配置。", "groups": { "placement": "调度", "catalog": "目录、Chart 与镜像", @@ -634,13 +662,21 @@ "priorityClass": "优先级类", "gpusPerNode": "每节点 GPU 数", "gpusPerNodeHint": "一个满配 GPU 节点的卡数,用来推算 cache.maxSlotsPerNode", + "gpuAliases": "GPU 型号别名", + "gpuAliasesHint": "用于厂商安装未按模型库 GPU 型号名打标签的节点。每行填写一个模型库 GPU 型号和一个节点标签选择器,所有 key=value 都须匹配。同时匹配两个型号的节点取匹配对数更多的那个", + "gpuAliasProduct": "模型库 GPU 型号", + "gpuAliasSelector": "节点标签", + "gpuAliasAdd": "添加别名", "chartDefault": "chart 默认值", "catalogs": "模型目录", - "catalogsHint": "每项是 https 地址或绝对路径,以名字区分。配置多个时页面默认打开标记为默认的那个;这里的设置优先于 swissd 的配置文件", + "catalogsHint": "每项是 https 地址或绝对路径,以名字区分。配置多个时页面默认打开标记为默认的那个;这里的设置优先于 swissd 的配置文件。推荐 schema v2;v1 是只读的旧版索引,未设置 schema 时按 v1 读取", "catalogName": "名称", "catalogUrl": "地址", "catalogDefault": "默认", "catalogAdd": "添加目录", + "catalogSchema": "Schema", + "catalogSchemaV1": "v1 · 旧版", + "catalogSchemaV2": "v2 · 推荐", "chartRepo": "Chart 仓库", "chartRepoHint": "https 的 helm 仓库或 oci:// 仓库,例如 https://modelsphere.github.io/helm-charts;留空表示使用本地 chart 路径", "chartPath": "Chart 路径", @@ -690,12 +726,33 @@ "setup": { "title": "初始化站点", "description": "这个 swissd 还没有站点配置。先说明这个集群的情况:模型目录、chart 仓库、模型路径。保存后就可以开始部署。", - "save": "保存并开始", - "saveExisting": "保存配置" + "save": "保存并开始" }, "errors": { "name": "集群名必填", - "pathTemplate": "路径模板必填" + "pathTemplate": "路径模板必填", + "catalog": { + "name": { + "required": "名称必填", + "format": "仅限小写字母、数字和 . - _", + "duplicate": "名称与其他目录重复" + }, + "url": { + "required": "地址必填", + "format": "http:// 或 https:// 地址,或绝对路径", + "duplicate": "相同 schema 下已有此地址" + } + }, + "gpuAlias": { + "product": { + "required": "请填写型号" + }, + "selector": { + "required": "请填写标签", + "format": "逗号分隔的 key=value", + "conflict": "节点可能同时匹配另一个型号,请再加一个标签" + } + } } }, "events": { diff --git a/web/src/modules/inferences/profile-lib.test.ts b/web/src/modules/inferences/profile-lib.test.ts index 996da2f..9ca21a1 100644 --- a/web/src/modules/inferences/profile-lib.test.ts +++ b/web/src/modules/inferences/profile-lib.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { SiteProfile } from "@swiss/lib/api"; -import { fromRows, markDefault, profileErrors, toRows } from "@/modules/inferences/profile-lib"; +import { fromRows, markDefault, profileErrors, toRows, withNodes } from "@/modules/inferences/profile-lib"; describe("key-value rows", () => { it("round-trips a table and drops rows without a key", () => { @@ -25,3 +25,28 @@ describe("markDefault", () => { expect(markDefault(cs, 0, false).map((c) => c.default)).toEqual([false, undefined]); }); }); + +describe("profileErrors with catalogs", () => { + it("blocks the save on a bad catalog row", () => { + expect(profileErrors({ name: "x", model: { pathTemplate: "/m" }, catalogs: [{ name: "", url: "/c" }] } as SiteProfile)).toEqual(["catalogs"]); + }); +}); + +describe("nodes", () => { + const aliases = { "Ascend-910B3": ["accelerator=huawei-Ascend910"] }; + + it("keeps the aliases when GPUs per node is edited, and the reverse", () => { + expect(withNodes({ gpusPerNode: 8, gpuProductAliases: aliases }, { gpusPerNode: 16 })).toEqual({ gpusPerNode: 16, gpuProductAliases: aliases }); + expect(withNodes({ gpusPerNode: 8, gpuProductAliases: aliases }, { gpusPerNode: undefined })).toMatchObject({ gpuProductAliases: aliases }); + expect(withNodes({ gpusPerNode: 8 }, { gpuProductAliases: aliases })).toEqual({ gpusPerNode: 8, gpuProductAliases: aliases }); + expect(withNodes({ gpusPerNode: 8 }, { gpusPerNode: undefined })).toBeUndefined(); + }); + + it("blocks the save on a bad alias", () => { + const p: SiteProfile = { name: "c", model: { pathTemplate: "/m" } }; + expect(profileErrors({ ...p, nodes: { gpuProductAliases: aliases } })).toEqual([]); + expect(profileErrors({ ...p, nodes: { gpuProductAliases: { A: ["accelerator"] } } })).toEqual(["gpuAliases"]); + expect(profileErrors({ ...p, nodes: { gpuProductAliases: { A: ["a=1"], B: ["b=1"] } } })).toEqual(["gpuAliases"]); + }); +}); + diff --git a/web/src/modules/inferences/profile-lib.ts b/web/src/modules/inferences/profile-lib.ts index 94dddb9..daef887 100644 --- a/web/src/modules/inferences/profile-lib.ts +++ b/web/src/modules/inferences/profile-lib.ts @@ -1,4 +1,5 @@ import type { SiteProfile } from "@swiss/lib/api"; +import { aliasErrors, aliasRows, catalogErrors } from "@swiss/lib/profile"; export interface Row { key: string; @@ -16,13 +17,22 @@ export function fromRows(rows: Row[]): Record | undefined { return kept.length ? Object.fromEntries(kept.map((r) => [r.key.trim(), r.value])) : undefined; } -export function profileErrors(p: SiteProfile): ("name" | "pathTemplate")[] { - const errs: ("name" | "pathTemplate")[] = []; +export function profileErrors(p: SiteProfile): ("name" | "pathTemplate" | "catalogs" | "gpuAliases")[] { + const errs: ("name" | "pathTemplate" | "catalogs" | "gpuAliases")[] = []; if (!p.name?.trim()) errs.push("name"); if (!p.model?.pathTemplate?.trim()) errs.push("pathTemplate"); + if (catalogErrors(p.catalogs).some((e) => e.name || e.url)) errs.push("catalogs"); + if (aliasErrors(aliasRows(p.nodes?.gpuProductAliases)).some((e) => e.product || e.selector)) errs.push("gpuAliases"); return errs; } +// nodes with one field replaced: the others ride along, and nothing set is no +// nodes section at all. +export function withNodes(nodes: SiteProfile["nodes"], patch: NonNullable): SiteProfile["nodes"] { + const next = { ...nodes, ...patch }; + return next.gpusPerNode === undefined && !next.gpuProductAliases ? undefined : next; +} + // One catalog marked default at most: marking one clears the others. export function markDefault(catalogs: NonNullable, index: number, on: boolean) { return catalogs.map((c, i) => ({ ...c, default: i === index ? on : on ? false : c.default })); diff --git a/web/src/modules/inferences/tabs/Overview.tsx b/web/src/modules/inferences/tabs/Overview.tsx index d8556fa..c13cbd2 100644 --- a/web/src/modules/inferences/tabs/Overview.tsx +++ b/web/src/modules/inferences/tabs/Overview.tsx @@ -18,6 +18,7 @@ import { AccessPoint } from "@swiss/components/Endpoint"; import { useT } from "@/modules/inferences/i18n"; import { installSteps, modelLine, releaseState, type Step, type StepState } from "@/modules/inferences/lib"; import { StatusDot } from "@/modules/inferences/components/StatusDot"; +import { InstallMethodLabel } from "@/modules/inferences/components/InstallMethodLabel"; import { LoadHint } from "@/modules/inferences/components/LoadHint"; import { EventRow, useReleaseEvents } from "@/modules/inferences/tabs/Events"; @@ -87,6 +88,7 @@ export function Overview({ { label: t("detail.fields.catalog"), value: catalog }, { label: t("detail.fields.chart"), value: plan && `${plan.chart.name}-${plan.chart.version}` }, { label: t("detail.fields.revision"), value: s.exists ? String(s.revision) : undefined }, + { label: t("deploy.fields.applyWith"), value: s.backend && }, { label: t("detail.fields.route"), value: s.route && /{s.route}/ }, { label: t("detail.fields.namespace"), value: namespace }, { label: t("detail.fields.updated"), value: updated && formatDateTime(updated) }, diff --git a/web/src/modules/swiss/components/CatalogChoice.tsx b/web/src/modules/swiss/components/CatalogChoice.tsx index 4c4175e..ec63b17 100644 --- a/web/src/modules/swiss/components/CatalogChoice.tsx +++ b/web/src/modules/swiss/components/CatalogChoice.tsx @@ -1,7 +1,7 @@ import { Link, useSearchParams } from "@swiss/lib/host"; import { useQuery } from "@tanstack/react-query"; import { Library } from "lucide-react"; -import { api, type CatalogInfo } from "@swiss/lib/api"; +import { api, type CatalogInfo, type CatalogSchema } from "@swiss/lib/api"; import { Badge } from "@swiss/components/ui/badge"; import { Empty } from "@swiss/components/States"; @@ -101,6 +101,7 @@ export function CatalogGate({ {c.name} {c.default && default} + {c.url} {!c.ref && could not be read right now} @@ -134,7 +135,9 @@ export function CatalogSwitch({ > {catalogs.map((c) => ( ))} @@ -154,3 +157,17 @@ export function CatalogBadge({ name, show }: { name: string; show: boolean }) { ); } + +// v1 is marked: the legacy schema, read-only. v2 is what a catalog should be +// set to, so it needs no badge. +export function SchemaBadge({ schema }: { schema?: CatalogSchema }) { + if (schema !== "v1") return null; + return ( + + v1 legacy + + ); +} diff --git a/web/src/modules/swiss/components/CatalogModelDiff.test.ts b/web/src/modules/swiss/components/CatalogModelDiff.test.ts index ae7dddb..8f4ca1f 100644 --- a/web/src/modules/swiss/components/CatalogModelDiff.test.ts +++ b/web/src/modules/swiss/components/CatalogModelDiff.test.ts @@ -1,37 +1,27 @@ import { describe, expect, it } from "vitest"; -import type { Entry } from "@swiss/lib/api"; +import type { Variant } from "@swiss/lib/api"; import { catalogValues, lineDiff, modelText } from "./CatalogModelDiff"; -const entry = { - apiVersion: "catalog.swiss/v1", - name: "m", +const variant = { + id: "v", version: "1.0.0", + digest: "sha256:def", servedName: "served", - source: { hf: "org/m" }, - variants: [ - { - id: "v", - engine: "sglang", - chart: { name: "sglang", version: "0.8.0" }, - requires: { gpus: 2 }, - image: { repository: "repo", tag: "t", digest: "sha256:abc" }, - values: { extraArgs: ["--tp-size=2"], model: { name: "from-values" } }, - }, - ], -} as Entry; + engine: "sglang", + chart: { name: "sglang", version: "0.8.0" }, + requires: { gpus: 2 }, + image: { repository: "repo", tag: "t", digest: "sha256:abc" }, + values: { extraArgs: ["--tp-size=2"], model: { name: "from-values" } }, +} as Variant; describe("catalogValues", () => { - it("projects a variant the way the catalog layer does", () => { - expect(catalogValues(entry, "v").tree).toEqual({ + it("projects a variant the way the catalog layer does, image digest left out", () => { + expect(catalogValues(variant).tree).toEqual({ extraArgs: ["--tp-size=2"], model: { name: "served", gpus: "2" }, - image: { repository: "repo", tag: "t", digest: "sha256:abc" }, + image: { repository: "repo", tag: "t" }, }); }); - - it("is empty when the variant is no longer listed", () => { - expect(catalogValues(entry, "missing")).toEqual({}); - }); }); describe("modelText", () => { diff --git a/web/src/modules/swiss/components/CatalogModelDiff.tsx b/web/src/modules/swiss/components/CatalogModelDiff.tsx index a8519df..b8210e4 100644 --- a/web/src/modules/swiss/components/CatalogModelDiff.tsx +++ b/web/src/modules/swiss/components/CatalogModelDiff.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { api, type Entry } from "@swiss/lib/api"; +import { api, type Variant } from "@swiss/lib/api"; import { toYaml } from "@swiss/lib/yaml"; import { Badge } from "@swiss/components/ui/badge"; import { Dialog } from "@swiss/components/ui/dialog"; @@ -34,15 +34,15 @@ export function CatalogModelDiff({ }) { const ready = !!model && !!version && !!variant; const entry = useQuery({ - queryKey: ["catalog-model", catalog, model, version], - queryFn: () => api.model(model!, version, catalog), + queryKey: ["catalog-model", catalog, model, variant, version], + queryFn: () => api.model(model!, { catalog, variant, version }), enabled: open && ready, retry: false, staleTime: Infinity, }); - const published = entry.data ? catalogValues(entry.data.entry, variant!) : undefined; - const publishedVariant = entry.data?.entry.variants.find((item) => item.id === variant); + const publishedVariant = entry.data?.variant; + const published = publishedVariant ? catalogValues(publishedVariant) : undefined; const diff = published?.tree && publishedVariant ? lineDiff( @@ -71,10 +71,6 @@ export function CatalogModelDiff({ ) : published?.error ? (

{published.error}

- ) : published && !published.tree ? ( -

- The catalog no longer lists variant {variant} on version {version}. -

) : (
@@ -97,14 +93,11 @@ export function CatalogModelDiff({ ); } -export function catalogValues( - entry: Entry, - variantId: string, -): { tree?: Record; error?: string } { - const variant = entry.variants.find((item) => item.id === variantId); - if (!variant) return {}; +// The catalog layer compose writes for a variant. A variant's image.digest stays +// out, as it does there: the charts' schema rejects it. +export function catalogValues(variant: Variant): { tree?: Record; error?: string } { const tree: Record = structuredClone(variant.values ?? {}); - const name = setPath(tree, "model.name", entry.servedName || entry.name); + const name = setPath(tree, "model.name", variant.servedName); if (name) return { error: name }; const gpus = setPath(tree, "model.gpus", String(variant.requires.gpus)); if (gpus) return { error: gpus }; @@ -112,7 +105,6 @@ export function catalogValues( for (const [key, value] of [ ["image.repository", variant.image.repository], ["image.tag", variant.image.tag], - ["image.digest", variant.image.digest], ] as const) { if (!value) continue; const err = setPath(tree, key, value); diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 489e6b8..915f1b7 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { ChevronDown, Plus, X } from "lucide-react"; +import { ChevronDown, Plus, TriangleAlert, X } from "lucide-react"; import type { ClusterInfo, Plan, PlanRequest, Variant } from "@swiss/lib/api"; import { cn } from "@swiss/lib/utils"; +import { productChoices, unlistedProducts } from "@swiss/lib/gpu"; import { DIRECTIVES, DIRECTIVES_BY_KEY, @@ -137,8 +138,9 @@ export function DeploySettings({ image?: { catalog: string; site: string }; lockIdentity?: boolean; readOnly?: boolean; - // Products the chosen variant declares, and those a node in this cluster - // actually reports. The intersection is what is worth offering. + // Products the chosen variant declares, and those this cluster reports on + // nodes of the variant's vendor. Every one of the second is offered; one the + // first does not name is marked, and selecting it is a forced deploy. supportedGPUs?: string[]; clusterGPUs?: string[]; }) { @@ -621,7 +623,8 @@ export function DeploySettings({ onChange({ gpuProducts: v })} - options={gpuOptions(supportedGPUs, clusterGPUs)} + options={productChoices(supportedGPUs, clusterGPUs, form.gpuProducts)} + listed={supportedGPUs} /> @@ -726,9 +729,6 @@ function Row({ ); } -// The products worth offering are those the variant declares and a node in -// this cluster reports. Either list alone is a guess: the catalog does not know -// what is racked here, and the cluster does not know what the model supports. function tolerationsOf(o: Record): Toleration[] { if (!Array.isArray(o.tolerations)) return []; return o.tolerations.flatMap((t): Toleration[] => { @@ -775,13 +775,6 @@ function gpuProductsOf(o: Record): string[] { return []; } -function gpuOptions(supported?: string[], present?: string[]): string[] { - const has = new Set(present?.filter(Boolean) ?? []); - if (!supported?.length) return [...has].sort(); - const both = supported.filter((p) => has.has(p)); - return (both.length ? both : supported).slice().sort(); -} - // Tab fills an empty field with the default it is showing, the way a shell // completes: the field is no longer empty afterwards, so the next Tab moves on. function accept( @@ -1000,14 +993,19 @@ function Select({ ); } +// Products offers every card of the variant's vendor on this cluster, the ones +// the catalog lists first and the rest after an "untested" mark: the catalog +// does not know what is racked here. function Products({ selected, onChange, options, + listed, }: { selected: string[]; onChange: (v: string[]) => void; options: string[]; + listed?: string[]; }) { if (options.length === 0) { return ( @@ -1016,32 +1014,53 @@ function Products({ ); } + const untested = (o: string) => unlistedProducts(listed, [o]).length > 0; + const supported = options.filter((o) => !untested(o)); + const others = options.filter(untested); + const forced = unlistedProducts(listed, selected); + const chip = (o: string) => { + const active = selected.includes(o); + const off = untested(o); + return ( + + ); + }; return ( -
- {options.map((o) => { - const active = selected.includes(o); - return ( - - ); - })} - {selected.length === 0 && ( - - none selected — any product the variant supports - +
+ {/* Two units, so a wrap moves the label with its chips. */} +
+ {supported.length > 0 && ( + {supported.map(chip)} + )} + {others.length > 0 && ( + + untested + {others.map(chip)} + + )} +
+ {forced.length > 0 ? ( +

+ + {forced.join(", ")} untested with this variant, deployed as forced +

+ ) : ( + selected.length === 0 &&

none selected: any supported product

)}
); @@ -1327,6 +1346,9 @@ export function planRequest( fromRelease?: string; catalog?: string; chartVersion?: string; + // Accept gpuProducts the variant does not list; swissd still requires each + // on this cluster under the variant's vendor. + forceGpuProducts?: boolean; }, ): PlanRequest { const overrides: Record = {}; @@ -1428,6 +1450,7 @@ export function planRequest( version: opts.version || undefined, editsYAML: f.edits.trim() || undefined, gpuProducts: f.gpuProducts.length ? f.gpuProducts : undefined, + forceGpuProducts: f.gpuProducts.length > 0 && opts.forceGpuProducts ? true : undefined, variant: opts.variant || undefined, release: f.serviceId.trim() || undefined, namespace: f.namespace.trim() || undefined, diff --git a/web/src/modules/swiss/components/InstallMethod.tsx b/web/src/modules/swiss/components/InstallMethod.tsx new file mode 100644 index 0000000..0796972 --- /dev/null +++ b/web/src/modules/swiss/components/InstallMethod.tsx @@ -0,0 +1,64 @@ +import type { ApplyWith } from "@swiss/lib/api"; +import type { LLMSVCBlock } from "@swiss/lib/applyWith"; +import { Badge } from "@swiss/components/ui/badge"; +import { cn } from "@swiss/lib/utils"; + +export const METHODS: Record = { + helm: { label: "Plan ConfigMap", hint: "swissd runs helm and keeps the plan in a ConfigMap beside the release." }, + llmsvc: { label: "LLMService", hint: "swissd writes an LLMService and llm-operator runs helm from it." }, +}; + +const BLOCKED: Record = { + unserved: "the LLMService CRD is not served on this cluster", + unavailable: "swissd cannot use LLMServices here; see the cluster warnings", + schema: "LLMService installs need a catalog schema v2 model", +}; + +export function InstallMethodChoice({ + value, + onChange, + blocked, + fallback, +}: { + value: ApplyWith; + onChange: (m: ApplyWith) => void; + blocked?: LLMSVCBlock; + // What an install naming none gets: the site profile's applyWith. + fallback: ApplyWith; +}) { + return ( +
+ {(["helm", "llmsvc"] as const).map((m) => { + const disabled = m === "llmsvc" && !!blocked; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/modules/swiss/components/Pipeline.tsx b/web/src/modules/swiss/components/Pipeline.tsx index f6e0dc3..5a8a256 100644 --- a/web/src/modules/swiss/components/Pipeline.tsx +++ b/web/src/modules/swiss/components/Pipeline.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CircleCheck, FlaskConical, Layers, Loader2, Rocket, TriangleAlert, Undo2 } from "lucide-react"; -import { api, deployApi, type ApplyResult, type DiffResult, type Plan } from "@swiss/lib/api"; +import { api, deployApi, type ApplyResult, type ApplyWith, type DiffResult, type Plan } from "@swiss/lib/api"; import { Badge } from "@swiss/components/ui/badge"; import { Button } from "@swiss/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@swiss/components/ui/card"; @@ -14,6 +14,7 @@ import { DiffView } from "@swiss/components/DiffView"; import { Provenance } from "@swiss/components/Provenance"; import { ReleaseStatus } from "@swiss/components/ReleaseStatus"; import { Empty, ErrorState } from "@swiss/components/States"; +import { METHODS } from "@swiss/components/InstallMethod"; // Pipeline is compose -> dry run -> apply -> status, shared by Deploy, Upgrade // and rollback. @@ -43,6 +44,7 @@ export function Pipeline({ onDiff, onApplied, rollbackTo, + applyWith, children, }: { namespace: string; @@ -62,6 +64,8 @@ export function Pipeline({ // re-applied. There is nothing to compose -- the plan came out of the // cluster exactly as it ran -- so the button opens the dialog directly. rollbackTo?: number; + // How an install stores the release. An existing release keeps its own. + applyWith?: ApplyWith; // Rendered on the Plan tab above the composed plan: what moves, on upgrade. children?: React.ReactNode; }) { @@ -117,6 +121,7 @@ export function Pipeline({ }); const exists = diff?.exists ?? live.data?.exists; const install = exists === false; + const method = install ? applyWith : live.data?.backend; const applyM = useMutation({ mutationFn: () => @@ -132,7 +137,7 @@ export function Pipeline({ note.trim(), ) : install - ? deployApi.install(plan!.hash, note.trim()) + ? deployApi.install(plan!.hash, note.trim(), applyWith) : // expectRevision is the optimistic lock the dry run computed. It // always exists here: nothing reaches this call without one. deployApi.apply(plan!.hash, diff?.revision, note.trim(), force), @@ -239,6 +244,11 @@ export function Pipeline({ chart {plan.chart.name}-{plan.chart.version} {plan.hash} + {method && ( + + {METHODS[method].label} + + )} ) } @@ -249,6 +259,7 @@ export function Pipeline({ known={known} rollbackTo={rollbackTo} install={install} + method={method} diff={diff} applied={applied} note={note} @@ -298,6 +309,17 @@ export function Pipeline({ {tab === "plan" && plan && (
{children} + {/* What compose accepted but a person should see; kept with the release. */} + {!!plan.warnings?.length && ( +
+ {plan.warnings.map((w) => ( +

+ + {w} +

+ ))} +
+ )} Composed plan @@ -366,6 +388,7 @@ function Action({ known, rollbackTo, install, + method, diff, applied, note, @@ -384,6 +407,7 @@ function Action({ known: boolean; rollbackTo?: number; install: boolean; + method?: ApplyWith; diff: DiffResult | null; applied: ApplyResult | null; note: string; @@ -415,8 +439,9 @@ function Action({ }.` : canApply && diff ? (diff.exists - ? `Asserting the release is still at revision ${diff.revision}.` - : "The release does not exist; this creates it.") + + ? `Asserting the release is still at revision ${diff.revision}.` + + (method ? ` Install method stays ${METHODS[method].label}.` : "") + : `The release does not exist; this creates it${method ? ` with ${METHODS[method].label}` : ""}.`) + (force ? " Fields a hand edit left kubectl owning will be overwritten." : "") : ""; diff --git a/web/src/modules/swiss/components/ProfileEditor.tsx b/web/src/modules/swiss/components/ProfileEditor.tsx index 3750a09..ff1c105 100644 --- a/web/src/modules/swiss/components/ProfileEditor.tsx +++ b/web/src/modules/swiss/components/ProfileEditor.tsx @@ -5,6 +5,7 @@ import { api, type SiteProfile } from "@swiss/lib/api"; import { Button } from "@swiss/components/ui/button"; import { ErrorState } from "@swiss/components/States"; import { ProfileForm } from "@swiss/components/ProfileForm"; +import { markSetUp } from "@swiss/components/Session"; type Mode = "form" | "yaml"; @@ -39,7 +40,7 @@ export function ProfileEditor({ mutationFn: () => (mode === "form" ? api.saveProfile({ profile: form }) : api.saveProfile({ yaml })), onSuccess: (saved) => { qc.setQueryData(["profile"], saved); - qc.invalidateQueries({ queryKey: ["session"] }); + markSetUp(qc); qc.invalidateQueries({ queryKey: ["cluster"] }); // Whichever half was not edited is now stale. A save that came back // without a profile did not parse, which the server refuses -- so this is diff --git a/web/src/modules/swiss/components/ProfileForm.tsx b/web/src/modules/swiss/components/ProfileForm.tsx index fd6f96d..e81ae75 100644 --- a/web/src/modules/swiss/components/ProfileForm.tsx +++ b/web/src/modules/swiss/components/ProfileForm.tsx @@ -5,6 +5,8 @@ import { api, type Site, type SiteProfile } from "@swiss/lib/api"; import { Button } from "@swiss/components/ui/button"; import { Field, Input } from "@swiss/components/ui/input"; import { Switch } from "@swiss/components/ui/switch"; +import { aliasErrors, aliasMap, aliasRows, catalogErrors, type AliasRow } from "@swiss/lib/profile"; +import { cn } from "@swiss/lib/utils"; // ProfileForm edits the site profile field by field, which is how it is // normally edited: the document is a fixed set of cluster facts, not free-form @@ -76,18 +78,37 @@ export function ProfileForm({ onChange={(e) => setIn("nodes", { gpusPerNode: num(e.target.value) })} /> + {/* Not a Field: that is a