From 48823022983d0bb3ebf225389d98658702002773 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Mon, 9 Nov 2026 20:51:41 +0800 Subject: [PATCH 1/9] feat(swiss): ask for a variant's requires.inputs when deploying Each input the catalog variant declares is a field on the deploy and upgrade forms. The site profile's features.inputs answer is prefilled, its template filled for the model; an upgrade shows the release's own answer. Only a typed answer is sent, as a typed override, so an untouched field keeps following the site profile. The type and pattern are checked as the deployer types. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- .../swiss/components/DeploySettings.tsx | 41 ++++++++- web/src/modules/swiss/lib/api.ts | 16 ++++ web/src/modules/swiss/lib/inputs.test.ts | 69 ++++++++++++++ web/src/modules/swiss/lib/inputs.ts | 91 +++++++++++++++++++ web/src/modules/swiss/routes/Deploy.tsx | 3 + web/src/modules/swiss/routes/Upgrade.tsx | 9 ++ 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 web/src/modules/swiss/lib/inputs.test.ts create mode 100644 web/src/modules/swiss/lib/inputs.ts diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 3a6fcaf..46f4186 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import { ChevronDown, Plus, RefreshCw, TriangleAlert, X } from "lucide-react"; -import type { ApplyFlags, ClusterInfo, Plan, PlanRequest, ProfileResponse, Variant } from "@swiss/lib/api"; +import type { ApplyFlags, ClusterInfo, Input as CatalogInput, Plan, PlanRequest, ProfileResponse, Variant } from "@swiss/lib/api"; import { cn } from "@swiss/lib/utils"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { productChoices, unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; +import { inputProblem, setInputOverrides, type InputRow } from "@swiss/lib/inputs"; import { FEATURES, featureOn } from "@swiss/lib/profile"; import { layersOf } from "@swiss/components/Provenance"; import { @@ -50,6 +51,8 @@ export interface Form { createNamespace: boolean; serviceId: string; localPath: string; + // By chart path, as typed. Empty is the row's answer (InputRow). + inputs: Record; scaler: boolean; // The LLMScaler's bounds. Empty leaves the chart's own. scalerMin: string; @@ -105,6 +108,7 @@ export const EMPTY: Form = { createNamespace: false, serviceId: "", localPath: "", + inputs: {}, scaler: true, scalerMin: "", scalerMax: "", @@ -181,6 +185,7 @@ export function DeploySettings({ serviceIdPlaceholder, localPathDefault, localPathPlaceholder, + inputs, image, supportedGPUs, clusterGPUs, @@ -204,6 +209,7 @@ export function DeploySettings({ // a field with. localPathDefault?: string; localPathPlaceholder?: string; + inputs?: InputRow[]; // The engine image the catalog pins, and what the site's mirror rewrites it // to. Equal when this site mirrors nothing, which is when there is no rewrite // to offer a choice about. @@ -315,6 +321,35 @@ export function DeploySettings({ /> + {inputs?.map(({ input, answer, from }) => { + const value = form.inputs[input.path] ?? ""; + const problem = inputProblem(input, value); + return ( + +
+ onChange({ inputs: { ...form.inputs, [input.path]: v } })} + suggestion={answer} + placeholder={answer ?? `e.g. ${String(input.example)}`} + /> + {problem &&

{problem}

} +
+
+ ); + })} + = {}; @@ -1707,6 +1744,8 @@ export function planRequest( if (f.priorityClassName.trim()) overrides.priorityClassName = f.priorityClassName.trim(); if (f.schedulerName.trim()) overrides.schedulerName = f.schedulerName.trim(); + setInputOverrides(overrides, opts.inputs, f.inputs); + return { catalog: opts.catalog || undefined, model: opts.model, diff --git a/web/src/modules/swiss/lib/api.ts b/web/src/modules/swiss/lib/api.ts index 71c5455..f6707c4 100644 --- a/web/src/modules/swiss/lib/api.ts +++ b/web/src/modules/swiss/lib/api.ts @@ -118,6 +118,20 @@ export interface Requires { topology?: string; gpuProduct?: string[]; rdma?: boolean; + // Chart values the variant leaves to the deploy. + inputs?: Input[]; +} + +export interface Input { + path: string; + title: string; + description?: string; + // Unset is string. + type?: "string" | "integer" | "boolean"; + // Matches a whole string answer. + pattern?: string; + // The field's placeholder; never a real value. + example: string | number | boolean; } // v1 is the legacy schema: read-only, and still what a catalog with no @@ -344,6 +358,8 @@ export interface SiteProfile { // The site's preferences, beneath the catalog: a variant overrides them. // features unset is on (featureOn). defaults?: { features?: { cart?: boolean; serviceMonitor?: boolean; sloRequirement?: boolean; scaler?: boolean } }; + // inputs answers requires.inputs by chart path; strings take pathTemplate's placeholders. + features?: { inputs?: Record }; nodes?: { gpusPerNode?: number; // Required by swissd; [] is a site without GPUs. diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts new file mode 100644 index 0000000..21137ab --- /dev/null +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { Input, SiteProfile } from "./api"; +import { fillTemplate, inputProblem, inputRows, setInputOverrides } from "./inputs"; + +const master: Input = { + path: "storageKVOffload.mooncake.master", + title: "Mooncake master", + pattern: "[^\\s:/]+:[0-9]+", + example: "mooncake-master.example:50051", +}; +const port: Input = { path: "service.port", title: "Port", type: "integer", example: 8000 }; +const site = (inputs: Record): SiteProfile => ({ + name: "prod", + model: { pathTemplate: "/models/{{name}}" }, + features: { inputs }, +}); + +describe("fillTemplate", () => { + it("fills the placeholders swissd fills", () => { + expect(fillTemplate("/data/{{org}}/{{name}}/{{model}}:{{hf}}", "zai-org/GLM-5.3", "glm-5.3")).toBe("/data/zai-org/GLM-5.3/glm-5.3:zai-org/GLM-5.3"); + }); + it("gives up where swissd refuses", () => { + expect(fillTemplate("/x/{{release}}", "a/b", "m")).toBeUndefined(); + expect(fillTemplate("/x/{{name}}", "", "m")).toBeUndefined(); + expect(fillTemplate("/x/{{model}}", "", "m")).toBe("/x/m"); + }); +}); + +describe("inputRows", () => { + it("prefills the site's answer, its template filled for this model", () => { + expect(inputRows([master], site({ [master.path]: "mc-{{model}}:50051" }), "zai-org/GLM-5.3", "glm-5.3")).toEqual([ + { input: master, answer: "mc-glm-5.3:50051", from: "site" }, + ]); + expect(inputRows([port], site({ [port.path]: 8050 }), "a/b", "m")[0].answer).toBe("8050"); + }); + it("leaves an input the site does not answer for the deployer", () => { + expect(inputRows([master], site({}), "a/b", "m")).toEqual([{ input: master }]); + expect(inputRows([master], undefined, "a/b", "m")).toEqual([{ input: master }]); + }); + it("shows an upgrade the release's own answer over the site's", () => { + const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; + expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); + }); +}); + +describe("inputProblem", () => { + it("checks a typed answer the way swissd will", () => { + expect(inputProblem(master, "")).toBeUndefined(); + expect(inputProblem(master, "mc:50051")).toBeUndefined(); + expect(inputProblem(master, "no-port")).toBe("must match [^\\s:/]+:[0-9]+"); + expect(inputProblem(port, "80.5")).toBe("a whole number"); + expect(inputProblem({ ...port, type: "boolean" }, "yes")).toBe("true or false"); + }); +}); + +describe("setInputOverrides", () => { + it("sends only typed answers, typed, beside the other overrides in a section", () => { + const overrides: Record = { storageKVOffload: { enabled: true }, service: "dropped" }; + setInputOverrides(overrides, [master, port, { ...master, path: "unset.here" }], { + [master.path]: " mc:50051 ", + [port.path]: "8050", + "unset.here": " ", + }); + expect(overrides).toEqual({ + storageKVOffload: { enabled: true, mooncake: { master: "mc:50051" } }, + service: { port: 8050 }, + }); + }); +}); diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts new file mode 100644 index 0000000..2fe4d14 --- /dev/null +++ b/web/src/modules/swiss/lib/inputs.ts @@ -0,0 +1,91 @@ +import type { Input, SiteProfile } from "./api"; + +// answer applies when the field is left empty; none means the deployer must fill it. +export interface InputRow { + input: Input; + answer?: string; + from?: "site" | "release"; +} + +// fillTemplate mirrors swissd's site.fill, and is undefined where swissd refuses. +export function fillTemplate(tmpl: string, hf: string, model: string): string | undefined { + if (!hf && /\{\{(hf|org|name)\}\}/.test(tmpl)) return undefined; + const slash = hf.indexOf("/"); + const org = slash < 0 ? hf : hf.slice(0, slash); + const name = slash < 0 ? "" : hf.slice(slash + 1); + const out = tmpl.replace(/\{\{(hf|org|name|model)\}\}/g, (_, ph: string) => ({ hf, org, name, model })[ph] ?? ""); + return out.includes("{{") ? undefined : out; +} + +export function answerAt(tree: Record | undefined, path: string): string | undefined { + let node: unknown = tree; + for (const key of path.split(".")) { + if (!node || typeof node !== "object" || Array.isArray(node)) return undefined; + node = (node as Record)[key]; + } + if (node === undefined || node === null || node === "" || typeof node === "object") return undefined; + return String(node); +} + +// release is the upgraded plan's form layer: swissd carries it forward, so its answer is kept. +export function inputRows( + inputs: Input[] | undefined, + profile: SiteProfile | undefined, + hf: string, + model: string, + release?: Record, +): InputRow[] { + return (inputs ?? []).map((input) => { + const kept = answerAt(release, input.path); + if (kept !== undefined) return { input, answer: kept, from: "release" }; + const v = profile?.features?.inputs?.[input.path]; + const site = v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v); + return site !== undefined ? { input, answer: site, from: "site" } : { input }; + }); +} + +// What swissd would refuse a typed answer for. Empty is fine: the row's answer applies. +export function inputProblem(input: Input, text: string): string | undefined { + const t = text.trim(); + if (!t) return undefined; + switch (input.type ?? "string") { + case "integer": + return /^-?\d+$/.test(t) ? undefined : "a whole number"; + case "boolean": + return t === "true" || t === "false" ? undefined : "true or false"; + default: + if (!input.pattern) return undefined; + try { + return new RegExp(`^(?:${input.pattern})$`, "u").test(t) ? undefined : `must match ${input.pattern}`; + } catch { + return undefined; // swissd's own check names a pattern it cannot read + } + } +} + +function typed(input: Input, text: string): unknown { + switch (input.type ?? "string") { + case "integer": + return Number(text); + case "boolean": + return text === "true"; + default: + return text; + } +} + +// An empty field sends nothing, so its answer stays the site's and follows the profile. +export function setInputOverrides(overrides: Record, inputs: Input[] | undefined, answers: Record) { + for (const input of inputs ?? []) { + const text = answers[input.path]?.trim(); + if (!text) continue; + const keys = input.path.split("."); + let node = overrides; + for (const key of keys.slice(0, -1)) { + const next = node[key]; + if (!next || typeof next !== "object" || Array.isArray(next)) node[key] = {}; + node = node[key] as Record; + } + node[keys[keys.length - 1]] = typed(input, text); + } +} diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index 29dfb0f..602062e 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -12,6 +12,7 @@ import { CatalogBadge, CatalogGate, SchemaBadge, useCatalogChoice, withCatalog } import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; import { isChartRange } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; +import { inputRows } from "@swiss/lib/inputs"; import { deployCapacity, pickableProducts, unlistedProducts, vendorResources } from "@swiss/lib/gpu"; import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { InstallMethodChoice } from "@swiss/components/InstallMethod"; @@ -119,6 +120,7 @@ export function Deploy() { chartVersion: chartVersion || undefined, forceGpuProducts: unlistedProducts(model.data?.variant.requires.gpuProduct, form.gpuProducts).length > 0, cacheUnsupported: !!cache.unsupported, + inputs: model.data?.variant.requires.inputs, }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -264,6 +266,7 @@ export function Deploy() { serviceIdPlaceholder={name} localPathDefault={model.data.localPath} localPathPlaceholder={model.data.pathTemplate} + inputs={inputRows(model.data.variant.requires.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name)} image={imageOf(variant, model.data.imageRepository)} supportedGPUs={variant?.requires.gpuProduct} clusterGPUs={pickableProducts(nodes.data?.nodes, variant?.requires.vendor, profile.data?.profile?.nodes?.gpus, form.gpuResource)} diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx index 39f7fff..43fc2b7 100644 --- a/web/src/modules/swiss/routes/Upgrade.tsx +++ b/web/src/modules/swiss/routes/Upgrade.tsx @@ -30,6 +30,7 @@ import { ErrorState, Loading } from "@swiss/components/States"; import { CatalogBadge, SchemaBadge, releaseCatalog as catalogOfRelease } from "@swiss/components/CatalogChoice"; import { movableCatalogs } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; +import { inputRows } from "@swiss/lib/inputs"; import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; import { CatalogModelDiff } from "@swiss/components/CatalogModelDiff"; import { VariantCard } from "@swiss/components/VariantCard"; @@ -164,6 +165,7 @@ export function Upgrade() { // dropped by a form that never knew about it. fromRelease: release, cacheUnsupported: !!cache.unsupported, + inputs: entry.data?.variant.requires.inputs, }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -468,6 +470,13 @@ export function Upgrade() { } localPathDefault={entry.data?.localPath} localPathPlaceholder={entry.data?.pathTemplate} + inputs={inputRows( + entry.data?.variant.requires.inputs, + profile.data?.profile, + entry.data?.model.source?.hf ?? "", + current.data?.source.model ?? "", + current.data?.layers?.form, + )} image={imageOf(entry.data?.variant, entry.data?.imageRepository)} cache={cache} lockIdentity From 24af2dfacbcdcc8eec8a0df2e8e9d4f2ce01c286 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 10:45:12 +0800 Subject: [PATCH 2/9] feat(swiss): fall back to the variant's default for an input An input field now shows the answer swissd would use, in its order: the release's (on upgrade), the site profile's, then the variant's own value. Only an input with none of them is marked required. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- web/src/modules/swiss/components/DeploySettings.tsx | 4 +++- web/src/modules/swiss/lib/inputs.test.ts | 9 ++++++--- web/src/modules/swiss/lib/inputs.ts | 10 +++++++--- web/src/modules/swiss/routes/Deploy.tsx | 2 +- web/src/modules/swiss/routes/Upgrade.tsx | 1 + 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 46f4186..d755955 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -333,7 +333,9 @@ export function DeploySettings({ ? "the release's answer, kept unless you change it" : from === "site" ? "the site profile's answer, unless you change it" - : "required: the site profile has no answer" + : from === "variant" + ? "the variant's default, unless you change it" + : "required: no default, and the site profile has no answer" } hint={input.description ? `${input.description} (${input.path})` : input.path} > diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index 21137ab..ee4f94e 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -33,13 +33,16 @@ describe("inputRows", () => { ]); expect(inputRows([port], site({ [port.path]: 8050 }), "a/b", "m")[0].answer).toBe("8050"); }); - it("leaves an input the site does not answer for the deployer", () => { - expect(inputRows([master], site({}), "a/b", "m")).toEqual([{ input: master }]); + it("falls back to the variant's default, and leaves an input with none for the deployer", () => { + const tuned = { storageKVOffload: { mooncake: { master: "tuned:1" } } }; + expect(inputRows([master], site({}), "a/b", "m", tuned)).toEqual([{ input: master, answer: "tuned:1", from: "variant" }]); + expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", tuned)[0].from).toBe("site"); + expect(inputRows([master], site({}), "a/b", "m", { storageKVOffload: { mooncake: { master: "" } } })).toEqual([{ input: master }]); expect(inputRows([master], undefined, "a/b", "m")).toEqual([{ input: master }]); }); it("shows an upgrade the release's own answer over the site's", () => { const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; - expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); + expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", undefined, release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); }); }); diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index 2fe4d14..496ffc1 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -4,7 +4,7 @@ import type { Input, SiteProfile } from "./api"; export interface InputRow { input: Input; answer?: string; - from?: "site" | "release"; + from?: "release" | "site" | "variant"; } // fillTemplate mirrors swissd's site.fill, and is undefined where swissd refuses. @@ -27,12 +27,14 @@ export function answerAt(tree: Record | undefined, path: string return String(node); } -// release is the upgraded plan's form layer: swissd carries it forward, so its answer is kept. +// The order swissd answers in: the release's form layer (carried forward on upgrade), +// the site profile, then the variant's own values. export function inputRows( inputs: Input[] | undefined, profile: SiteProfile | undefined, hf: string, model: string, + variantValues?: Record, release?: Record, ): InputRow[] { return (inputs ?? []).map((input) => { @@ -40,7 +42,9 @@ export function inputRows( if (kept !== undefined) return { input, answer: kept, from: "release" }; const v = profile?.features?.inputs?.[input.path]; const site = v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v); - return site !== undefined ? { input, answer: site, from: "site" } : { input }; + if (site !== undefined) return { input, answer: site, from: "site" }; + const def = answerAt(variantValues, input.path); + return def !== undefined ? { input, answer: def, from: "variant" } : { input }; }); } diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index 602062e..769776a 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -266,7 +266,7 @@ export function Deploy() { serviceIdPlaceholder={name} localPathDefault={model.data.localPath} localPathPlaceholder={model.data.pathTemplate} - inputs={inputRows(model.data.variant.requires.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name)} + inputs={inputRows(model.data.variant.requires.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name, model.data.variant.values)} image={imageOf(variant, model.data.imageRepository)} supportedGPUs={variant?.requires.gpuProduct} clusterGPUs={pickableProducts(nodes.data?.nodes, variant?.requires.vendor, profile.data?.profile?.nodes?.gpus, form.gpuResource)} diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx index 43fc2b7..33a8bb7 100644 --- a/web/src/modules/swiss/routes/Upgrade.tsx +++ b/web/src/modules/swiss/routes/Upgrade.tsx @@ -475,6 +475,7 @@ export function Upgrade() { profile.data?.profile, entry.data?.model.source?.hf ?? "", current.data?.source.model ?? "", + entry.data?.variant.values, current.data?.layers?.form, )} image={imageOf(entry.data?.variant, entry.data?.imageRepository)} From 0daab1e72d02ea0ac35789cb58d89b7e47771c41 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 10:59:05 +0800 Subject: [PATCH 3/9] feat(swiss): every input is required; "" from any layer is an answer A row with no answer from the release, the site or the variant shows as unanswered, and "" counts as an answer, as swissd reads it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- web/src/modules/swiss/components/DeploySettings.tsx | 2 +- web/src/modules/swiss/lib/inputs.test.ts | 1 - web/src/modules/swiss/lib/inputs.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index d755955..273f144 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -335,7 +335,7 @@ export function DeploySettings({ ? "the site profile's answer, unless you change it" : from === "variant" ? "the variant's default, unless you change it" - : "required: no default, and the site profile has no answer" + : "unanswered: the variant has no default, and the site profile no answer" } hint={input.description ? `${input.description} (${input.path})` : input.path} > diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index ee4f94e..917fe90 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -37,7 +37,6 @@ describe("inputRows", () => { const tuned = { storageKVOffload: { mooncake: { master: "tuned:1" } } }; expect(inputRows([master], site({}), "a/b", "m", tuned)).toEqual([{ input: master, answer: "tuned:1", from: "variant" }]); expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", tuned)[0].from).toBe("site"); - expect(inputRows([master], site({}), "a/b", "m", { storageKVOffload: { mooncake: { master: "" } } })).toEqual([{ input: master }]); expect(inputRows([master], undefined, "a/b", "m")).toEqual([{ input: master }]); }); it("shows an upgrade the release's own answer over the site's", () => { diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index 496ffc1..e220582 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -23,7 +23,7 @@ export function answerAt(tree: Record | undefined, path: string if (!node || typeof node !== "object" || Array.isArray(node)) return undefined; node = (node as Record)[key]; } - if (node === undefined || node === null || node === "" || typeof node === "object") return undefined; + if (node === undefined || node === null || typeof node === "object") return undefined; return String(node); } From 16a60e58e719d8b63fd285063f19da1175300bae Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 11:08:59 +0800 Subject: [PATCH 4/9] feat(swiss): prefill an input's catalog default An input field falls back to requires.inputs[].default, after the release's and the site's answers, the order swissd selects in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- web/src/modules/swiss/lib/api.ts | 2 ++ web/src/modules/swiss/lib/inputs.test.ts | 11 ++++++----- web/src/modules/swiss/lib/inputs.ts | 6 ++---- web/src/modules/swiss/routes/Deploy.tsx | 2 +- web/src/modules/swiss/routes/Upgrade.tsx | 1 - 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/web/src/modules/swiss/lib/api.ts b/web/src/modules/swiss/lib/api.ts index f6707c4..2760302 100644 --- a/web/src/modules/swiss/lib/api.ts +++ b/web/src/modules/swiss/lib/api.ts @@ -132,6 +132,8 @@ export interface Input { pattern?: string; // The field's placeholder; never a real value. example: string | number | boolean; + // swissd's answer when the site profile has none. + default?: string | number | boolean; } // v1 is the legacy schema: read-only, and still what a catalog with no diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index 917fe90..c0cc75c 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -33,15 +33,16 @@ describe("inputRows", () => { ]); expect(inputRows([port], site({ [port.path]: 8050 }), "a/b", "m")[0].answer).toBe("8050"); }); - it("falls back to the variant's default, and leaves an input with none for the deployer", () => { - const tuned = { storageKVOffload: { mooncake: { master: "tuned:1" } } }; - expect(inputRows([master], site({}), "a/b", "m", tuned)).toEqual([{ input: master, answer: "tuned:1", from: "variant" }]); - expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", tuned)[0].from).toBe("site"); + it("falls back to the input's default, and leaves an input with none for the deployer", () => { + const tuned = { ...master, default: "tuned:1" }; + expect(inputRows([tuned], site({}), "a/b", "m")).toEqual([{ input: tuned, answer: "tuned:1", from: "variant" }]); + expect(inputRows([tuned], site({ [master.path]: "site:1" }), "a/b", "m")[0].from).toBe("site"); + expect(inputRows([{ ...port, default: 8000 }], site({}), "a/b", "m")[0].answer).toBe("8000"); expect(inputRows([master], undefined, "a/b", "m")).toEqual([{ input: master }]); }); it("shows an upgrade the release's own answer over the site's", () => { const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; - expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", undefined, release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); + expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); }); }); diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index e220582..419a23b 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -28,13 +28,12 @@ export function answerAt(tree: Record | undefined, path: string } // The order swissd answers in: the release's form layer (carried forward on upgrade), -// the site profile, then the variant's own values. +// the site profile, then the input's default. export function inputRows( inputs: Input[] | undefined, profile: SiteProfile | undefined, hf: string, model: string, - variantValues?: Record, release?: Record, ): InputRow[] { return (inputs ?? []).map((input) => { @@ -43,8 +42,7 @@ export function inputRows( const v = profile?.features?.inputs?.[input.path]; const site = v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v); if (site !== undefined) return { input, answer: site, from: "site" }; - const def = answerAt(variantValues, input.path); - return def !== undefined ? { input, answer: def, from: "variant" } : { input }; + return input.default !== undefined && input.default !== null ? { input, answer: String(input.default), from: "variant" } : { input }; }); } diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index 769776a..602062e 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -266,7 +266,7 @@ export function Deploy() { serviceIdPlaceholder={name} localPathDefault={model.data.localPath} localPathPlaceholder={model.data.pathTemplate} - inputs={inputRows(model.data.variant.requires.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name, model.data.variant.values)} + inputs={inputRows(model.data.variant.requires.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name)} image={imageOf(variant, model.data.imageRepository)} supportedGPUs={variant?.requires.gpuProduct} clusterGPUs={pickableProducts(nodes.data?.nodes, variant?.requires.vendor, profile.data?.profile?.nodes?.gpus, form.gpuResource)} diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx index 33a8bb7..43fc2b7 100644 --- a/web/src/modules/swiss/routes/Upgrade.tsx +++ b/web/src/modules/swiss/routes/Upgrade.tsx @@ -475,7 +475,6 @@ export function Upgrade() { profile.data?.profile, entry.data?.model.source?.hf ?? "", current.data?.source.model ?? "", - entry.data?.variant.values, current.data?.layers?.form, )} image={imageOf(entry.data?.variant, entry.data?.imageRepository)} From c486c98ff58cb796bae8d941f67b8f8a1dcf52ca Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 11:28:01 +0800 Subject: [PATCH 5/9] feat(swiss): read inputs from the variant and the profile's top level Variant.inputs replaces requires.inputs, and SiteProfile.inputs replaces features.inputs, as swissd now serves them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- web/src/modules/swiss/lib/api.ts | 8 ++++---- web/src/modules/swiss/lib/inputs.test.ts | 2 +- web/src/modules/swiss/lib/inputs.ts | 2 +- web/src/modules/swiss/routes/Deploy.tsx | 4 ++-- web/src/modules/swiss/routes/Upgrade.tsx | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/modules/swiss/lib/api.ts b/web/src/modules/swiss/lib/api.ts index 2760302..fa72d2f 100644 --- a/web/src/modules/swiss/lib/api.ts +++ b/web/src/modules/swiss/lib/api.ts @@ -118,8 +118,6 @@ export interface Requires { topology?: string; gpuProduct?: string[]; rdma?: boolean; - // Chart values the variant leaves to the deploy. - inputs?: Input[]; } export interface Input { @@ -208,6 +206,8 @@ export interface CatalogResponse { export interface Variant extends Omit { servedName: string; image?: { repository: string; tag: string; digest?: string }; + // The chart values every deploy must answer. + inputs?: Input[]; values?: Record; } @@ -360,8 +360,8 @@ export interface SiteProfile { // The site's preferences, beneath the catalog: a variant overrides them. // features unset is on (featureOn). defaults?: { features?: { cart?: boolean; serviceMonitor?: boolean; sloRequirement?: boolean; scaler?: boolean } }; - // inputs answers requires.inputs by chart path; strings take pathTemplate's placeholders. - features?: { inputs?: Record }; + // Answers a variant's inputs by chart path; strings take pathTemplate's placeholders. + inputs?: Record; nodes?: { gpusPerNode?: number; // Required by swissd; [] is a site without GPUs. diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index c0cc75c..8858aa6 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -12,7 +12,7 @@ const port: Input = { path: "service.port", title: "Port", type: "integer", exam const site = (inputs: Record): SiteProfile => ({ name: "prod", model: { pathTemplate: "/models/{{name}}" }, - features: { inputs }, + inputs, }); describe("fillTemplate", () => { diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index 419a23b..a3c05e8 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -39,7 +39,7 @@ export function inputRows( return (inputs ?? []).map((input) => { const kept = answerAt(release, input.path); if (kept !== undefined) return { input, answer: kept, from: "release" }; - const v = profile?.features?.inputs?.[input.path]; + const v = profile?.inputs?.[input.path]; const site = v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v); if (site !== undefined) return { input, answer: site, from: "site" }; return input.default !== undefined && input.default !== null ? { input, answer: String(input.default), from: "variant" } : { input }; diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index 602062e..9466864 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -120,7 +120,7 @@ export function Deploy() { chartVersion: chartVersion || undefined, forceGpuProducts: unlistedProducts(model.data?.variant.requires.gpuProduct, form.gpuProducts).length > 0, cacheUnsupported: !!cache.unsupported, - inputs: model.data?.variant.requires.inputs, + inputs: model.data?.variant.inputs, }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -266,7 +266,7 @@ export function Deploy() { serviceIdPlaceholder={name} localPathDefault={model.data.localPath} localPathPlaceholder={model.data.pathTemplate} - inputs={inputRows(model.data.variant.requires.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name)} + inputs={inputRows(model.data.variant.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name)} image={imageOf(variant, model.data.imageRepository)} supportedGPUs={variant?.requires.gpuProduct} clusterGPUs={pickableProducts(nodes.data?.nodes, variant?.requires.vendor, profile.data?.profile?.nodes?.gpus, form.gpuResource)} diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx index 43fc2b7..639863a 100644 --- a/web/src/modules/swiss/routes/Upgrade.tsx +++ b/web/src/modules/swiss/routes/Upgrade.tsx @@ -165,7 +165,7 @@ export function Upgrade() { // dropped by a form that never knew about it. fromRelease: release, cacheUnsupported: !!cache.unsupported, - inputs: entry.data?.variant.requires.inputs, + inputs: entry.data?.variant.inputs, }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -471,7 +471,7 @@ export function Upgrade() { localPathDefault={entry.data?.localPath} localPathPlaceholder={entry.data?.pathTemplate} inputs={inputRows( - entry.data?.variant.requires.inputs, + entry.data?.variant.inputs, profile.data?.profile, entry.data?.model.source?.hf ?? "", current.data?.source.model ?? "", From 07676cc5f1d8b9a458b5d5cd099c4a7c5bacc107 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 11:45:53 +0800 Subject: [PATCH 6/9] feat(swiss): read an input's chart value from inputs[].key --- .../swiss/components/DeploySettings.tsx | 8 ++++---- web/src/modules/swiss/lib/api.ts | 3 ++- web/src/modules/swiss/lib/inputs.test.ts | 18 +++++++++--------- web/src/modules/swiss/lib/inputs.ts | 14 +++++++------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 273f144..9fe6d6f 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -322,11 +322,11 @@ export function DeploySettings({ {inputs?.map(({ input, answer, from }) => { - const value = form.inputs[input.path] ?? ""; + const value = form.inputs[input.key] ?? ""; const problem = inputProblem(input, value); return (
onChange({ inputs: { ...form.inputs, [input.path]: v } })} + onChange={(v) => onChange({ inputs: { ...form.inputs, [input.key]: v } })} suggestion={answer} placeholder={answer ?? `e.g. ${String(input.example)}`} /> diff --git a/web/src/modules/swiss/lib/api.ts b/web/src/modules/swiss/lib/api.ts index fa72d2f..de838be 100644 --- a/web/src/modules/swiss/lib/api.ts +++ b/web/src/modules/swiss/lib/api.ts @@ -121,7 +121,8 @@ export interface Requires { } export interface Input { - path: string; + // The chart value's dotted key: storageKVOffload.mooncake.master. + key: string; title: string; description?: string; // Unset is string. diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index 8858aa6..25afb25 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -3,12 +3,12 @@ import type { Input, SiteProfile } from "./api"; import { fillTemplate, inputProblem, inputRows, setInputOverrides } from "./inputs"; const master: Input = { - path: "storageKVOffload.mooncake.master", + key: "storageKVOffload.mooncake.master", title: "Mooncake master", pattern: "[^\\s:/]+:[0-9]+", example: "mooncake-master.example:50051", }; -const port: Input = { path: "service.port", title: "Port", type: "integer", example: 8000 }; +const port: Input = { key: "service.port", title: "Port", type: "integer", example: 8000 }; const site = (inputs: Record): SiteProfile => ({ name: "prod", model: { pathTemplate: "/models/{{name}}" }, @@ -28,21 +28,21 @@ describe("fillTemplate", () => { describe("inputRows", () => { it("prefills the site's answer, its template filled for this model", () => { - expect(inputRows([master], site({ [master.path]: "mc-{{model}}:50051" }), "zai-org/GLM-5.3", "glm-5.3")).toEqual([ + expect(inputRows([master], site({ [master.key]: "mc-{{model}}:50051" }), "zai-org/GLM-5.3", "glm-5.3")).toEqual([ { input: master, answer: "mc-glm-5.3:50051", from: "site" }, ]); - expect(inputRows([port], site({ [port.path]: 8050 }), "a/b", "m")[0].answer).toBe("8050"); + expect(inputRows([port], site({ [port.key]: 8050 }), "a/b", "m")[0].answer).toBe("8050"); }); it("falls back to the input's default, and leaves an input with none for the deployer", () => { const tuned = { ...master, default: "tuned:1" }; expect(inputRows([tuned], site({}), "a/b", "m")).toEqual([{ input: tuned, answer: "tuned:1", from: "variant" }]); - expect(inputRows([tuned], site({ [master.path]: "site:1" }), "a/b", "m")[0].from).toBe("site"); + expect(inputRows([tuned], site({ [master.key]: "site:1" }), "a/b", "m")[0].from).toBe("site"); expect(inputRows([{ ...port, default: 8000 }], site({}), "a/b", "m")[0].answer).toBe("8000"); expect(inputRows([master], undefined, "a/b", "m")).toEqual([{ input: master }]); }); it("shows an upgrade the release's own answer over the site's", () => { const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; - expect(inputRows([master], site({ [master.path]: "site:1" }), "a/b", "m", release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); + expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); }); }); @@ -59,9 +59,9 @@ describe("inputProblem", () => { describe("setInputOverrides", () => { it("sends only typed answers, typed, beside the other overrides in a section", () => { const overrides: Record = { storageKVOffload: { enabled: true }, service: "dropped" }; - setInputOverrides(overrides, [master, port, { ...master, path: "unset.here" }], { - [master.path]: " mc:50051 ", - [port.path]: "8050", + setInputOverrides(overrides, [master, port, { ...master, key: "unset.here" }], { + [master.key]: " mc:50051 ", + [port.key]: "8050", "unset.here": " ", }); expect(overrides).toEqual({ diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index a3c05e8..c99ca5b 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -17,11 +17,11 @@ export function fillTemplate(tmpl: string, hf: string, model: string): string | return out.includes("{{") ? undefined : out; } -export function answerAt(tree: Record | undefined, path: string): string | undefined { +export function answerAt(tree: Record | undefined, key: string): string | undefined { let node: unknown = tree; - for (const key of path.split(".")) { + for (const k of key.split(".")) { if (!node || typeof node !== "object" || Array.isArray(node)) return undefined; - node = (node as Record)[key]; + node = (node as Record)[k]; } if (node === undefined || node === null || typeof node === "object") return undefined; return String(node); @@ -37,9 +37,9 @@ export function inputRows( release?: Record, ): InputRow[] { return (inputs ?? []).map((input) => { - const kept = answerAt(release, input.path); + const kept = answerAt(release, input.key); if (kept !== undefined) return { input, answer: kept, from: "release" }; - const v = profile?.inputs?.[input.path]; + const v = profile?.inputs?.[input.key]; const site = v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v); if (site !== undefined) return { input, answer: site, from: "site" }; return input.default !== undefined && input.default !== null ? { input, answer: String(input.default), from: "variant" } : { input }; @@ -79,9 +79,9 @@ function typed(input: Input, text: string): unknown { // An empty field sends nothing, so its answer stays the site's and follows the profile. export function setInputOverrides(overrides: Record, inputs: Input[] | undefined, answers: Record) { for (const input of inputs ?? []) { - const text = answers[input.path]?.trim(); + const text = answers[input.key]?.trim(); if (!text) continue; - const keys = input.path.split("."); + const keys = input.key.split("."); let node = overrides; for (const key of keys.slice(0, -1)) { const next = node[key]; From b5f9764c69e7552b5de1fb060c2f2974c8a02a08 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 17:15:50 +0800 Subject: [PATCH 7/9] feat: add inputs requires in inference module, optimize ui --- .../ui/src/components/floating-field.tsx | 4 +++ .../inferences/components/DeployForm.test.tsx | 36 +++++++++++++++++++ .../inferences/components/DeployForm.tsx | 22 +++++++++++- .../inferences/components/DeploySheet.tsx | 9 +++-- web/src/modules/inferences/locales/en-US.json | 7 ++++ web/src/modules/inferences/locales/zh-CN.json | 7 ++++ .../swiss/components/DeploySettings.tsx | 18 +++++++--- web/src/modules/swiss/lib/inputs.test.ts | 34 +++++++++++++++--- web/src/modules/swiss/lib/inputs.ts | 20 ++++++++--- web/src/modules/swiss/routes/Deploy.tsx | 7 ++-- web/src/modules/swiss/routes/Upgrade.tsx | 12 +++---- 11 files changed, 149 insertions(+), 27 deletions(-) diff --git a/web/packages/ui/src/components/floating-field.tsx b/web/packages/ui/src/components/floating-field.tsx index 0a1f407..94756df 100644 --- a/web/packages/ui/src/components/floating-field.tsx +++ b/web/packages/ui/src/components/floating-field.tsx @@ -352,6 +352,8 @@ export interface FloatingFieldProps { /** 固定后缀(`.svc.cluster.local`)。同 `prefix`,画在框内右侧。 */ suffix?: React.ReactNode action?: React.ReactNode + /** 跟在 `?` 后面的一段弱化文字,如字段的示例值。 */ + aside?: React.ReactNode /** * 警示条 —— 「什么情况下应该开启/填写这一项」。 * @@ -433,6 +435,7 @@ export function FloatingField({ prefix, suffix, action, + aside, error, notice, expandedContent, @@ -491,6 +494,7 @@ export function FloatingField({ {hint} ) : null} + {aside ? {aside} : null} ) diff --git a/web/src/modules/inferences/components/DeployForm.test.tsx b/web/src/modules/inferences/components/DeployForm.test.tsx index 986e61b..1c4fc5d 100644 --- a/web/src/modules/inferences/components/DeployForm.test.tsx +++ b/web/src/modules/inferences/components/DeployForm.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { DeployForm } from "./DeployForm"; import { EMPTY, planRequest, type Form } from "@swiss/components/DeploySettings"; import type { CacheCard } from "@swiss/lib/cache"; +import type { InputRow } from "@swiss/lib/inputs"; import type { RouteOptions } from "@/modules/inferences/routeOptions"; import en from "@/modules/inferences/locales/en-US.json"; import zh from "@/modules/inferences/locales/zh-CN.json"; @@ -219,3 +220,38 @@ describe("GPU capacity", () => { expect(html).toContain("24"); }); }); + +describe("variant inputs", () => { + const master = { key: "storageKVOffload.mooncake.master", title: "Mooncake master", description: "host:port of the master", pattern: "[^\\s:/]+:[0-9]+", example: "mooncake-master.example:50051" }; + const render = (form: Form, inputs: InputRow[]) => + renderToStaticMarkup( + {}} submitted={false} target={null} defaults={{}} gpuOptions={[]} inputs={inputs} + expanded={{ basic: false, resources: true, routing: false, advanced: false }} + onExpand={() => {}} idPrefix="test-" + />, + ); + it("renders each input as its variant declares it", () => { + const html = render({ ...EMPTY, serviceId: "model" }, [{ input: master }]); + expect(html).toContain("Mooncake master"); + expect([en, zh].some((l) => html.includes(l.deploy.fields.inputExample.replace("{example}", master.example)))).toBe(true); + expect(html).not.toContain(`placeholder="${master.example}"`); + expect(html).not.toMatch(/placeholder="[^"]*mooncake-master\.example/); + expect([en, zh].some((l) => html.includes(l.deploy.fields.inputRequired))).toBe(true); + expect(html).toContain('aria-invalid="true"'); + }); + it("shows the answer that applies when the field is left empty", () => { + const html = render({ ...EMPTY, serviceId: "model" }, [{ input: master, answer: "site.svc:50051", from: "site" }]); + expect(html).toContain('placeholder="site.svc:50051"'); + expect(html).not.toContain('aria-invalid="true"'); + }); + it("says when a typed answer does not match the pattern", () => { + const html = render({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "no-port" } }, [{ input: master }]); + expect(html).toContain('value="no-port"'); + expect([en, zh].some((l) => html.includes(l.deploy.fields.inputProblem.string.replace(" {pattern}", "")))).toBe(true); + }); + it("sends a typed answer at the input's key", () => { + const form = { ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } }; + expect(planRequest(form, { model: "model", inputs: [master] }).overrides).toMatchObject({ storageKVOffload: { mooncake: { master: "mine.svc:50051" } } }); + }); +}); diff --git a/web/src/modules/inferences/components/DeployForm.tsx b/web/src/modules/inferences/components/DeployForm.tsx index c0e745f..12a97fd 100644 --- a/web/src/modules/inferences/components/DeployForm.tsx +++ b/web/src/modules/inferences/components/DeployForm.tsx @@ -25,6 +25,7 @@ import { Plus, RefreshCw, X } from "lucide-react"; import type { ClusterInfo } from "@swiss/lib/api"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; +import { inputError, type InputRow } from "@swiss/lib/inputs"; import { effective, imageChoices, type Form, type Toleration } from "@swiss/components/DeploySettings"; import { MAX_VALUES, rejectKey } from "@swiss/lib/routeDirectives"; import { FALLBACK_ROUTE_OPTIONS, routeDirectives, runtimeValue, type RouteDirective, type RouteOptions } from "@/modules/inferences/routeOptions"; @@ -59,9 +60,11 @@ export interface DeployFormProps { // The chart's values and swissd's layers for the Override YAML editor. helmValues?: HelmValuesSource; recovery?: ReactNode; + // The variant's inputs, each with the answer that applies when its field is left empty. + inputs?: InputRow[]; } -export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery }: DeployFormProps) { +export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs }: DeployFormProps) { const t = useT(); const on = effective(form); const f = (k: string) => t(`deploy.fields.${k}`); @@ -149,6 +152,23 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster onChange({ localPath })} suggestion={defaults.localPath} placeholder={defaults.localPath || defaults.localPathTemplate} /> + {inputs?.map(({ input, answer }) => { + const value = form.inputs[input.key] ?? ""; + const error = inputError({ input, answer }, value); + return ( + + onChange({ inputs: { ...form.inputs, [input.key]: v } })} suggestion={answer} placeholder={answer} /> + + ); + })} {defaults.image && ( 0; const change = (patch: Partial
) => setForm((f) => ({ ...f, ...patch })); + const inputs = inputRows(v?.inputs, profile.data?.profile, entry.data?.model.source?.hf ?? "", modelName ?? "", v?.values, upgrade ? current.data?.layers?.form : undefined); // What a compose of f sends; the Override YAML editor's preview sends the same. const requestFor = (f: Form) => planRequest( { ...f, serviceId: f.serviceId || (upgrade ? f.serviceId : defaultServiceId(target.model)) }, upgrade - ? { model: recorded!.model, version: version || undefined, variant: variant || undefined, catalog: moveTo || undefined, chartVersion: chartVersion || undefined, fromRelease: target.release, cacheUnsupported: !!cache.unsupported, forceGpuProducts } - : { model: target.model, version, variant: keptVariant, catalog: target.catalog, chartVersion: chartVersion || undefined, cacheUnsupported: !!cache.unsupported, forceGpuProducts }, + ? { model: recorded!.model, version: version || undefined, variant: variant || undefined, catalog: moveTo || undefined, chartVersion: chartVersion || undefined, fromRelease: target.release, cacheUnsupported: !!cache.unsupported, forceGpuProducts, inputs: v?.inputs } + : { model: target.model, version, variant: keptVariant, catalog: target.catalog, chartVersion: chartVersion || undefined, cacheUnsupported: !!cache.unsupported, forceGpuProducts, inputs: v?.inputs }, profile.data?.profile, ); // The chart's values and swissd's layers for this form, without the @@ -488,6 +490,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( catalogRoute={catalogRoute} idPrefix={ID_PREFIX} helmValues={readOnly ? undefined : valuesSource} + inputs={inputs} />
@@ -516,7 +519,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( {step === "form" ? ( <> - diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 07d323d..2ac64f0 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -578,6 +578,13 @@ "scheduler": "Scheduler", "localPath": "Model path", "localPathHint": "Overrides the site profile's path template", + "inputExample": "e.g. {example}", + "inputRequired": "Required: no default or site answer", + "inputProblem": { + "string": "Must match {pattern}", + "integer": "Enter a whole number", + "boolean": "Enter true or false" + }, "image": "Override image", "imageHint": "Off uses the catalog's tag and the site's mirror", "imageValue": "Image (repository:tag)", diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index 9da1ae8..0683dbe 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -567,6 +567,13 @@ "scheduler": "调度器", "localPath": "模型路径", "localPathHint": "覆盖站点配置里的路径模板", + "inputExample": "例如 {example}", + "inputRequired": "必填:变体没有默认值,站点配置也没有答案", + "inputProblem": { + "string": "需匹配 {pattern}", + "integer": "请输入整数", + "boolean": "请输入 true 或 false" + }, "image": "覆盖镜像", "imageHint": "关闭时使用目录里的 tag 和站点的镜像仓库", "imageValue": "镜像(repository:tag)", diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 9fe6d6f..6eac1fc 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -4,7 +4,7 @@ import type { ApplyFlags, ClusterInfo, Input as CatalogInput, Plan, PlanRequest, import { cn } from "@swiss/lib/utils"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { productChoices, unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; -import { inputProblem, setInputOverrides, type InputRow } from "@swiss/lib/inputs"; +import { inputError, inputProblem, setInputOverrides, type InputRow } from "@swiss/lib/inputs"; import { FEATURES, featureOn } from "@swiss/lib/profile"; import { layersOf } from "@swiss/components/Provenance"; import { @@ -323,7 +323,8 @@ export function DeploySettings({ {inputs?.map(({ input, answer, from }) => { const value = form.inputs[input.key] ?? ""; - const problem = inputProblem(input, value); + const error = inputError({ input, answer }, value); + const problem = error === "required" ? "required" : error && inputProblem(input, value.trim() || answer || ""); return (
onChange({ inputs: { ...form.inputs, [input.key]: v } })} suggestion={answer} - placeholder={answer ?? `e.g. ${String(input.example)}`} + placeholder={answer} + invalid={!!error} /> {problem &&

{problem}

}
@@ -848,11 +851,14 @@ function Row({ note, hint, caption, + aside, toggle, children, muted, }: { label: string; + // Muted text after the label's hint icon, e.g. an input's example. + aside?: string; note?: string; // Longer hover text when note stays short (or when note is absent). hint?: string; @@ -879,6 +885,7 @@ function Row({ {label} + {aside && {aside}} {caption ? (

{caption}

) : null} @@ -1041,12 +1048,14 @@ function Suggest({ suggestion, placeholder, disabled, + invalid, }: { value: string; onChange: (v: string) => void; suggestion?: string; placeholder?: string; disabled?: boolean; + invalid?: boolean; }) { const s = useSuggestion(value, suggestion, disabled); return ( @@ -1057,7 +1066,8 @@ function Suggest({ onKeyDown={(e) => accept(e, s.offer, onChange)} disabled={disabled} placeholder={placeholder} - className={cn(s.hint && "pr-11")} + aria-invalid={invalid || undefined} + className={cn(s.hint && "pr-11", invalid && "border-destructive")} {...s.focus} /> {s.hint && } diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index 25afb25..8da4d74 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Input, SiteProfile } from "./api"; -import { fillTemplate, inputProblem, inputRows, setInputOverrides } from "./inputs"; +import { fillTemplate, inputError, inputProblem, inputRows, inputsAnswered, setInputOverrides } from "./inputs"; const master: Input = { key: "storageKVOffload.mooncake.master", @@ -38,17 +38,21 @@ describe("inputRows", () => { expect(inputRows([tuned], site({}), "a/b", "m")).toEqual([{ input: tuned, answer: "tuned:1", from: "variant" }]); expect(inputRows([tuned], site({ [master.key]: "site:1" }), "a/b", "m")[0].from).toBe("site"); expect(inputRows([{ ...port, default: 8000 }], site({}), "a/b", "m")[0].answer).toBe("8000"); + const values = { storageKVOffload: { mooncake: { master: "tuned:1" } } }; + expect(inputRows([master], site({}), "a/b", "m", values)).toEqual([{ input: master, answer: "tuned:1", from: "variant" }]); + expect(inputRows([tuned], site({}), "a/b", "m", { storageKVOffload: { mooncake: { master: "base:1" } } })[0].answer).toBe("tuned:1"); expect(inputRows([master], undefined, "a/b", "m")).toEqual([{ input: master }]); }); it("shows an upgrade the release's own answer over the site's", () => { const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; - expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); + expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", undefined, release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); }); }); describe("inputProblem", () => { - it("checks a typed answer the way swissd will", () => { - expect(inputProblem(master, "")).toBeUndefined(); + it("checks an answer the way swissd will", () => { + expect(inputProblem(master, "")).toBe("must match [^\\s:/]+:[0-9]+"); + expect(inputProblem({ ...master, pattern: undefined }, "")).toBeUndefined(); expect(inputProblem(master, "mc:50051")).toBeUndefined(); expect(inputProblem(master, "no-port")).toBe("must match [^\\s:/]+:[0-9]+"); expect(inputProblem(port, "80.5")).toBe("a whole number"); @@ -56,6 +60,28 @@ describe("inputProblem", () => { }); }); +describe("inputError", () => { + it("is required with no answer, typed or the row's", () => { + expect(inputError({ input: master })).toBe("required"); + expect(inputError({ input: master }, " ")).toBe("required"); + expect(inputError({ input: master, answer: "" })).toBe("required"); + expect(inputError({ input: port, answer: "" })).toBe("required"); + expect(inputError({ input: { ...master, pattern: undefined }, answer: "" })).toBeUndefined(); + }); + it("takes the row's answer when nothing is typed, and the typed one over it", () => { + expect(inputError({ input: master, answer: "site:1" })).toBeUndefined(); + expect(inputError({ input: master }, "mc:50051")).toBeUndefined(); + expect(inputError({ input: master, answer: "site:1" }, "no-port")).toBe("invalid"); + expect(inputError({ input: master, answer: "no-port" })).toBe("invalid"); + }); + it("answers every row before a compose", () => { + const rows = [{ input: master, answer: "site:1" }, { input: port }]; + expect(inputsAnswered(rows, {})).toBe(false); + expect(inputsAnswered(rows, { [port.key]: "8000" })).toBe(true); + expect(inputsAnswered([], {})).toBe(true); + }); +}); + describe("setInputOverrides", () => { it("sends only typed answers, typed, beside the other overrides in a section", () => { const overrides: Record = { storageKVOffload: { enabled: true }, service: "dropped" }; diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index c99ca5b..31ec336 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -28,12 +28,13 @@ export function answerAt(tree: Record | undefined, key: string) } // The order swissd answers in: the release's form layer (carried forward on upgrade), -// the site profile, then the input's default. +// the site profile, the input's default, then the variant's own values. export function inputRows( inputs: Input[] | undefined, profile: SiteProfile | undefined, hf: string, model: string, + variantValues?: Record, release?: Record, ): InputRow[] { return (inputs ?? []).map((input) => { @@ -42,14 +43,15 @@ export function inputRows( const v = profile?.inputs?.[input.key]; const site = v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v); if (site !== undefined) return { input, answer: site, from: "site" }; - return input.default !== undefined && input.default !== null ? { input, answer: String(input.default), from: "variant" } : { input }; + if (input.default !== undefined && input.default !== null) return { input, answer: String(input.default), from: "variant" }; + const base = answerAt(variantValues, input.key); + return base !== undefined ? { input, answer: base, from: "variant" } : { input }; }); } -// What swissd would refuse a typed answer for. Empty is fine: the row's answer applies. +// What swissd would refuse an answer for. export function inputProblem(input: Input, text: string): string | undefined { const t = text.trim(); - if (!t) return undefined; switch (input.type ?? "string") { case "integer": return /^-?\d+$/.test(t) ? undefined : "a whole number"; @@ -65,6 +67,16 @@ export function inputProblem(input: Input, text: string): string | undefined { } } +// Why a deploy cannot go with the row's answer, the typed one or else the row's own: +// it has none, or one the input does not take. +export function inputError(row: InputRow, text = ""): "required" | "invalid" | undefined { + const answer = text.trim() || row.answer; + if (!answer) return answer === undefined || inputProblem(row.input, "") ? "required" : undefined; + return inputProblem(row.input, answer) ? "invalid" : undefined; +} + +export const inputsAnswered = (rows: InputRow[], answers: Record) => rows.every((row) => !inputError(row, answers[row.input.key])); + function typed(input: Input, text: string): unknown { switch (input.type ?? "string") { case "integer": diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index 9466864..e15e76f 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -12,7 +12,7 @@ import { CatalogBadge, CatalogGate, SchemaBadge, useCatalogChoice, withCatalog } import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; import { isChartRange } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; -import { inputRows } from "@swiss/lib/inputs"; +import { inputRows, inputsAnswered } from "@swiss/lib/inputs"; import { deployCapacity, pickableProducts, unlistedProducts, vendorResources } from "@swiss/lib/gpu"; import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { InstallMethodChoice } from "@swiss/components/InstallMethod"; @@ -160,6 +160,7 @@ export function Deploy() { variant, { name: variant.chart.name, version: chart.resolved ?? variant.chart.version }, ); + const inputs = inputRows(variant.inputs, profile.data?.profile, m.source?.hf ?? "", name, variant.values); if (!cluster.data?.allowDeploy) { return ( @@ -266,7 +267,7 @@ export function Deploy() { serviceIdPlaceholder={name} localPathDefault={model.data.localPath} localPathPlaceholder={model.data.pathTemplate} - inputs={inputRows(model.data.variant.inputs, profile.data?.profile, model.data.model.source?.hf ?? "", name)} + inputs={inputs} image={imageOf(variant, model.data.imageRepository)} supportedGPUs={variant?.requires.gpuProduct} clusterGPUs={pickableProducts(nodes.data?.nodes, variant?.requires.vendor, profile.data?.profile?.nodes?.gpus, form.gpuResource)} @@ -289,7 +290,7 @@ export function Deploy() { onTab={setTab} onCompose={() => planM.mutate()} composing={planM.isPending} - composeDisabled={!form.serviceId} + composeDisabled={!form.serviceId || !inputsAnswered(inputs, form.inputs)} applyWith={method} applyFlags={applyFlags(form)} onDiff={setDiff} diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx index 639863a..2174c7a 100644 --- a/web/src/modules/swiss/routes/Upgrade.tsx +++ b/web/src/modules/swiss/routes/Upgrade.tsx @@ -30,7 +30,7 @@ import { ErrorState, Loading } from "@swiss/components/States"; import { CatalogBadge, SchemaBadge, releaseCatalog as catalogOfRelease } from "@swiss/components/CatalogChoice"; import { movableCatalogs } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; -import { inputRows } from "@swiss/lib/inputs"; +import { inputRows, inputsAnswered } from "@swiss/lib/inputs"; import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; import { CatalogModelDiff } from "@swiss/components/CatalogModelDiff"; import { VariantCard } from "@swiss/components/VariantCard"; @@ -237,6 +237,7 @@ 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 inputs = inputRows(pin?.inputs, profile.data?.profile, entry.data?.model.source?.hf ?? "", current.data?.source.model ?? "", pin?.values, current.data?.layers?.form); if (!cluster.data?.allowDeploy) { return ( @@ -470,13 +471,7 @@ export function Upgrade() { } localPathDefault={entry.data?.localPath} localPathPlaceholder={entry.data?.pathTemplate} - inputs={inputRows( - entry.data?.variant.inputs, - profile.data?.profile, - entry.data?.model.source?.hf ?? "", - current.data?.source.model ?? "", - current.data?.layers?.form, - )} + inputs={inputs} image={imageOf(entry.data?.variant, entry.data?.imageRepository)} cache={cache} lockIdentity @@ -494,6 +489,7 @@ export function Upgrade() { onTab={setTab} onCompose={() => planM.mutate()} composing={planM.isPending} + composeDisabled={!rollbackTo && !inputsAnswered(inputs, form.inputs)} composeLabel={planM.isPending ? "Composing…" : plan ? "Recompose upgrade" : "Compose upgrade"} applyFlags={applyFlags(form)} onDiff={setDiff} From 5ac1e422555167000bcc94253a6a1cb93b44a2d3 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 18:05:55 +0800 Subject: [PATCH 8/9] feat(web): edit the site profile's input answers, and pick between an input's answers - Both site profile pages (inferences and swiss) show and edit `inputs` as a key/value table. A value reads as the YAML tab reads it: 8000 is a number, true a boolean, quotes keep a string, since swissd checks an answer's type against the input's. - The inferences deploy form lists an input's answers (the release's, the site profile's, the variant default) when its empty field is clicked, where more than one is found. - An input is checked once typing pauses for 500ms, its pattern compiled once; the deploy fields turn off the browser's autofill list, which covered their own suggestion. --- .../modules/inferences/SiteProfilePage.tsx | 29 +++++++++++ .../inferences/components/DeployForm.test.tsx | 9 ++++ .../inferences/components/DeployForm.tsx | 45 ++++++++++++++--- .../inferences/components/DeploySheet.tsx | 6 ++- .../components/ProfileForm.test.tsx | 15 ++++++ .../inferences/components/ProfileForm.tsx | 8 ++- web/src/modules/inferences/locales/en-US.json | 9 ++++ web/src/modules/inferences/locales/zh-CN.json | 9 ++++ .../modules/inferences/profile-lib.test.ts | 10 ++++ .../swiss/components/DeploySettings.tsx | 10 ++-- .../swiss/components/ProfileForm.test.tsx | 16 ++++++ .../modules/swiss/components/ProfileForm.tsx | 13 ++++- web/src/modules/swiss/lib/inputs.test.ts | 33 +++++++++++- web/src/modules/swiss/lib/inputs.ts | 50 +++++++++++++++---- web/src/modules/swiss/lib/useDebounced.ts | 11 ++++ web/src/modules/swiss/routes/Deploy.tsx | 6 ++- web/src/modules/swiss/routes/SiteProfile.tsx | 14 +++++- web/src/modules/swiss/routes/Upgrade.tsx | 6 ++- 18 files changed, 270 insertions(+), 29 deletions(-) create mode 100644 web/src/modules/inferences/components/ProfileForm.test.tsx create mode 100644 web/src/modules/swiss/components/ProfileForm.test.tsx create mode 100644 web/src/modules/swiss/lib/useDebounced.ts diff --git a/web/src/modules/inferences/SiteProfilePage.tsx b/web/src/modules/inferences/SiteProfilePage.tsx index d331cc5..7fc12ca 100644 --- a/web/src/modules/inferences/SiteProfilePage.tsx +++ b/web/src/modules/inferences/SiteProfilePage.tsx @@ -19,6 +19,12 @@ import { SheetHeader, SheetTitle, Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@modelsphere/ui"; import { SlidersHorizontal, TriangleAlert } from "lucide-react"; import { useModulePath } from "@/shell"; @@ -30,6 +36,7 @@ import { useT } from "@/modules/inferences/i18n"; import { ProfileEditorBody, SaveButton, useProfileEditor } from "@/modules/inferences/components/ProfileEditor"; import { catalogTokenKey, listSecrets } from "@/modules/inferences/catalog-write-api"; import { markedWritable } from "@/modules/inferences/catalog-write-lib"; +import { answerText } from "@swiss/lib/inputs"; export function SiteProfilePage() { const t = useT(); @@ -158,6 +165,28 @@ function ProfileView({ p }: { p: SiteProfile }) { { label: f("pathTemplate"), value: mono(p.model?.pathTemplate) }, { label: f("overrides"), value: kv(p.model?.overrides) }, ])} + + {p.inputs && Object.keys(p.inputs).length ? ( + + + + {f("key")} + {f("value")} + + + + {Object.entries(p.inputs).map(([k, x]) => ( + + {k} + {answerText(x)} + + ))} + +
+ ) : ( + v(undefined) + )} +
{card("routing", [ { label: f("routeEnabled"), value: onOff(!!p.route?.enabled) }, { label: f("gateway"), value: mono(p.route?.gateway) }, diff --git a/web/src/modules/inferences/components/DeployForm.test.tsx b/web/src/modules/inferences/components/DeployForm.test.tsx index 1c4fc5d..5c8010f 100644 --- a/web/src/modules/inferences/components/DeployForm.test.tsx +++ b/web/src/modules/inferences/components/DeployForm.test.tsx @@ -250,6 +250,15 @@ describe("variant inputs", () => { expect(html).toContain('value="no-port"'); expect([en, zh].some((l) => html.includes(l.deploy.fields.inputProblem.string.replace(" {pattern}", "")))).toBe(true); }); + it("offers a pick between the answers when more than one is found, the applying one shown", () => { + const choices = [{ answer: "site.svc:50051", from: "site" as const }, { answer: "tuned.svc:50051", from: "variant" as const }]; + const html = render({ ...EMPTY, serviceId: "model" }, [{ input: master, answer: "site.svc:50051", from: "site", choices }]); + const single = render({ ...EMPTY, serviceId: "model" }, [{ input: master, answer: "site.svc:50051", from: "site" }]); + // The list opens on a click; closed, the field is the plain one. + expect(html).not.toContain('role="listbox"'); + expect(html).toBe(single); + expect(html).toContain('placeholder="site.svc:50051"'); + }); it("sends a typed answer at the input's key", () => { const form = { ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } }; expect(planRequest(form, { model: "model", inputs: [master] }).overrides).toMatchObject({ storageKVOffload: { mooncake: { master: "mine.svc:50051" } } }); diff --git a/web/src/modules/inferences/components/DeployForm.tsx b/web/src/modules/inferences/components/DeployForm.tsx index 12a97fd..41dcef2 100644 --- a/web/src/modules/inferences/components/DeployForm.tsx +++ b/web/src/modules/inferences/components/DeployForm.tsx @@ -25,7 +25,8 @@ import { Plus, RefreshCw, X } from "lucide-react"; import type { ClusterInfo } from "@swiss/lib/api"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; -import { inputError, type InputRow } from "@swiss/lib/inputs"; +import { CHECK_MS, inputError, type InputRow } from "@swiss/lib/inputs"; +import { useDebounced } from "@swiss/lib/useDebounced"; import { effective, imageChoices, type Form, type Toleration } from "@swiss/components/DeploySettings"; import { MAX_VALUES, rejectKey } from "@swiss/lib/routeDirectives"; import { FALLBACK_ROUTE_OPTIONS, routeDirectives, runtimeValue, type RouteDirective, type RouteOptions } from "@/modules/inferences/routeOptions"; @@ -66,6 +67,7 @@ export interface DeployFormProps { export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs }: DeployFormProps) { const t = useT(); + const checked = useDebounced(form.inputs, CHECK_MS); const on = effective(form); const f = (k: string) => t(`deploy.fields.${k}`); const forced = unlistedProducts(gpuListed, form.gpuProducts); @@ -152,9 +154,10 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster onChange({ localPath })} suggestion={defaults.localPath} placeholder={defaults.localPath || defaults.localPathTemplate} /> - {inputs?.map(({ input, answer }) => { + {inputs?.map(({ input, answer, choices }) => { const value = form.inputs[input.key] ?? ""; - const error = inputError({ input, answer }, value); + const error = inputError({ input, answer }, checked[input.key]); + const set = (v: string) => onChange({ inputs: { ...form.inputs, [input.key]: v } }); return ( - onChange({ inputs: { ...form.inputs, [input.key]: v } })} suggestion={answer} placeholder={answer} /> + ({ value: c.answer, label: f(`inputFrom.${c.from}`) }))} /> ); })} @@ -493,20 +496,28 @@ export function RouteExtras({ form, onChange, options, showRuntime = false, mode } // Tab fills an empty field with the default it shows, as swiss's own form does. -function Suggest({ value, onChange, suggestion, bare, placeholder, className, ...props }: Omit, "value" | "onChange"> & { value: string; onChange: (v: string) => void; suggestion?: string; bare?: boolean }) { +// choices list under the field when the empty field is clicked; typing closes the list. +function Suggest({ value, onChange, suggestion, bare, placeholder, className, choices, ...props }: Omit, "value" | "onChange"> & { value: string; onChange: (v: string) => void; suggestion?: string; bare?: boolean; choices?: { value: string; label: string }[] }) { const [focused, setFocused] = useState(false); + const [listed, setListed] = useState(false); const offer = !value && !props.disabled ? suggestion : undefined; const hint = !!offer && focused; const Control = bare ? Input : FieldInput; return (
onChange(e.target.value)} onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} + onBlur={() => { + setFocused(false); + setListed(false); + }} + onClick={() => setListed(!!choices?.length && !value)} onKeyDown={(e) => { + if (e.key === "Escape") setListed(false); if (e.key !== "Tab" || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || !offer) return; e.preventDefault(); onChange(offer); @@ -517,6 +528,28 @@ function Suggest({ value, onChange, suggestion, bare, placeholder, className, .. {hint && ( tab )} + {listed && !value && choices && ( +
+ {choices.map((c) => ( + // mousedown, not click: the field keeps focus, and its blur does not close the list first. + + ))} +
+ )}
); } diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index 7548fe8..c55cf89 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -34,7 +34,8 @@ import { Provenance } from "@swiss/components/Provenance"; import { DiffView } from "@swiss/components/DiffView"; import { deployCapacity, gpuShort, pickableProducts, productChoices, unlistedProducts, vendorResources } from "@swiss/lib/gpu"; import { isChartRange, movableCatalogs } from "@swiss/lib/upgrade"; -import { inputRows, inputsAnswered } from "@swiss/lib/inputs"; +import { CHECK_MS, inputRows, inputsAnswered } from "@swiss/lib/inputs"; +import { useDebounced } from "@swiss/lib/useDebounced"; import { cacheCard } from "@swiss/lib/cache"; import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { useT } from "@/modules/inferences/i18n"; @@ -148,6 +149,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const [recoveryPatch, setRecoveryPatch] = useState>>({}); const recovery = { ...initialRecovery, ...recoveryPatch }; const [form, setForm] = useState(initial); + const checkedInputs = useDebounced(form.inputs, CHECK_MS); const catalogRoute = upgrade && current.data ? initial.route || initial.serviceId || target.release : undefined; const routeOptions = useQuery({ queryKey: ["route-options", catalogRoute ?? ""], @@ -519,7 +521,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( {step === "form" ? ( <> - diff --git a/web/src/modules/inferences/components/ProfileForm.test.tsx b/web/src/modules/inferences/components/ProfileForm.test.tsx new file mode 100644 index 0000000..d562189 --- /dev/null +++ b/web/src/modules/inferences/components/ProfileForm.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ProfileForm } from "./ProfileForm"; + +describe("variant inputs", () => { + it("lists the profile's answers as rows, a string that reads as a number quoted", () => { + const html = renderToStaticMarkup( + + {}} submitted={false} /> + , + ); + for (const v of ["a.master", "mc:1", "a.port", "8000", "a.tag", ""8000""]) expect(html).toContain(`value="${v}"`); + }); +}); diff --git a/web/src/modules/inferences/components/ProfileForm.tsx b/web/src/modules/inferences/components/ProfileForm.tsx index 0b407ef..407b011 100644 --- a/web/src/modules/inferences/components/ProfileForm.tsx +++ b/web/src/modules/inferences/components/ProfileForm.tsx @@ -4,6 +4,7 @@ import { Plus, X } from "lucide-react"; import type { RouteAuth, SiteProfile } from "@swiss/lib/api"; import { useT } from "@/modules/inferences/i18n"; import { fromRows, markDefault, toRows, withNodes, type Row } from "@/modules/inferences/profile-lib"; +import { answersText, parseAnswers } from "@swiss/lib/inputs"; import { GpuTable } from "@/modules/inferences/components/GpuResources"; import { catalogErrors, FEATURES, featureOn, withFeature, type CatalogError } from "@swiss/lib/profile"; @@ -20,7 +21,7 @@ export interface TokenField { onChange: (v: string) => void; } -const GROUPS = ["placement", "catalog", "model", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const; +const GROUPS = ["placement", "catalog", "model", "inputs", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const; export function ProfileForm({ value: p, @@ -126,6 +127,11 @@ export function ProfileForm({ , )} + {group( + "inputs", + set({ inputs: parseAnswers(o) })} />, + )} + {group( "routing", <> diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 2ac64f0..f9637b5 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -580,6 +580,11 @@ "localPathHint": "Overrides the site profile's path template", "inputExample": "e.g. {example}", "inputRequired": "Required: no default or site answer", + "inputFrom": { + "release": "Release", + "site": "Site profile", + "variant": "Variant default" + }, "inputProblem": { "string": "Must match {pattern}", "integer": "Enter a whole number", @@ -823,6 +828,7 @@ "placement": "Placement", "catalog": "Catalogs, charts and images", "model": "Model paths", + "inputs": "Variant inputs", "routing": "Routing", "auth": "Entrypoint auth", "scaler": "Cache and scaler", @@ -835,6 +841,7 @@ "placement": "Where releases land. The scheduler and priority class apply before the deploy form merges, so a deploy can override either.", "catalog": "Where the models come from, and the registries the catalog deliberately does not name.", "model": "Built from the catalog's source.hf. Variables: hf (the full repo id), org and name (its halves), model (the catalog entry name); see the field's example for the syntax.", + "inputs": "Answers to the inputs catalog variants declare, by chart key. Only a variant that declares a key takes its answer, and a deploy can still override it. A string takes the model path variables.", "routing": "The openresty entrypoint every model publishes a route on.", "auth": "Used only by the serving and health checks. This document is a ConfigMap, so it names a Secret rather than holding the key.", "pdScale": "swissd's PD slice autoscaler, for releases planned with PD on and the form SLO on. Field names follow the LLMScaler's.", @@ -907,6 +914,8 @@ "pathTemplate": "Path template", "overrides": "Path overrides", "overridesHint": "Keyed by catalog model name, for weights that do not sit where the template says", + "inputs": "Answers", + "inputsHint": "8000 reads as a number and true as a boolean; quote one to keep it a string", "key": "Key", "value": "Value", "add": "Add", diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index 0683dbe..dcb7b1c 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -569,6 +569,11 @@ "localPathHint": "覆盖站点配置里的路径模板", "inputExample": "例如 {example}", "inputRequired": "必填:变体没有默认值,站点配置也没有答案", + "inputFrom": { + "release": "当前发布", + "site": "站点配置", + "variant": "变体默认值" + }, "inputProblem": { "string": "需匹配 {pattern}", "integer": "请输入整数", @@ -812,6 +817,7 @@ "placement": "调度", "catalog": "目录、Chart 与镜像", "model": "模型路径", + "inputs": "变体输入", "routing": "路由", "auth": "入口鉴权", "scaler": "缓存与伸缩", @@ -824,6 +830,7 @@ "placement": "发布到哪里。调度器和优先级类在部署表单合并之前生效,部署时可以覆盖。", "catalog": "模型从哪来,以及目录刻意不写的 chart 和镜像仓库。", "model": "根据目录的 source.hf 生成路径。可用变量:hf(完整 repo id)、org 和 name(它的两段)、model(目录里的模型名),写法见输入框里的示例。", + "inputs": "按 chart 键回答目录变体声明的输入。只有声明了该键的变体会采用,部署时仍可覆盖。字符串可使用模型路径的变量。", "routing": "每个模型发布路由的 openresty 入口。", "auth": "只用于服务检查。这份配置是 ConfigMap,所以只写 Secret 的名字,不保存密钥本身。", "pdScale": "swissd 的 PD slice 自动伸缩,作用于开启 PD 且表单开启 SLO 的发布。字段名沿用 LLMScaler。", @@ -896,6 +903,8 @@ "pathTemplate": "路径模板", "overrides": "路径覆盖", "overridesHint": "按目录里的模型名,给不在模板路径下的权重单独指定路径", + "inputs": "答案", + "inputsHint": "8000 读作数字,true 读作布尔值;加引号则保持为字符串", "key": "键", "value": "值", "add": "添加", diff --git a/web/src/modules/inferences/profile-lib.test.ts b/web/src/modules/inferences/profile-lib.test.ts index 5dfb1d8..0b3a9f5 100644 --- a/web/src/modules/inferences/profile-lib.test.ts +++ b/web/src/modules/inferences/profile-lib.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { SiteProfile } from "@swiss/lib/api"; import { fromRows, markDefault, profileErrors, toRows, withNodes } from "@/modules/inferences/profile-lib"; +import { answersText, parseAnswers } from "@swiss/lib/inputs"; describe("key-value rows", () => { it("round-trips a table and drops rows without a key", () => { @@ -51,3 +52,12 @@ describe("nodes", () => { }); }); + +describe("input answers", () => { + it("maps a profile's answers to rows and back", () => { + const inputs = { "a.master": "mc:1", "a.port": 8000, "a.on": true }; + expect(parseAnswers(answersText(inputs))).toEqual(inputs); + expect(parseAnswers(fromRows(toRows(answersText(inputs))))).toEqual(inputs); + expect(parseAnswers(fromRows([]))).toBeUndefined(); + }); +}); diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 6eac1fc..74182c1 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -4,7 +4,8 @@ import type { ApplyFlags, ClusterInfo, Input as CatalogInput, Plan, PlanRequest, import { cn } from "@swiss/lib/utils"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { productChoices, unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; -import { inputError, inputProblem, setInputOverrides, type InputRow } from "@swiss/lib/inputs"; +import { CHECK_MS, inputError, inputProblem, setInputOverrides, type InputRow } from "@swiss/lib/inputs"; +import { useDebounced } from "@swiss/lib/useDebounced"; import { FEATURES, featureOn } from "@swiss/lib/profile"; import { layersOf } from "@swiss/components/Provenance"; import { @@ -228,6 +229,7 @@ export function DeploySettings({ capacity?: CapacityProps; cache?: CacheCard; }) { + const checked = useDebounced(form.inputs, CHECK_MS); const set = (k: keyof Form) => (e: React.ChangeEvent) => @@ -323,8 +325,9 @@ export function DeploySettings({ {inputs?.map(({ input, answer, from }) => { const value = form.inputs[input.key] ?? ""; - const error = inputError({ input, answer }, value); - const problem = error === "required" ? "required" : error && inputProblem(input, value.trim() || answer || ""); + const typed = checked[input.key] ?? ""; + const error = inputError({ input, answer }, typed); + const problem = error === "required" ? "required" : error && inputProblem(input, typed.trim() || answer || ""); return ( onChange(e.target.value)} onKeyDown={(e) => accept(e, s.offer, onChange)} diff --git a/web/src/modules/swiss/components/ProfileForm.test.tsx b/web/src/modules/swiss/components/ProfileForm.test.tsx new file mode 100644 index 0000000..44c4da5 --- /dev/null +++ b/web/src/modules/swiss/components/ProfileForm.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ProfileForm } from "./ProfileForm"; + +describe("variant inputs", () => { + it("lists the profile's answers as rows, a string that reads as a number quoted", () => { + const html = renderToStaticMarkup( + + {}} /> + , + ); + expect(html).toContain("Variant inputs"); + for (const v of ["a.master", "mc:1", "a.port", "8000", "a.tag", ""8000""]) expect(html).toContain(`value="${v}"`); + }); +}); diff --git a/web/src/modules/swiss/components/ProfileForm.tsx b/web/src/modules/swiss/components/ProfileForm.tsx index ab79ae0..38506ba 100644 --- a/web/src/modules/swiss/components/ProfileForm.tsx +++ b/web/src/modules/swiss/components/ProfileForm.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { FolderTree, Gauge, Globe, KeyRound, Library, MapPin, Plus, Route, Scaling, ToggleRight, TriangleAlert, X, type LucideIcon } from "lucide-react"; +import { FolderTree, Gauge, Globe, KeyRound, Library, ListChecks, MapPin, Plus, Route, Scaling, ToggleRight, TriangleAlert, X, type LucideIcon } from "lucide-react"; import { api, type Site, type SiteProfile } from "@swiss/lib/api"; import { Button } from "@swiss/components/ui/button"; import { Card, CardContent, CardHeader } from "@swiss/components/ui/card"; @@ -9,6 +9,7 @@ import { Input } from "@swiss/components/ui/input"; import { Switch } from "@swiss/components/ui/switch"; import { catalogErrors, FEATURES, featureOn, withFeature, type FeatureKey } from "@swiss/lib/profile"; import { GpuTable } from "@swiss/components/GpuResources"; +import { answersText, parseAnswers } from "@swiss/lib/inputs"; import { cn } from "@swiss/lib/utils"; const FEATURE_LABELS: Record = { @@ -125,6 +126,16 @@ export function ProfileForm({ +
+ + set("inputs", parseAnswers(inputs))} /> + +
+
setIn("route", { enabled: enabled || undefined })} /> diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index 8da4d74..a98ceb3 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Input, SiteProfile } from "./api"; -import { fillTemplate, inputError, inputProblem, inputRows, inputsAnswered, setInputOverrides } from "./inputs"; +import { answerText, fillTemplate, inputError, inputProblem, inputRows, inputsAnswered, parseAnswer, setInputOverrides } from "./inputs"; const master: Input = { key: "storageKVOffload.mooncake.master", @@ -45,7 +45,18 @@ describe("inputRows", () => { }); it("shows an upgrade the release's own answer over the site's", () => { const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; - expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", undefined, release)[0]).toEqual({ input: master, answer: "mine:1", from: "release" }); + expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", undefined, release)[0]).toMatchObject({ input: master, answer: "mine:1", from: "release" }); + }); + it("lists the answers to pick between when there is more than one", () => { + const tuned = { ...master, default: "tuned:1" }; + expect(inputRows([tuned], site({ [master.key]: "site:1" }), "a/b", "m")[0].choices).toEqual([ + { answer: "site:1", from: "site" }, + { answer: "tuned:1", from: "variant" }, + ]); + const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; + expect(inputRows([tuned], site({ [master.key]: "site:1" }), "a/b", "m", undefined, release)[0].choices?.map((c) => c.from)).toEqual(["release", "site", "variant"]); + expect(inputRows([tuned], site({ [master.key]: "tuned:1" }), "a/b", "m")[0]).toEqual({ input: tuned, answer: "tuned:1", from: "site" }); + expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", { storageKVOffload: { mooncake: { master: "" } } })[0].choices).toBeUndefined(); }); }); @@ -96,3 +107,21 @@ describe("setInputOverrides", () => { }); }); }); + +describe("input answers", () => { + it("reads a typed answer as the YAML tab does", () => { + expect(parseAnswer("mc.svc:50051")).toBe("mc.svc:50051"); + expect(parseAnswer("8000")).toBe(8000); + expect(parseAnswer("-1")).toBe(-1); + expect(parseAnswer("true")).toBe(true); + expect(parseAnswer("false")).toBe(false); + expect(parseAnswer('"8000"')).toBe("8000"); + expect(parseAnswer('"true"')).toBe("true"); + expect(parseAnswer('"quoted"')).toBe('"quoted"'); + expect(parseAnswer("")).toBe(""); + }); + it("shows an answer so it reads back the same", () => { + for (const v of ["mc-{{model}}.svc:50051", 8000, true, "8000", "false", '"quoted"', ""]) expect(parseAnswer(answerText(v))).toBe(v); + expect(answerText("8000")).toBe('"8000"'); + }); +}); diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index 31ec336..021d2fc 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -1,10 +1,17 @@ import type { Input, SiteProfile } from "./api"; +export interface InputChoice { + answer: string; + from: "release" | "site" | "variant"; +} + // answer applies when the field is left empty; none means the deployer must fill it. +// choices, when more than one answer is found, are the ones to pick between, answer first. export interface InputRow { input: Input; answer?: string; - from?: "release" | "site" | "variant"; + from?: InputChoice["from"]; + choices?: InputChoice[]; } // fillTemplate mirrors swissd's site.fill, and is undefined where swissd refuses. @@ -38,17 +45,26 @@ export function inputRows( release?: Record, ): InputRow[] { return (inputs ?? []).map((input) => { - const kept = answerAt(release, input.key); - if (kept !== undefined) return { input, answer: kept, from: "release" }; const v = profile?.inputs?.[input.key]; - const site = v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v); - if (site !== undefined) return { input, answer: site, from: "site" }; - if (input.default !== undefined && input.default !== null) return { input, answer: String(input.default), from: "variant" }; - const base = answerAt(variantValues, input.key); - return base !== undefined ? { input, answer: base, from: "variant" } : { input }; + const found: [string | undefined, InputChoice["from"]][] = [ + [answerAt(release, input.key), "release"], + [v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v), "site"], + [input.default !== undefined && input.default !== null ? String(input.default) : answerAt(variantValues, input.key), "variant"], + ]; + const answers = found.flatMap(([answer, from]) => (answer === undefined ? [] : [{ answer, from }])); + if (!answers.length) return { input }; + // The form sends no empty answer, so "" is no choice; one answer is listed once, under its first source. + const choices = answers.filter((c, i) => c.answer && answers.findIndex((d) => d.answer === c.answer) === i); + return { input, ...answers[0], ...(choices.length > 1 && { choices }) }; }); } +// An input is checked once typing pauses for this long, not on every key. +export const CHECK_MS = 500; + +const patterns = new Map(); +const patternOf = (p: string) => patterns.get(p) ?? patterns.set(p, new RegExp(`^(?:${p})$`, "u")).get(p)!; + // What swissd would refuse an answer for. export function inputProblem(input: Input, text: string): string | undefined { const t = text.trim(); @@ -60,7 +76,7 @@ export function inputProblem(input: Input, text: string): string | undefined { default: if (!input.pattern) return undefined; try { - return new RegExp(`^(?:${input.pattern})$`, "u").test(t) ? undefined : `must match ${input.pattern}`; + return patternOf(input.pattern).test(t) ? undefined : `must match ${input.pattern}`; } catch { return undefined; // swissd's own check names a pattern it cannot read } @@ -103,3 +119,19 @@ export function setInputOverrides(overrides: Record, inputs: In node[keys[keys.length - 1]] = typed(input, text); } } + +type Answer = string | number | boolean; +const SCALAR = /^(-?\d+|true|false)$/; + +// An input answer as the YAML tab reads it: 8000 is a number, true a boolean, and +// quotes keep either a string. swissd checks the answer's type against the input's. +export const answerText = (v: Answer) => (typeof v === "string" && SCALAR.test(v) ? JSON.stringify(v) : String(v)); + +export function parseAnswer(text: string): Answer { + if (/^-?\d+$/.test(text)) return Number(text); + if (text === "true" || text === "false") return text === "true"; + return /^"(-?\d+|true|false)"$/.test(text) ? text.slice(1, -1) : text; +} + +export const answersText = (a?: Record) => a && Object.fromEntries(Object.entries(a).map(([k, v]) => [k, answerText(v)])); +export const parseAnswers = (r?: Record) => r && Object.fromEntries(Object.entries(r).map(([k, v]) => [k, parseAnswer(v)])); diff --git a/web/src/modules/swiss/lib/useDebounced.ts b/web/src/modules/swiss/lib/useDebounced.ts new file mode 100644 index 0000000..251c08c --- /dev/null +++ b/web/src/modules/swiss/lib/useDebounced.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +// value, once it has held still for ms. +export function useDebounced(value: T, ms: number): T { + const [settled, setSettled] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setSettled(value), ms); + return () => clearTimeout(timer); + }, [value, ms]); + return settled; +} diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index e15e76f..cc7d056 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -12,7 +12,8 @@ import { CatalogBadge, CatalogGate, SchemaBadge, useCatalogChoice, withCatalog } import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; import { isChartRange } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; -import { inputRows, inputsAnswered } from "@swiss/lib/inputs"; +import { CHECK_MS, inputRows, inputsAnswered } from "@swiss/lib/inputs"; +import { useDebounced } from "@swiss/lib/useDebounced"; import { deployCapacity, pickableProducts, unlistedProducts, vendorResources } from "@swiss/lib/gpu"; import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { InstallMethodChoice } from "@swiss/components/InstallMethod"; @@ -69,6 +70,7 @@ export function Deploy() { const [picked, setPicked] = useState(); const [form, setForm] = useState({ ...EMPTY, serviceId: name }); + const checkedInputs = useDebounced(form.inputs, CHECK_MS); // serviceId is the identity everything else is named after: the helm release, // the openresty route, the LLMScaler and the LLMSLORequirement. const release = form.serviceId; @@ -290,7 +292,7 @@ export function Deploy() { onTab={setTab} onCompose={() => planM.mutate()} composing={planM.isPending} - composeDisabled={!form.serviceId || !inputsAnswered(inputs, form.inputs)} + composeDisabled={!form.serviceId || !inputsAnswered(inputs, checkedInputs)} applyWith={method} applyFlags={applyFlags(form)} onDiff={setDiff} diff --git a/web/src/modules/swiss/routes/SiteProfile.tsx b/web/src/modules/swiss/routes/SiteProfile.tsx index bab8ff4..9c29aba 100644 --- a/web/src/modules/swiss/routes/SiteProfile.tsx +++ b/web/src/modules/swiss/routes/SiteProfile.tsx @@ -11,6 +11,7 @@ import { ErrorState, Loading } from "@swiss/components/States"; import { Code } from "@swiss/components/ui/code"; import { toYaml } from "@swiss/lib/yaml"; import { GpuTable } from "@swiss/components/GpuResources"; +import { answerText } from "@swiss/lib/inputs"; export function SiteProfile() { const [editing, setEditing] = useState(false); @@ -150,6 +151,17 @@ export function SiteProfile() {
)} +
+ {p.inputs && Object.keys(p.inputs).length > 0 ? ( + Object.entries(p.inputs).map(([k, v]) => ) + ) : ( + + )} +
+
-
{label}
+
{label}
(EMPTY); + const checkedInputs = useDebounced(form.inputs, CHECK_MS); const [plan, setPlan] = useState(null); const [diff, setDiff] = useState(null); const [applied, setApplied] = useState(null); @@ -489,7 +491,7 @@ export function Upgrade() { onTab={setTab} onCompose={() => planM.mutate()} composing={planM.isPending} - composeDisabled={!rollbackTo && !inputsAnswered(inputs, form.inputs)} + composeDisabled={!rollbackTo && !inputsAnswered(inputs, checkedInputs)} composeLabel={planM.isPending ? "Composing…" : plan ? "Recompose upgrade" : "Compose upgrade"} applyFlags={applyFlags(form)} onDiff={setDiff} From c71c3679e7b0c6f4a558208e25be6d18b2301a58 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 19:13:04 +0800 Subject: [PATCH 9/9] feat(web): save a typed input answer to the site profile once the deploy applies An input's key is any chart value, so a deployer who answers one by hand may want every later deploy at the site to take that answer. - Under an input field, a typed answer the input takes and the site does not already give can be ticked "Save to the site profile", naming the site's answer it replaces. Review (inferences) and the Plan tab (swiss) list what will be saved. - The save runs after the install or upgrade succeeds, so one abandoned before that leaves the profile as it was. It re-reads the profile and sets inputs[key] in the YAML text, keeping its comments and any edit made meanwhile; the value is typed as the input is. A failed save is a toast, and the deploy stands. - The swiss deploy form lists an input's answers when its empty field is clicked, as the inferences one does. --- .../inferences/components/DeployForm.test.tsx | 19 ++++++ .../inferences/components/DeployForm.tsx | 42 ++++++++----- .../inferences/components/DeploySheet.tsx | 45 +++++++++++++- web/src/modules/inferences/locales/en-US.json | 7 +++ web/src/modules/inferences/locales/zh-CN.json | 7 +++ .../swiss/components/DeploySettings.tsx | 60 +++++++++++++++++-- .../modules/swiss/components/SiteSaves.tsx | 55 +++++++++++++++++ web/src/modules/swiss/lib/inputs.test.ts | 26 +++++++- web/src/modules/swiss/lib/inputs.ts | 33 ++++++++-- web/src/modules/swiss/lib/siteAnswers.test.ts | 33 ++++++++++ web/src/modules/swiss/lib/siteAnswers.ts | 19 ++++++ web/src/modules/swiss/routes/Deploy.tsx | 16 ++++- web/src/modules/swiss/routes/Upgrade.tsx | 15 ++++- 13 files changed, 346 insertions(+), 31 deletions(-) create mode 100644 web/src/modules/swiss/components/SiteSaves.tsx create mode 100644 web/src/modules/swiss/lib/siteAnswers.test.ts create mode 100644 web/src/modules/swiss/lib/siteAnswers.ts diff --git a/web/src/modules/inferences/components/DeployForm.test.tsx b/web/src/modules/inferences/components/DeployForm.test.tsx index 5c8010f..d7b7200 100644 --- a/web/src/modules/inferences/components/DeployForm.test.tsx +++ b/web/src/modules/inferences/components/DeployForm.test.tsx @@ -259,6 +259,25 @@ describe("variant inputs", () => { expect(html).toBe(single); expect(html).toContain('placeholder="site.svc:50051"'); }); + it("offers to save a typed answer to the site profile, naming the one it replaces", () => { + const row: InputRow = { input: master, answer: "mc-m.svc:50051", from: "site", site: { answer: "mc-m.svc:50051", written: "mc-{{model}}.svc:50051" } }; + const remember = (form: Form, on?: Record) => + renderToStaticMarkup( + {}} submitted={false} target={null} defaults={{}} gpuOptions={[]} inputs={[row]} remember={on} onRemember={() => {}} + expanded={{ basic: false, resources: true, routing: false, advanced: false }} + onExpand={() => {}} idPrefix="test-" + />, + ); + const offers = (h: string) => [en, zh].some((l) => h.includes(l.deploy.fields.inputRemember)); + const typed = remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } }, {}); + expect(offers(typed)).toBe(true); + expect(typed).toContain("mc-{{model}}.svc:50051"); + expect(offers(remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "mc-m.svc:50051" } }, {}))).toBe(false); + expect(offers(remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "no-port" } }, {}))).toBe(false); + expect(offers(remember({ ...EMPTY, serviceId: "model" }, {}))).toBe(false); + expect(offers(remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } }))).toBe(false); + }); it("sends a typed answer at the input's key", () => { const form = { ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } }; expect(planRequest(form, { model: "model", inputs: [master] }).overrides).toMatchObject({ storageKVOffload: { mooncake: { master: "mine.svc:50051" } } }); diff --git a/web/src/modules/inferences/components/DeployForm.tsx b/web/src/modules/inferences/components/DeployForm.tsx index 41dcef2..02c5e8b 100644 --- a/web/src/modules/inferences/components/DeployForm.tsx +++ b/web/src/modules/inferences/components/DeployForm.tsx @@ -2,6 +2,7 @@ import { useId, useState, type ReactNode } from "react"; import { Badge, Button, + Checkbox, DataSelect, DropdownMenu, DropdownMenuContent, @@ -25,7 +26,7 @@ import { Plus, RefreshCw, X } from "lucide-react"; import type { ClusterInfo } from "@swiss/lib/api"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; -import { CHECK_MS, inputError, type InputRow } from "@swiss/lib/inputs"; +import { CHECK_MS, inputError, rememberable, type InputRow } from "@swiss/lib/inputs"; import { useDebounced } from "@swiss/lib/useDebounced"; import { effective, imageChoices, type Form, type Toleration } from "@swiss/components/DeploySettings"; import { MAX_VALUES, rejectKey } from "@swiss/lib/routeDirectives"; @@ -63,9 +64,12 @@ export interface DeployFormProps { recovery?: ReactNode; // The variant's inputs, each with the answer that applies when its field is left empty. inputs?: InputRow[]; + // Which typed answers to save to the site profile once the deploy applies; absent where it cannot be saved. + remember?: Record; + onRemember?: (key: string, on: boolean) => void; } -export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs }: DeployFormProps) { +export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs, remember, onRemember }: DeployFormProps) { const t = useT(); const checked = useDebounced(form.inputs, CHECK_MS); const on = effective(form); @@ -154,22 +158,32 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster onChange({ localPath })} suggestion={defaults.localPath} placeholder={defaults.localPath || defaults.localPathTemplate} /> - {inputs?.map(({ input, answer, choices }) => { + {inputs?.map((row) => { + const { input, answer, choices, site } = row; const value = form.inputs[input.key] ?? ""; const error = inputError({ input, answer }, checked[input.key]); const set = (v: string) => onChange({ inputs: { ...form.inputs, [input.key]: v } }); return ( - - ({ value: c.answer, label: f(`inputFrom.${c.from}`) }))} /> - +
+ + ({ value: c.answer, label: f(`inputFrom.${c.from}`) }))} /> + + {remember && onRemember && rememberable(row, checked[input.key]) && ( + + )} +
); })} {defaults.image && ( diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index c55cf89..aad8f5a 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -34,7 +34,9 @@ import { Provenance } from "@swiss/components/Provenance"; import { DiffView } from "@swiss/components/DiffView"; import { deployCapacity, gpuShort, pickableProducts, productChoices, unlistedProducts, vendorResources } from "@swiss/lib/gpu"; import { isChartRange, movableCatalogs } from "@swiss/lib/upgrade"; -import { CHECK_MS, inputRows, inputsAnswered } from "@swiss/lib/inputs"; +import { CHECK_MS, inputRows, inputsAnswered, siteSaves, type SiteSave } from "@swiss/lib/inputs"; +import { saveSiteAnswers } from "@swiss/lib/siteAnswers"; +import { messageOf, useToast } from "@swiss/components/ui/toast"; import { useDebounced } from "@swiss/lib/useDebounced"; import { cacheCard } from "@swiss/lib/cache"; import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; @@ -68,6 +70,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const p = useModulePath(); const navigate = useNavigate(); const qc = useQueryClient(); + const toast = useToast(); const upgrade = target.kind === "upgrade"; const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster }); @@ -150,6 +153,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const recovery = { ...initialRecovery, ...recoveryPatch }; const [form, setForm] = useState(initial); const checkedInputs = useDebounced(form.inputs, CHECK_MS); + const [remember, setRemember] = useState>({}); const catalogRoute = upgrade && current.data ? initial.route || initial.serviceId || target.release : undefined; const routeOptions = useQuery({ queryKey: ["route-options", catalogRoute ?? ""], @@ -199,6 +203,9 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const forceGpuProducts = unlistedProducts(v?.requires.gpuProduct, form.gpuProducts).length > 0; const change = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); const inputs = inputRows(v?.inputs, profile.data?.profile, entry.data?.model.source?.hf ?? "", modelName ?? "", v?.values, upgrade ? current.data?.layers?.form : undefined); + // A profile swissd did not parse is not written to from here. + const canRemember = profile.data?.yaml !== undefined && !profile.data.error && !!cluster.data?.allowDeploy; + const saves = canRemember ? siteSaves(inputs, form.inputs, remember) : []; // What a compose of f sends; the Override YAML editor's preview sends the same. const requestFor = (f: Form) => planRequest( { ...f, serviceId: f.serviceId || (upgrade ? f.serviceId : defaultServiceId(target.model)) }, @@ -273,6 +280,15 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( onSuccess: (r) => { setApplied(r); for (const key of REFRESH) qc.invalidateQueries({ queryKey: key }); + // After the deploy, not before: one abandoned at review leaves the profile as it was. + if (saves.length) + saveSiteAnswers(saves).then( + (saved) => { + qc.setQueryData(["profile"], saved); + toast.success(t("deploy.review.siteSaved", { keys: saves.map((s) => s.key).join(", ") })); + }, + (e) => toast.error(t("deploy.review.siteNotSaved", { error: messageOf(e) })), + ); onClose(); if (!upgrade) navigate(p(detailPath(plan!.release.name, plan!.release.namespace)), { state: { sloSetup: state.action === "install" && sloEnabled(plan!) } }); }, @@ -493,6 +509,8 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( idPrefix={ID_PREFIX} helmValues={readOnly ? undefined : valuesSource} inputs={inputs} + remember={canRemember ? remember : undefined} + onRemember={(key, on) => setRemember((r) => ({ ...r, [key]: on }))} />
@@ -512,6 +530,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( force={force} onForce={setForce} method={state.action === "install" ? method : live.data?.backend} + siteSaves={saves} /> ) )} @@ -556,6 +575,7 @@ export function Review({ force, onForce, method, + siteSaves, }: { upgrade: boolean; rollback?: boolean; @@ -572,6 +592,8 @@ export function Review({ onForce: (v: boolean) => void; // The install method: the one picked on an install, the release's own otherwise. method?: ApplyWith; + // Typed answers saved to the site profile once this applies. + siteSaves?: SiteSave[]; }) { const t = useT(); const moves = current && (upgrade || rollback) ? whatMoves(current, plan, currentCatalog) : null; @@ -598,6 +620,27 @@ export function Review({
)} + {!!siteSaves?.length && ( + +
+

{t("deploy.review.siteProfileHint")}

+ {siteSaves.map((s) => ( +
+
{s.key}
+
+ {s.replaces && ( + <> + {s.replaces} + + + )} + {s.text} +
+
+ ))} +
+
+ )} {diffing ? (
diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index f9637b5..17e95cd 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -580,6 +580,9 @@ "localPathHint": "Overrides the site profile's path template", "inputExample": "e.g. {example}", "inputRequired": "Required: no default or site answer", + "inputRemember": "Save to the site profile", + "inputRememberHint": "Every variant that declares this key, for any model, will take it", + "inputReplaces": "replaces the site's answer {answer}", "inputFrom": { "release": "Release", "site": "Site profile", @@ -696,6 +699,10 @@ "back": "Back to edit", "compose": "Compose plan", "composing": "Composing…", + "siteProfile": "Site profile", + "siteProfileHint": "Saved once the deploy applies:", + "siteSaved": "Saved to the site profile: {keys}", + "siteNotSaved": "Deployed; the site profile was not saved: {error}", "install": "Deploy", "apply": "Upgrade", "rollback": "Roll back", diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index dcb7b1c..29d506e 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -569,6 +569,9 @@ "localPathHint": "覆盖站点配置里的路径模板", "inputExample": "例如 {example}", "inputRequired": "必填:变体没有默认值,站点配置也没有答案", + "inputRemember": "保存到站点配置", + "inputRememberHint": "任何模型中声明了该键的变体都会采用它", + "inputReplaces": "替换站点现有答案 {answer}", "inputFrom": { "release": "当前发布", "site": "站点配置", @@ -685,6 +688,10 @@ "back": "返回修改", "compose": "生成计划", "composing": "生成中…", + "siteProfile": "站点配置", + "siteProfileHint": "部署生效后保存:", + "siteSaved": "已保存到站点配置:{keys}", + "siteNotSaved": "已部署,但站点配置未保存:{error}", "install": "确认部署", "apply": "确认升级", "rollback": "确认回滚", diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 74182c1..b0fb374 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -4,7 +4,7 @@ import type { ApplyFlags, ClusterInfo, Input as CatalogInput, Plan, PlanRequest, import { cn } from "@swiss/lib/utils"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { productChoices, unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; -import { CHECK_MS, inputError, inputProblem, setInputOverrides, type InputRow } from "@swiss/lib/inputs"; +import { CHECK_MS, inputError, inputProblem, rememberable, setInputOverrides, type InputChoice, type InputRow } from "@swiss/lib/inputs"; import { useDebounced } from "@swiss/lib/useDebounced"; import { FEATURES, featureOn } from "@swiss/lib/profile"; import { layersOf } from "@swiss/components/Provenance"; @@ -200,6 +200,8 @@ export function DeploySettings({ // archived plan verbatim, so an editable field beside it would promise a // change that the rollback would then not make. readOnly = false, + remember, + onRemember, }: { form: Form; onChange: (patch: Partial) => void; @@ -228,6 +230,9 @@ export function DeploySettings({ // What the picked resource and products can land on; absent without GPUs. capacity?: CapacityProps; cache?: CacheCard; + // Which typed answers to save to the site profile once the deploy applies; absent where it cannot be saved. + remember?: Record; + onRemember?: (key: string, on: boolean) => void; }) { const checked = useDebounced(form.inputs, CHECK_MS); const set = @@ -323,7 +328,8 @@ export function DeploySettings({ /> - {inputs?.map(({ input, answer, from }) => { + {inputs?.map((row) => { + const { input, answer, from, choices, site } = row; const value = form.inputs[input.key] ?? ""; const typed = checked[input.key] ?? ""; const error = inputError({ input, answer }, typed); @@ -351,8 +357,18 @@ export function DeploySettings({ suggestion={answer} placeholder={answer} invalid={!!error} + choices={choices?.map((c) => ({ value: c.answer, label: FROM[c.from] }))} /> {problem &&

{problem}

} + {remember && onRemember && rememberable(row, typed) && ( + + )}
); @@ -1045,6 +1061,9 @@ function TabHint() { ); } +const FROM: Record = { release: "Release", site: "Site profile", variant: "Variant default" }; + +// choices list under the field when the empty field is clicked; typing closes the list. function Suggest({ value, onChange, @@ -1052,6 +1071,7 @@ function Suggest({ placeholder, disabled, invalid, + choices, }: { value: string; onChange: (v: string) => void; @@ -1059,22 +1079,54 @@ function Suggest({ placeholder?: string; disabled?: boolean; invalid?: boolean; + choices?: { value: string; label: string }[]; }) { const s = useSuggestion(value, suggestion, disabled); + const [listed, setListed] = useState(false); return (
onChange(e.target.value)} - onKeyDown={(e) => accept(e, s.offer, onChange)} + onKeyDown={(e) => { + if (e.key === "Escape") setListed(false); + accept(e, s.offer, onChange); + }} disabled={disabled} placeholder={placeholder} aria-invalid={invalid || undefined} className={cn(s.hint && "pr-11", invalid && "border-destructive")} - {...s.focus} + onFocus={s.focus.onFocus} + onBlur={() => { + s.focus.onBlur(); + setListed(false); + }} + onClick={() => setListed(!!choices?.length && !value)} /> {s.hint && } + {listed && !value && choices && ( +
+ {choices.map((c) => ( + // mousedown, not click: the field keeps focus, and its blur does not close the list first. + + ))} +
+ )}
); } diff --git a/web/src/modules/swiss/components/SiteSaves.tsx b/web/src/modules/swiss/components/SiteSaves.tsx new file mode 100644 index 0000000..5081e24 --- /dev/null +++ b/web/src/modules/swiss/components/SiteSaves.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowRight } from "lucide-react"; +import type { ProfileResponse } from "@swiss/lib/api"; +import type { SiteSave } from "@swiss/lib/inputs"; +import { saveSiteAnswers } from "@swiss/lib/siteAnswers"; +import { messageOf, useToast } from "@swiss/components/ui/toast"; + +// Which typed answers to save, and the save, run once the deploy applies: one +// abandoned before that leaves the profile as it was. A profile swissd did not +// parse is not written to from here. +export function useSiteSaver(profile?: ProfileResponse) { + const qc = useQueryClient(); + const toast = useToast(); + const [remember, setRemember] = useState>({}); + const can = profile?.yaml !== undefined && !profile.error; + return { + remember: can ? remember : undefined, + onRemember: (key: string, on: boolean) => setRemember((r) => ({ ...r, [key]: on })), + save: (saves: SiteSave[]) => { + if (!saves.length) return; + saveSiteAnswers(saves).then( + (saved) => { + qc.setQueryData(["profile"], saved); + toast.success(`Saved to the site profile: ${saves.map((s) => s.key).join(", ")}`); + }, + (e) => toast.error(`Deployed; the site profile was not saved: ${messageOf(e)}`), + ); + }, + }; +} + +// Typed answers saved to the site profile once the deploy applies. +export function SiteSaves({ saves }: { saves: SiteSave[] }) { + if (!saves.length) return null; + return ( +
+

Saved to the site profile once this applies:

+ {saves.map((s) => ( +
+
{s.key}
+
+ {s.replaces && ( + <> + {s.replaces} + + + )} + {s.text} +
+
+ ))} +
+ ); +} diff --git a/web/src/modules/swiss/lib/inputs.test.ts b/web/src/modules/swiss/lib/inputs.test.ts index a98ceb3..03bba21 100644 --- a/web/src/modules/swiss/lib/inputs.test.ts +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Input, SiteProfile } from "./api"; -import { answerText, fillTemplate, inputError, inputProblem, inputRows, inputsAnswered, parseAnswer, setInputOverrides } from "./inputs"; +import { answerText, fillTemplate, inputError, inputProblem, inputRows, inputsAnswered, parseAnswer, rememberable, setInputOverrides, siteSaves } from "./inputs"; const master: Input = { key: "storageKVOffload.mooncake.master", @@ -29,7 +29,7 @@ describe("fillTemplate", () => { describe("inputRows", () => { it("prefills the site's answer, its template filled for this model", () => { expect(inputRows([master], site({ [master.key]: "mc-{{model}}:50051" }), "zai-org/GLM-5.3", "glm-5.3")).toEqual([ - { input: master, answer: "mc-glm-5.3:50051", from: "site" }, + { input: master, answer: "mc-glm-5.3:50051", from: "site", site: { answer: "mc-glm-5.3:50051", written: "mc-{{model}}:50051" } }, ]); expect(inputRows([port], site({ [port.key]: 8050 }), "a/b", "m")[0].answer).toBe("8050"); }); @@ -55,7 +55,7 @@ describe("inputRows", () => { ]); const release = { storageKVOffload: { mooncake: { master: "mine:1" } } }; expect(inputRows([tuned], site({ [master.key]: "site:1" }), "a/b", "m", undefined, release)[0].choices?.map((c) => c.from)).toEqual(["release", "site", "variant"]); - expect(inputRows([tuned], site({ [master.key]: "tuned:1" }), "a/b", "m")[0]).toEqual({ input: tuned, answer: "tuned:1", from: "site" }); + expect(inputRows([tuned], site({ [master.key]: "tuned:1" }), "a/b", "m")[0]).toEqual({ input: tuned, answer: "tuned:1", from: "site", site: { answer: "tuned:1", written: "tuned:1" } }); expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", { storageKVOffload: { mooncake: { master: "" } } })[0].choices).toBeUndefined(); }); }); @@ -93,6 +93,26 @@ describe("inputError", () => { }); }); +describe("saving an answer to the site profile", () => { + const row = { input: master, answer: "mc-m:1", from: "site" as const, site: { answer: "mc-m:1", written: "mc-{{model}}:1" } }; + it("takes a typed answer the input takes, and not the site's own", () => { + expect(rememberable(row, "mine:1")).toBe(true); + expect(rememberable(row, " mc-m:1 ")).toBe(false); + expect(rememberable(row, "no-port")).toBe(false); + expect(rememberable(row, "")).toBe(false); + expect(rememberable({ input: master }, "mine:1")).toBe(true); + }); + it("saves the ticked answers, typed as the input, naming what each replaces", () => { + const rows = [row, { input: port }, { input: { ...master, key: "other.master" } }]; + const answers = { [master.key]: " mine:1 ", [port.key]: "8050", "other.master": "x:1" }; + expect(siteSaves(rows, answers, { [master.key]: true, [port.key]: true })).toEqual([ + { key: master.key, text: "mine:1", value: "mine:1", replaces: "mc-{{model}}:1" }, + { key: port.key, text: "8050", value: 8050, replaces: undefined }, + ]); + expect(siteSaves(rows, { [master.key]: "mc-m:1" }, { [master.key]: true })).toEqual([]); + }); +}); + describe("setInputOverrides", () => { it("sends only typed answers, typed, beside the other overrides in a section", () => { const overrides: Record = { storageKVOffload: { enabled: true }, service: "dropped" }; diff --git a/web/src/modules/swiss/lib/inputs.ts b/web/src/modules/swiss/lib/inputs.ts index 021d2fc..24bcf94 100644 --- a/web/src/modules/swiss/lib/inputs.ts +++ b/web/src/modules/swiss/lib/inputs.ts @@ -7,11 +7,13 @@ export interface InputChoice { // answer applies when the field is left empty; none means the deployer must fill it. // choices, when more than one answer is found, are the ones to pick between, answer first. +// site is the site profile's answer: as it is written, and filled for this model. export interface InputRow { input: Input; answer?: string; from?: InputChoice["from"]; choices?: InputChoice[]; + site?: { answer?: string; written: string }; } // fillTemplate mirrors swissd's site.fill, and is undefined where swissd refuses. @@ -46,16 +48,17 @@ export function inputRows( ): InputRow[] { return (inputs ?? []).map((input) => { const v = profile?.inputs?.[input.key]; + const site = v === undefined || v === null ? undefined : { answer: typeof v === "string" ? fillTemplate(v, hf, model) : String(v), written: String(v) }; const found: [string | undefined, InputChoice["from"]][] = [ [answerAt(release, input.key), "release"], - [v === undefined || v === null ? undefined : typeof v === "string" ? fillTemplate(v, hf, model) : String(v), "site"], + [site?.answer, "site"], [input.default !== undefined && input.default !== null ? String(input.default) : answerAt(variantValues, input.key), "variant"], ]; const answers = found.flatMap(([answer, from]) => (answer === undefined ? [] : [{ answer, from }])); - if (!answers.length) return { input }; + if (!answers.length) return site ? { input, site } : { input }; // The form sends no empty answer, so "" is no choice; one answer is listed once, under its first source. const choices = answers.filter((c, i) => c.answer && answers.findIndex((d) => d.answer === c.answer) === i); - return { input, ...answers[0], ...(choices.length > 1 && { choices }) }; + return { input, ...answers[0], ...(choices.length > 1 && { choices }), ...(site && { site }) }; }); } @@ -91,9 +94,31 @@ export function inputError(row: InputRow, text = ""): "required" | "invalid" | u return inputProblem(row.input, answer) ? "invalid" : undefined; } +// A typed answer the site profile can take: one the input takes, and not the site's own. +export function rememberable(row: InputRow, text = ""): boolean { + const t = text.trim(); + return !!t && !inputError(row, t) && t !== row.site?.answer; +} + +export interface SiteSave { + key: string; + text: string; + value: unknown; + replaces?: string; +} + +// What a deploy saves to the site profile once it applies: the ticked answers it can take. +export function siteSaves(rows: InputRow[], answers: Record, remember: Record): SiteSave[] { + return rows.flatMap((row) => { + const text = answers[row.input.key]?.trim() ?? ""; + if (!remember[row.input.key] || !rememberable(row, text)) return []; + return [{ key: row.input.key, text, value: typed(row.input, text), replaces: row.site?.written }]; + }); +} + export const inputsAnswered = (rows: InputRow[], answers: Record) => rows.every((row) => !inputError(row, answers[row.input.key])); -function typed(input: Input, text: string): unknown { +export function typed(input: Input, text: string): unknown { switch (input.type ?? "string") { case "integer": return Number(text); diff --git a/web/src/modules/swiss/lib/siteAnswers.test.ts b/web/src/modules/swiss/lib/siteAnswers.test.ts new file mode 100644 index 0000000..96ff948 --- /dev/null +++ b/web/src/modules/swiss/lib/siteAnswers.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { withSiteAnswers } from "./siteAnswers"; + +const profile = `# the lab cluster +name: lab +model: + pathTemplate: /models/{{name}} # one per repo +`; + +describe("withSiteAnswers", () => { + it("adds the answers under inputs, keeping the comments and every other line", () => { + const out = withSiteAnswers(profile, [{ key: "storageKVOffload.mooncake.master", value: "mc:1" }, { key: "service.port", value: 8050 }]); + expect(out).toContain("# the lab cluster"); + expect(out).toContain("# one per repo"); + expect(parse(out)).toEqual({ + name: "lab", + model: { pathTemplate: "/models/{{name}}" }, + inputs: { "storageKVOffload.mooncake.master": "mc:1", "service.port": 8050 }, + }); + }); + it("replaces an answer and keeps the others", () => { + const out = withSiteAnswers(`${profile}inputs:\n a.b: old # site-wide\n c.d: kept\n`, [{ key: "a.b", value: "new" }]); + expect(parse(out).inputs).toEqual({ "a.b": "new", "c.d": "kept" }); + expect(out).toContain("# site-wide"); + }); + it("fills an empty inputs section", () => { + expect(parse(withSiteAnswers(`${profile}inputs:\n`, [{ key: "a.b", value: true }])).inputs).toEqual({ "a.b": true }); + }); + it("refuses a document that does not parse", () => { + expect(() => withSiteAnswers("name: [", [{ key: "a.b", value: 1 }])).toThrow(); + }); +}); diff --git a/web/src/modules/swiss/lib/siteAnswers.ts b/web/src/modules/swiss/lib/siteAnswers.ts new file mode 100644 index 0000000..7932750 --- /dev/null +++ b/web/src/modules/swiss/lib/siteAnswers.ts @@ -0,0 +1,19 @@ +import { isMap, parseDocument } from "yaml"; +import { api, type ProfileResponse } from "./api"; +import type { SiteSave } from "./inputs"; + +// The profile's text with each answer set under inputs, its comments and every other line kept. +export function withSiteAnswers(yaml: string, saves: Pick[]): string { + const doc = parseDocument(yaml); + if (doc.errors.length) throw new Error(doc.errors[0].message); + if (doc.has("inputs") && !isMap(doc.get("inputs", true))) doc.set("inputs", doc.createNode({})); + for (const s of saves) doc.setIn(["inputs", s.key], s.value); + return String(doc); +} + +// Read just before the write, so an edit made since the deploy page opened is kept. +export async function saveSiteAnswers(saves: SiteSave[]): Promise { + const current = await api.profile(); + if (current.error || current.yaml === undefined) throw new Error(current.error ?? "the site profile could not be read"); + return api.saveProfile({ yaml: withSiteAnswers(current.yaml, saves) }); +} diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index cc7d056..4cbcb1b 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -12,7 +12,8 @@ import { CatalogBadge, CatalogGate, SchemaBadge, useCatalogChoice, withCatalog } import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; import { isChartRange } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; -import { CHECK_MS, inputRows, inputsAnswered } from "@swiss/lib/inputs"; +import { CHECK_MS, inputRows, inputsAnswered, siteSaves } from "@swiss/lib/inputs"; +import { SiteSaves, useSiteSaver } from "@swiss/components/SiteSaves"; import { useDebounced } from "@swiss/lib/useDebounced"; import { deployCapacity, pickableProducts, unlistedProducts, vendorResources } from "@swiss/lib/gpu"; import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; @@ -71,6 +72,7 @@ export function Deploy() { const [form, setForm] = useState({ ...EMPTY, serviceId: name }); const checkedInputs = useDebounced(form.inputs, CHECK_MS); + const siteSaver = useSiteSaver(profile.data); // serviceId is the identity everything else is named after: the helm release, // the openresty route, the LLMScaler and the LLMSLORequirement. const release = form.serviceId; @@ -163,6 +165,7 @@ export function Deploy() { { name: variant.chart.name, version: chart.resolved ?? variant.chart.version }, ); const inputs = inputRows(variant.inputs, profile.data?.profile, m.source?.hf ?? "", name, variant.values); + const saves = siteSaver.remember ? siteSaves(inputs, form.inputs, siteSaver.remember) : []; if (!cluster.data?.allowDeploy) { return ( @@ -270,6 +273,8 @@ export function Deploy() { localPathDefault={model.data.localPath} localPathPlaceholder={model.data.pathTemplate} inputs={inputs} + remember={siteSaver.remember} + onRemember={siteSaver.onRemember} image={imageOf(variant, model.data.imageRepository)} supportedGPUs={variant?.requires.gpuProduct} clusterGPUs={pickableProducts(nodes.data?.nodes, variant?.requires.vendor, profile.data?.profile?.nodes?.gpus, form.gpuResource)} @@ -296,8 +301,13 @@ export function Deploy() { applyWith={method} applyFlags={applyFlags(form)} onDiff={setDiff} - onApplied={setApplied} - /> + onApplied={(r) => { + setApplied(r); + siteSaver.save(saves); + }} + > + +
); } diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx index 07ac358..a93fb44 100644 --- a/web/src/modules/swiss/routes/Upgrade.tsx +++ b/web/src/modules/swiss/routes/Upgrade.tsx @@ -30,7 +30,8 @@ import { ErrorState, Loading } from "@swiss/components/States"; import { CatalogBadge, SchemaBadge, releaseCatalog as catalogOfRelease } from "@swiss/components/CatalogChoice"; import { movableCatalogs } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; -import { CHECK_MS, inputRows, inputsAnswered } from "@swiss/lib/inputs"; +import { CHECK_MS, inputRows, inputsAnswered, siteSaves } from "@swiss/lib/inputs"; +import { SiteSaves, useSiteSaver } from "@swiss/components/SiteSaves"; import { useDebounced } from "@swiss/lib/useDebounced"; import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; import { CatalogModelDiff } from "@swiss/components/CatalogModelDiff"; @@ -127,6 +128,7 @@ export function Upgrade() { const [form, setForm] = useState(EMPTY); const checkedInputs = useDebounced(form.inputs, CHECK_MS); + const siteSaver = useSiteSaver(profile.data); const [plan, setPlan] = useState(null); const [diff, setDiff] = useState(null); const [applied, setApplied] = useState(null); @@ -240,6 +242,9 @@ export function Upgrade() { // rollback. Nothing else about the page differs. const target = rollbackTo ? (archived.data ?? null) : plan; const inputs = inputRows(pin?.inputs, profile.data?.profile, entry.data?.model.source?.hf ?? "", current.data?.source.model ?? "", pin?.values, current.data?.layers?.form); + // A rollback re-applies an archived plan: nothing typed, nothing to save. + const remember = rollbackTo ? undefined : siteSaver.remember; + const saves = remember ? siteSaves(inputs, form.inputs, remember) : []; if (!cluster.data?.allowDeploy) { return ( @@ -474,6 +479,8 @@ export function Upgrade() { localPathDefault={entry.data?.localPath} localPathPlaceholder={entry.data?.pathTemplate} inputs={inputs} + remember={remember} + onRemember={siteSaver.onRemember} image={imageOf(entry.data?.variant, entry.data?.imageRepository)} cache={cache} lockIdentity @@ -495,7 +502,10 @@ export function Upgrade() { composeLabel={planM.isPending ? "Composing…" : plan ? "Recompose upgrade" : "Compose upgrade"} applyFlags={applyFlags(form)} onDiff={setDiff} - onApplied={setApplied} + onApplied={(r) => { + setApplied(r); + siteSaver.save(saves); + }} > {target && ( 0} /> )} +
);