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/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 986e61b..d7b7200 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,66 @@ 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("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("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 c0e745f..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,6 +26,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 { 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"; import { FALLBACK_ROUTE_OPTIONS, routeDirectives, runtimeValue, type RouteDirective, type RouteOptions } from "@/modules/inferences/routeOptions"; @@ -59,10 +62,16 @@ 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[]; + // 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 }: 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); const f = (k: string) => t(`deploy.fields.${k}`); const forced = unlistedProducts(gpuListed, form.gpuProducts); @@ -149,6 +158,34 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster onChange({ localPath })} suggestion={defaults.localPath} placeholder={defaults.localPath || defaults.localPathTemplate} /> + {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}`) }))} /> + + {remember && onRemember && rememberable(row, checked[input.key]) && ( + + )} +
+ ); + })} {defaults.image && ( , "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); @@ -497,6 +542,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 e03c216..aad8f5a 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -34,6 +34,10 @@ 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, 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"; import { useT } from "@/modules/inferences/i18n"; @@ -66,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 }); @@ -147,6 +152,8 @@ 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 [remember, setRemember] = useState>({}); const catalogRoute = upgrade && current.data ? initial.route || initial.serviceId || target.release : undefined; const routeOptions = useQuery({ queryKey: ["route-options", catalogRoute ?? ""], @@ -195,12 +202,16 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( // A product the variant does not list is one swissd takes only as forced. 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)) }, 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 @@ -269,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!) } }); }, @@ -488,6 +508,9 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( catalogRoute={catalogRoute} idPrefix={ID_PREFIX} helmValues={readOnly ? undefined : valuesSource} + inputs={inputs} + remember={canRemember ? remember : undefined} + onRemember={(key, on) => setRemember((r) => ({ ...r, [key]: on }))} />
@@ -507,6 +530,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( force={force} onForce={setForce} method={state.action === "install" ? method : live.data?.backend} + siteSaves={saves} /> ) )} @@ -516,7 +540,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( {step === "form" ? ( <> - @@ -551,6 +575,7 @@ export function Review({ force, onForce, method, + siteSaves, }: { upgrade: boolean; rollback?: boolean; @@ -567,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; @@ -593,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/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 07d323d..17e95cd 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -578,6 +578,21 @@ "scheduler": "Scheduler", "localPath": "Model path", "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", + "variant": "Variant default" + }, + "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)", @@ -684,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", @@ -816,6 +835,7 @@ "placement": "Placement", "catalog": "Catalogs, charts and images", "model": "Model paths", + "inputs": "Variant inputs", "routing": "Routing", "auth": "Entrypoint auth", "scaler": "Cache and scaler", @@ -828,6 +848,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.", @@ -900,6 +921,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 9da1ae8..29d506e 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -567,6 +567,21 @@ "scheduler": "调度器", "localPath": "模型路径", "localPathHint": "覆盖站点配置里的路径模板", + "inputExample": "例如 {example}", + "inputRequired": "必填:变体没有默认值,站点配置也没有答案", + "inputRemember": "保存到站点配置", + "inputRememberHint": "任何模型中声明了该键的变体都会采用它", + "inputReplaces": "替换站点现有答案 {answer}", + "inputFrom": { + "release": "当前发布", + "site": "站点配置", + "variant": "变体默认值" + }, + "inputProblem": { + "string": "需匹配 {pattern}", + "integer": "请输入整数", + "boolean": "请输入 true 或 false" + }, "image": "覆盖镜像", "imageHint": "关闭时使用目录里的 tag 和站点的镜像仓库", "imageValue": "镜像(repository:tag)", @@ -673,6 +688,10 @@ "back": "返回修改", "compose": "生成计划", "composing": "生成中…", + "siteProfile": "站点配置", + "siteProfileHint": "部署生效后保存:", + "siteSaved": "已保存到站点配置:{keys}", + "siteNotSaved": "已部署,但站点配置未保存:{error}", "install": "确认部署", "apply": "确认升级", "rollback": "确认回滚", @@ -805,6 +824,7 @@ "placement": "调度", "catalog": "目录、Chart 与镜像", "model": "模型路径", + "inputs": "变体输入", "routing": "路由", "auth": "入口鉴权", "scaler": "缓存与伸缩", @@ -817,6 +837,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。", @@ -889,6 +910,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 3a6fcaf..b0fb374 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -1,9 +1,11 @@ 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 { 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"; import { @@ -50,6 +52,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 +109,7 @@ export const EMPTY: Form = { createNamespace: false, serviceId: "", localPath: "", + inputs: {}, scaler: true, scalerMin: "", scalerMax: "", @@ -181,6 +186,7 @@ export function DeploySettings({ serviceIdPlaceholder, localPathDefault, localPathPlaceholder, + inputs, image, supportedGPUs, clusterGPUs, @@ -194,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; @@ -204,6 +212,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. @@ -221,7 +230,11 @@ 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 = (k: keyof Form) => (e: React.ChangeEvent) => @@ -315,6 +328,52 @@ export function DeploySettings({ /> + {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); + const problem = error === "required" ? "required" : error && inputProblem(input, typed.trim() || answer || ""); + return ( + +
+ onChange({ inputs: { ...form.inputs, [input.key]: v } })} + suggestion={answer} + placeholder={answer} + invalid={!!error} + choices={choices?.map((c) => ({ value: c.answer, label: FROM[c.from] }))} + /> + {problem &&

{problem}

} + {remember && onRemember && rememberable(row, typed) && ( + + )} +
+
+ ); + })} + + {aside && {aside}} {caption ? (

{caption}

) : null} @@ -998,32 +1061,72 @@ 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, suggestion, placeholder, disabled, + invalid, + choices, }: { value: string; onChange: (v: string) => void; suggestion?: string; 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} - className={cn(s.hint && "pr-11")} - {...s.focus} + aria-invalid={invalid || undefined} + className={cn(s.hint && "pr-11", invalid && "border-destructive")} + 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. + + ))} +
+ )}
); } @@ -1602,6 +1705,8 @@ export function planRequest( forceGpuProducts?: boolean; // The chart has no cache section (CHART_FEATURES): the switch is forced off. cacheUnsupported?: boolean; + // To send the form's answers typed. + inputs?: CatalogInput[]; }, ): PlanRequest { const overrides: Record = {}; @@ -1707,6 +1812,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/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/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/api.ts b/web/src/modules/swiss/lib/api.ts index 71c5455..de838be 100644 --- a/web/src/modules/swiss/lib/api.ts +++ b/web/src/modules/swiss/lib/api.ts @@ -120,6 +120,21 @@ export interface Requires { rdma?: boolean; } +export interface Input { + // The chart value's dotted key: storageKVOffload.mooncake.master. + key: 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; + // 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 // schemaVersion reads. v2 is the one new catalogs publish. export type CatalogSchema = "v1" | "v2"; @@ -192,6 +207,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; } @@ -344,6 +361,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 } }; + // 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 new file mode 100644 index 0000000..03bba21 --- /dev/null +++ b/web/src/modules/swiss/lib/inputs.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import type { Input, SiteProfile } from "./api"; +import { answerText, fillTemplate, inputError, inputProblem, inputRows, inputsAnswered, parseAnswer, rememberable, setInputOverrides, siteSaves } from "./inputs"; + +const master: Input = { + key: "storageKVOffload.mooncake.master", + title: "Mooncake master", + pattern: "[^\\s:/]+:[0-9]+", + example: "mooncake-master.example:50051", +}; +const port: Input = { key: "service.port", title: "Port", type: "integer", example: 8000 }; +const site = (inputs: Record): SiteProfile => ({ + name: "prod", + model: { pathTemplate: "/models/{{name}}" }, + 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.key]: "mc-{{model}}:50051" }), "zai-org/GLM-5.3", "glm-5.3")).toEqual([ + { 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"); + }); + 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.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", 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", site: { answer: "tuned:1", written: "tuned:1" } }); + expect(inputRows([master], site({ [master.key]: "site:1" }), "a/b", "m", { storageKVOffload: { mooncake: { master: "" } } })[0].choices).toBeUndefined(); + }); +}); + +describe("inputProblem", () => { + 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"); + expect(inputProblem({ ...port, type: "boolean" }, "yes")).toBe("true or false"); + }); +}); + +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("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" }; + setInputOverrides(overrides, [master, port, { ...master, key: "unset.here" }], { + [master.key]: " mc:50051 ", + [port.key]: "8050", + "unset.here": " ", + }); + expect(overrides).toEqual({ + storageKVOffload: { enabled: true, mooncake: { master: "mc:50051" } }, + service: { port: 8050 }, + }); + }); +}); + +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 new file mode 100644 index 0000000..24bcf94 --- /dev/null +++ b/web/src/modules/swiss/lib/inputs.ts @@ -0,0 +1,162 @@ +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. +// 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. +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, key: string): string | undefined { + let node: unknown = tree; + for (const k of key.split(".")) { + if (!node || typeof node !== "object" || Array.isArray(node)) return undefined; + node = (node as Record)[k]; + } + if (node === undefined || node === null || typeof node === "object") return undefined; + return String(node); +} + +// The order swissd answers in: the release's form layer (carried forward on upgrade), +// 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) => { + 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"], + [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 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 }), ...(site && { site }) }; + }); +} + +// 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(); + 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 patternOf(input.pattern).test(t) ? undefined : `must match ${input.pattern}`; + } catch { + return undefined; // swissd's own check names a pattern it cannot read + } + } +} + +// 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; +} + +// 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])); + +export 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.key]?.trim(); + if (!text) continue; + const keys = input.key.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); + } +} + +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/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/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 29dfb0f..4cbcb1b 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -12,6 +12,9 @@ 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, 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"; import { InstallMethodChoice } from "@swiss/components/InstallMethod"; @@ -68,6 +71,8 @@ export function Deploy() { const [picked, setPicked] = useState(); 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; @@ -119,6 +124,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.inputs, }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -158,6 +164,8 @@ 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); + const saves = siteSaver.remember ? siteSaves(inputs, form.inputs, siteSaver.remember) : []; if (!cluster.data?.allowDeploy) { return ( @@ -264,6 +272,9 @@ export function Deploy() { serviceIdPlaceholder={name} 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)} @@ -286,12 +297,17 @@ export function Deploy() { onTab={setTab} onCompose={() => planM.mutate()} composing={planM.isPending} - composeDisabled={!form.serviceId} + composeDisabled={!form.serviceId || !inputsAnswered(inputs, checkedInputs)} applyWith={method} applyFlags={applyFlags(form)} onDiff={setDiff} - onApplied={setApplied} - /> + onApplied={(r) => { + setApplied(r); + siteSaver.save(saves); + }} + > + +
); } 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 siteSaver = useSiteSaver(profile.data); const [plan, setPlan] = useState(null); const [diff, setDiff] = useState(null); const [applied, setApplied] = useState(null); @@ -164,6 +169,7 @@ export function Upgrade() { // dropped by a form that never knew about it. fromRelease: release, cacheUnsupported: !!cache.unsupported, + inputs: entry.data?.variant.inputs, }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -235,6 +241,10 @@ 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); + // 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 ( @@ -468,6 +478,9 @@ 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 @@ -485,10 +498,14 @@ export function Upgrade() { onTab={setTab} onCompose={() => planM.mutate()} composing={planM.isPending} + composeDisabled={!rollbackTo && !inputsAnswered(inputs, checkedInputs)} 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} /> )} + );