diff --git a/web/src/modules/inferences/SiteProfilePage.tsx b/web/src/modules/inferences/SiteProfilePage.tsx index 7fc12ca..4a9f86e 100644 --- a/web/src/modules/inferences/SiteProfilePage.tsx +++ b/web/src/modules/inferences/SiteProfilePage.tsx @@ -227,6 +227,20 @@ function ProfileView({ p }: { p: SiteProfile }) { "sites", p.sites.map((s) => ({ label: s.name, value: mono(s.url) })), )} + {p.presets && Object.keys(p.presets).length > 0 && ( + + {t("profile.hints.presets")} + {Object.entries(p.presets).map(([name, preset]) => ( + + + {name} + {preset.description && {preset.description}} + + + + ))} + + )} {p.extra && Object.keys(p.extra).length > 0 && ( diff --git a/web/src/modules/inferences/components/DeployForm.tsx b/web/src/modules/inferences/components/DeployForm.tsx index 02c5e8b..037ed62 100644 --- a/web/src/modules/inferences/components/DeployForm.tsx +++ b/web/src/modules/inferences/components/DeployForm.tsx @@ -3,6 +3,7 @@ import { Badge, Button, Checkbox, + CodeBlock, DataSelect, DropdownMenu, DropdownMenuContent, @@ -23,12 +24,14 @@ import { cn, } from "@modelsphere/ui"; import { Plus, RefreshCw, X } from "lucide-react"; -import type { ClusterInfo } from "@swiss/lib/api"; +import type { ClusterInfo, Preset } from "@swiss/lib/api"; +import { editRule, listLabel, type AffinityRule } from "@swiss/lib/affinity"; +import { toYaml } from "@swiss/lib/yaml"; 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 { effective, imageChoices, tolerationExtras, 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"; import { useT } from "@/modules/inferences/i18n"; @@ -67,9 +70,11 @@ export interface DeployFormProps { // 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; + // The site profile's presets, on a deploy. + presets?: Record; } -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) { +export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs, remember, onRemember, presets }: DeployFormProps) { const t = useT(); const checked = useDebounced(form.inputs, CHECK_MS); const on = effective(form); @@ -145,7 +150,9 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster onChange({ gpuProducts: v })} options={gpuOptions.map((o) => ({ value: o, label: o, badge: unlistedProducts(gpuListed, [o]).length ? {f("gpuUnverified")} : {f("gpuVerified")} }))} placeholder={gpuOptions.length ? undefined : f("gpuProductsEmpty")} disabled={!gpuOptions.length} /> {capacity && } + {presets && Object.keys(presets).length > 0 && onChange({ preset })} />} onChange({ tolerations: v })} /> + {form.affinity.length > 0 && onChange({ affinity })} />} onChange({ priorityClassName })} suggestion={cluster?.priorityClassName} /> @@ -396,13 +403,14 @@ function Tolerations({ rows, onChange }: { rows: Toleration[]; onChange: (v: Tol {rows.map((r, i) => ( - patch(i, { key: e.target.value })} placeholder={f("tolKey")} aria-label={f("tolKey")} /> + patch(i, { key: e.target.value })} placeholder={r.raw && !r.raw.key ? f("tolAny") : f("tolKey")} aria-label={f("tolKey")} /> patch(i, { operator: v as Toleration["operator"], value: v === "Exists" ? "" : r.value })} options={["Equal", "Exists"].map((o) => ({ value: o, label: o }))} /> patch(i, { value: e.target.value })} placeholder={f("tolValue")} aria-label={f("tolValue")} disabled={r.operator === "Exists"} /> patch(i, { effect: v })} options={EFFECTS.map((e) => ({ value: e, label: e || f("anyEffect") }))} /> onChange(rows.filter((_, j) => j !== i))}> + {tolerationExtras(r).length > 0 && {tolerationExtras(r).join(" · ")}} ))} onChange([...rows, { key: "", operator: "Equal", value: "", effect: "" }])}> @@ -412,6 +420,60 @@ function Tolerations({ rows, onChange }: { rows: Toleration[]; onChange: (v: Tol ); } +const NO_PRESET = "__none"; + +function PresetField({ presets, value, onChange }: { presets: Record; value: string; onChange: (v: string) => void }) { + const t = useT(); + const f = (k: string) => t(`deploy.fields.${k}`); + const picked = presets[value]; + return ( + + + v && onChange(v === NO_PRESET ? "" : v)} + options={[{ value: NO_PRESET, label: f("presetNone") }, ...Object.keys(presets).sort().map((n) => ({ value: n, label: n }))]} + /> + + {picked?.description && {picked.description}} + {picked && } + + ); +} + +function AffinityRules({ rules, onChange }: { rules: AffinityRule[]; onChange: (v: AffinityRule[]) => void }) { + const t = useT(); + const f = (k: string) => t(`deploy.fields.${k}`); + const patch = (i: number, r: AffinityRule) => onChange(rules.map((x, j) => (i === j ? r : x))); + return ( + + {rules.map((r, i) => ( + + + + {r.scope} · {listLabel(r.list)} + + patch(i, { ...r, drop: !r.drop })}> + {r.drop ? f("affinityUndo") : f("affinityRemove")} + + + patch(i, editRule(r, e.target.value))} + disabled={r.drop} + spellCheck={false} + aria-label={`${r.scope} ${listLabel(r.list)}`} + aria-invalid={!!r.error} + rows={Math.max(2, r.text.split("\n").length)} + className={cn("w-full rounded-md border bg-background px-2 py-1 font-mono text-[11px] leading-snug", r.drop && "line-through", r.error && "border-destructive")} + /> + {r.error && {t("deploy.fields.affinityInvalid", { error: r.error })}} + + ))} + + ); +} + function directiveText(t: ReturnType, d: RouteDirective | undefined, field: "label" | "detail"): string { return d ? t(`deploy.routeOptions.options.${d.key}.${field}`, { defaultValue: d[field] }) : ""; } diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index aad8f5a..55889e4 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -35,6 +35,7 @@ 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 { affinityInvalid } from "@swiss/lib/affinity"; import { saveSiteAnswers } from "@swiss/lib/siteAnswers"; import { messageOf, useToast } from "@swiss/components/ui/toast"; import { useDebounced } from "@swiss/lib/useDebounced"; @@ -210,7 +211,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( 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, inputs: v?.inputs } + ? { 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, formLayer: seedPlan?.layers?.form } : { model: target.model, version, variant: keptVariant, catalog: target.catalog, chartVersion: chartVersion || undefined, cacheUnsupported: !!cache.unsupported, forceGpuProducts, inputs: v?.inputs }, profile.data?.profile, ); @@ -511,6 +512,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( inputs={inputs} remember={canRemember ? remember : undefined} onRemember={(key, on) => setRemember((r) => ({ ...r, [key]: on }))} + presets={upgrade ? undefined : profile.data?.profile?.presets} /> > @@ -540,7 +542,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( {step === "form" ? ( <> - + {compose.isPending ? t("deploy.review.composing") : t("deploy.review.compose")} > diff --git a/web/src/modules/inferences/components/PresetTable.tsx b/web/src/modules/inferences/components/PresetTable.tsx new file mode 100644 index 0000000..58f2937 --- /dev/null +++ b/web/src/modules/inferences/components/PresetTable.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, FieldInput, FieldTextarea, FloatingField } from "@modelsphere/ui"; +import { Pencil, Plus, X } from "lucide-react"; +import type { SiteProfile } from "@swiss/lib/api"; +import { hasPresetErrors, presetErrors, presetRow, putPreset, type PresetRow } from "@swiss/lib/profile"; +import { useT } from "@/modules/inferences/i18n"; + +// The profile's presets, each edited in a dialog: a preset goes into the +// profile only once its name and values are valid. +export function PresetTable({ presets, onChange }: { presets: SiteProfile["presets"]; onChange: (next: SiteProfile["presets"]) => void }) { + const t = useT(); + const f = (k: string) => t(`profile.fields.${k}`); + // null adds a preset; a name opens that one. + const [open, setOpen] = useState(undefined); + const list = Object.entries(presets ?? {}); + return ( + + {list.length === 0 && {f("presetsNone")}} + {list.map(([name, p]) => ( + + + + {name} + {p.description && {p.description}} + + {Object.keys(p.values ?? {}).join(", ") || f("presetNoValues")} + + setOpen(name)}> + + + onChange(putPreset(presets, name))}> + + + + ))} + setOpen(null)}> + {f("presetAdd")} + + {open !== undefined && ( + n).filter((n) => n !== open)} + onClose={() => setOpen(undefined)} + onSave={(row) => { + onChange(putPreset(presets, open ?? undefined, row)); + setOpen(undefined); + }} + /> + )} + + ); +} + +function PresetDialog({ from, value, others, onClose, onSave }: { from?: string; value: PresetRow; others: string[]; onClose: () => void; onSave: (row: PresetRow) => void }) { + const t = useT(); + const f = (k: string) => t(`profile.fields.${k}`); + const [row, setRow] = useState(value); + const [submitted, setSubmitted] = useState(false); + const errs = presetErrors(row, others); + const edit = (patch: Partial) => setRow((r) => ({ ...r, ...patch })); + const save = () => { + setSubmitted(true); + if (!hasPresetErrors(errs)) onSave(row); + }; + const valuesError = errs.values === "yaml" ? t("profile.errors.preset.yaml", { error: errs.yaml }) : errs.values === "map" ? t("profile.errors.preset.map") : undefined; + return ( + !o && onClose()}> + + + {from === undefined ? f("presetAdd") : f("presetEdit")} + {t("profile.hints.presets")} + + + + + edit({ name: e.target.value })} className="font-mono text-xs" /> + + + edit({ description: e.target.value })} /> + + + + edit({ values: e.target.value })} rows={16} spellCheck={false} className="font-mono text-xs" /> + + + + + {t("common:actions.cancel")} + + {t("common:actions.save")} + + + + ); +} diff --git a/web/src/modules/inferences/components/ProfileForm.tsx b/web/src/modules/inferences/components/ProfileForm.tsx index 407b011..f450e69 100644 --- a/web/src/modules/inferences/components/ProfileForm.tsx +++ b/web/src/modules/inferences/components/ProfileForm.tsx @@ -6,6 +6,7 @@ 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 { PresetTable } from "@/modules/inferences/components/PresetTable"; import { catalogErrors, FEATURES, featureOn, withFeature, type CatalogError } from "@swiss/lib/profile"; // The copied swiss types predate writable. @@ -21,7 +22,7 @@ export interface TokenField { onChange: (v: string) => void; } -const GROUPS = ["placement", "catalog", "model", "inputs", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const; +const GROUPS = ["placement", "catalog", "model", "inputs", "presets", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const; export function ProfileForm({ value: p, @@ -132,6 +133,8 @@ export function ProfileForm({ set({ inputs: parseAnswers(o) })} />, )} + {group("presets", set({ presets })} />)} + {group( "routing", <> diff --git a/web/src/modules/inferences/deployment-settings.ts b/web/src/modules/inferences/deployment-settings.ts index 205cf34..fd5515c 100644 --- a/web/src/modules/inferences/deployment-settings.ts +++ b/web/src/modules/inferences/deployment-settings.ts @@ -1,5 +1,6 @@ import { EMPTY as SWISS_EMPTY, formFromPlan as swissFormFromPlan, planRequest as swissPlanRequest, type Form } from "@swiss/components/DeploySettings"; import type { Plan, SiteProfile } from "@swiss/lib/api"; +import { presetValues } from "@swiss/lib/formLayer"; // The site's monitor ConfigMap (modelRoute.monitor.outputConfigMap) turns Monitor on in the form layer. // An unknown profile (unread, unparsed) decides nothing: an upgrade keeps its plan's Monitor. @@ -15,4 +16,4 @@ export const EMPTY: Form = monitorSettings(SWISS_EMPTY, false); export const formFromPlan = (plan: Plan): Form => swissFormFromPlan(plan); export const planRequest = (form: Form, options: Parameters[1], profile?: SiteProfile) => - swissPlanRequest(profile ? monitorSettings(form, monitorOn(profile)) : form, options); + swissPlanRequest(profile ? monitorSettings(form, monitorOn(profile)) : form, { ...options, preset: presetValues(profile, form.preset) }); diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 17e95cd..d6a590f 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -574,6 +574,15 @@ "tolEffect": "Effect", "tolAdd": "Add toleration", "anyEffect": "Any", + "tolAny": "Every taint", + "preset": "Preset", + "presetHint": "Site profile values merged into this deploy's: a list keeps both sides' items, a value set here wins", + "presetNone": "None", + "affinity": "Affinity", + "affinityHint": "Exactly as this release's form values set it; edit or remove each rule", + "affinityInvalid": "Not valid YAML: {error}", + "affinityRemove": "Remove", + "affinityUndo": "Undo", "priorityClass": "Priority class", "scheduler": "Scheduler", "localPath": "Model path", @@ -836,6 +845,7 @@ "catalog": "Catalogs, charts and images", "model": "Model paths", "inputs": "Variant inputs", + "presets": "Presets", "routing": "Routing", "auth": "Entrypoint auth", "scaler": "Cache and scaler", @@ -849,6 +859,7 @@ "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.", + "presets": "Named values a deploy may pick one of, merged into its form values: a list keeps both sides' items.", "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.", @@ -962,6 +973,15 @@ "deferTimeout": "Defer timeout (s)", "deferTimeoutHint": "How long a hold may defer a scale before it is applied anyway: a DisaggregatedSet rollout that never lands, a failed release, pods that never get ready. Uninstalling, a missing DisaggregatedSet and an apply in flight still hold. Empty is 600; 0 defers indefinitely", "deferTimeoutNever": "Never; defers indefinitely", + "presetAdd": "Add preset", + "presetEdit": "Edit preset", + "presetName": "Name", + "presetDescription": "Description", + "presetDescriptionHint": "Shown beside the name in the deploy page's picker", + "presetValues": "Values", + "presetValuesHint": "Chart values as YAML. Not checked against the chart here: a deploy merges them into its form values", + "presetNoValues": "No values", + "presetsNone": "None: a deploy has no preset to pick", "siteName": "Site", "siteUrl": "URL", "siteAdd": "Add site" @@ -996,6 +1016,12 @@ "duplicate": "Already listed with this schema" } }, + "preset": { + "required": "Name is required", + "duplicate": "Another preset has this name", + "map": "Values must be a map of chart keys", + "yaml": "Not valid YAML: {error}" + }, "gpu": { "required": "Required", "duplicate": "Listed twice", diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index 29d506e..2d0ec60 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -563,6 +563,15 @@ "tolEffect": "Effect", "tolAdd": "添加容忍", "anyEffect": "任意", + "tolAny": "所有污点", + "preset": "预设", + "presetHint": "站点配置中的值,合并进本次部署:列表保留两边的项,此处设置的值优先", + "presetNone": "无", + "affinity": "亲和性", + "affinityHint": "与此发布的表单值完全一致;可逐条编辑或移除", + "affinityInvalid": "不是有效的 YAML:{error}", + "affinityRemove": "移除", + "affinityUndo": "撤销", "priorityClass": "优先级类", "scheduler": "调度器", "localPath": "模型路径", @@ -825,6 +834,7 @@ "catalog": "目录、Chart 与镜像", "model": "模型路径", "inputs": "变体输入", + "presets": "预设", "routing": "路由", "auth": "入口鉴权", "scaler": "缓存与伸缩", @@ -838,6 +848,7 @@ "catalog": "模型从哪来,以及目录刻意不写的 chart 和镜像仓库。", "model": "根据目录的 source.hf 生成路径。可用变量:hf(完整 repo id)、org 和 name(它的两段)、model(目录里的模型名),写法见输入框里的示例。", "inputs": "按 chart 键回答目录变体声明的输入。只有声明了该键的变体会采用,部署时仍可覆盖。字符串可使用模型路径的变量。", + "presets": "部署时可选其一的命名值,合并进部署的表单值:列表保留两边的项。", "routing": "每个模型发布路由的 openresty 入口。", "auth": "只用于服务检查。这份配置是 ConfigMap,所以只写 Secret 的名字,不保存密钥本身。", "pdScale": "swissd 的 PD slice 自动伸缩,作用于开启 PD 且表单开启 SLO 的发布。字段名沿用 LLMScaler。", @@ -951,6 +962,15 @@ "deferTimeout": "延迟超时(秒)", "deferTimeoutHint": "暂缓条件最多能把一次伸缩推迟多久,超时后照常执行:DisaggregatedSet 滚动一直未完成、发布失败、Pod 一直未就绪。卸载中、DisaggregatedSet 不存在、正在 apply 时仍会一直暂缓。留空为 600;0 表示无限期暂缓", "deferTimeoutNever": "永不超时,无限期暂缓", + "presetAdd": "添加预设", + "presetEdit": "编辑预设", + "presetName": "名称", + "presetDescription": "描述", + "presetDescriptionHint": "在部署页的选择框里显示在名称旁", + "presetValues": "值", + "presetValuesHint": "YAML 格式的 chart values。这里不按 chart 校验:部署时合并进表单值", + "presetNoValues": "没有值", + "presetsNone": "暂无:部署时没有可选的预设", "siteName": "站点名", "siteUrl": "地址", "siteAdd": "添加站点" @@ -985,6 +1005,12 @@ "duplicate": "相同 schema 下已有此地址" } }, + "preset": { + "required": "名称必填", + "duplicate": "名称与其他预设重复", + "map": "值必须是 chart key 的映射", + "yaml": "YAML 无效:{error}" + }, "gpu": { "required": "必填", "duplicate": "重复填写", diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index b0fb374..44c73ec 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -1,11 +1,14 @@ import { useState } from "react"; import { ChevronDown, Plus, RefreshCw, TriangleAlert, X } from "lucide-react"; -import type { ApplyFlags, ClusterInfo, Input as CatalogInput, Plan, PlanRequest, ProfileResponse, Variant } from "@swiss/lib/api"; +import type { ApplyFlags, ClusterInfo, Input as CatalogInput, Plan, PlanRequest, Preset, 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 { PRODUCT_KEY, affinityRules, editRule, listLabel, setAffinity, type AffinityRule } from "@swiss/lib/affinity"; +import { overlay, withPreset } from "@swiss/lib/formLayer"; +import { toYaml } from "@swiss/lib/yaml"; import { FEATURES, featureOn } from "@swiss/lib/profile"; import { layersOf } from "@swiss/components/Provenance"; import { @@ -31,6 +34,9 @@ export interface Toleration { operator: "Exists" | "Equal"; value: string; effect: string; + // The toleration as the release's form layer has it, sent as is while the + // fields still match it: keys no field shows, tolerationSeconds, survive. + raw?: Record; } export interface Form { @@ -39,6 +45,12 @@ export interface Form { // The resource picked among the vendor's; empty lets swissd pick. gpuResource: string; tolerations: Toleration[]; + // A site profile preset, by name, merged into a deploy's form values. An + // upgrade has none: the release's form layer already carries its values. + preset: string; + // The rules the release's form layer's affinities carry, on an upgrade: + // each one edited or removed in place. + affinity: AffinityRule[]; priorityClassName: string; schedulerName: string; cart: boolean; @@ -99,6 +111,8 @@ export const EMPTY: Form = { gpuProducts: [], gpuResource: "", tolerations: [], + preset: "", + affinity: [], priorityClassName: "", schedulerName: "", cart: true, @@ -202,6 +216,7 @@ export function DeploySettings({ readOnly = false, remember, onRemember, + presets, }: { form: Form; onChange: (patch: Partial) => void; @@ -233,8 +248,11 @@ export function DeploySettings({ // 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; + // The site profile's presets, on a deploy. + presets?: Record; }) { const checked = useDebounced(form.inputs, CHECK_MS); + const picked = presets?.[form.preset]; const set = (k: keyof Form) => (e: React.ChangeEvent) => @@ -793,12 +811,38 @@ export function DeploySettings({ )} + {presets && Object.keys(presets).length > 0 && ( + + + onChange({ preset: e.target.value })} + aria-label="Preset" + > + none + {Object.keys(presets).sort().map((n) => ( + + {n} + + ))} + + {picked?.description && {picked.description}} + {picked && {toYaml(picked.values ?? {})}} + + + )} onChange({ tolerations: v })} /> + {form.affinity.length > 0 && ( + + onChange({ affinity })} /> + + )} ): Toleration { + const str = (v: unknown) => (typeof v === "string" ? v : ""); + return { key: str(r.key), operator: r.operator === "Exists" ? "Exists" : "Equal", value: str(r.value), effect: str(r.effect), raw: r }; +} + +// The keys of a deployed toleration no row field shows, as "k: v". +export function tolerationExtras(t: Toleration): string[] { + return Object.entries(t.raw ?? {}) + .filter(([k]) => !TOLERATION_FIELDS.includes(k)) + .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`); +} + function tolerationsOf(o: Record): Toleration[] { if (!Array.isArray(o.tolerations)) return []; - return o.tolerations.flatMap((t): Toleration[] => { - if (!t || typeof t !== "object") return []; - const r = t as Record; - if (typeof r.key !== "string" || !r.key) return []; - return [ - { - key: r.key, - operator: r.operator === "Equal" ? "Equal" : "Exists", - value: typeof r.value === "string" ? r.value : "", - effect: typeof r.effect === "string" ? r.effect : "", - }, - ]; - }); + return o.tolerations.flatMap((t): Toleration[] => (t && typeof t === "object" && !Array.isArray(t) ? [tolerationRow(t as Record)] : [])); +} + +// A row with no key is a half-typed one, unless it is a deployed toleration +// that has none: that one tolerates every taint. +function tolerationValue(t: Toleration): Record | undefined { + const raw = t.raw; + if (raw) { + const was = tolerationRow(raw); + if (TOLERATION_FIELDS.every((k) => was[k as keyof Toleration] === t[k as keyof Toleration])) return raw; + } + const key = t.key.trim(); + if (!key && !(raw && !raw.key)) return undefined; + const out: Record = Object.fromEntries(Object.entries(raw ?? {}).filter(([k]) => !TOLERATION_FIELDS.includes(k))); + if (key) out.key = key; + out.operator = t.operator; + if (t.operator === "Equal" && t.value.trim()) out.value = t.value.trim(); + if (t.effect) out.effect = t.effect; + return out; } // The products sit in a required nodeAffinity term under a vendor-specific @@ -1021,7 +1086,7 @@ function gpuProductsOf(o: Record): string[] { const vals = dig(e, "values"); if ( typeof key === "string" && - /\.product$|device-id$|ascend910$/i.test(key) && + PRODUCT_KEY.test(key) && Array.isArray(vals) ) { return vals.filter((v): v is string => typeof v === "string"); @@ -1214,7 +1279,7 @@ function Tolerations({ className="w-44" value={r.key} onChange={(e) => patch(i, { key: e.target.value })} - placeholder="taint key" + placeholder={r.raw && !r.raw.key ? "every taint" : "taint key"} aria-label="taint key" /> + {tolerationExtras(r).map((x) => ( + + {x} + + ))} onChange(rows.filter((_, j) => j !== i))} @@ -1260,6 +1330,36 @@ function Tolerations({ ); } +function AffinityRules({ rules, onChange }: { rules: AffinityRule[]; onChange: (v: AffinityRule[]) => void }) { + const patch = (i: number, r: AffinityRule) => onChange(rules.map((x, j) => (i === j ? r : x))); + return ( + + {rules.map((r, i) => ( + + + + {r.scope} · {listLabel(r.list)} + + patch(i, { ...r, drop: !r.drop })} className="ms-auto shrink-0 text-muted-foreground hover:text-destructive"> + {r.drop ? "undo" : "remove"} + + + patch(i, editRule(r, e.target.value))} + disabled={r.drop} + spellCheck={false} + aria-label={`${r.scope} ${listLabel(r.list)} rule`} + rows={Math.max(2, r.text.split("\n").length)} + className={cn("mt-1 w-full rounded-md border bg-background px-2 py-1 font-mono text-[11px] leading-snug", r.drop && "line-through")} + /> + {r.error && {r.error}} + + ))} + + ); +} + function Select({ value, onChange, @@ -1707,6 +1807,11 @@ export function planRequest( cacheUnsupported?: boolean; // To send the form's answers typed. inputs?: CatalogInput[]; + // The values of the preset f picks. + preset?: Record; + // The release's form layer, on an upgrade: the form's values are put over + // it, so the request carries the whole layer rebuilt. + formLayer?: Record; }, ): PlanRequest { const overrides: Record = {}; @@ -1791,16 +1896,8 @@ export function planRequest( overrides.cache = cache; } - // A row with no key tolerates nothing; it is a half-typed row, not a value. - const tolerations = f.tolerations - .filter((t) => t.key.trim()) - .map((t) => { - const out: Record = { key: t.key.trim(), operator: t.operator }; - if (t.operator === "Equal" && t.value.trim()) out.value = t.value.trim(); - if (t.effect) out.effect = t.effect; - return out; - }); - if (tolerations.length) overrides.tolerations = tolerations; + const tolerations = f.tolerations.flatMap((t) => tolerationValue(t) ?? []); + if (tolerations.length || opts.formLayer?.tolerations !== undefined) overrides.tolerations = tolerations; // Only the repository: the tag and digest are the catalog's, and a form that // wrote the whole image section would drop them. @@ -1813,6 +1910,8 @@ export function planRequest( if (f.schedulerName.trim()) overrides.schedulerName = f.schedulerName.trim(); setInputOverrides(overrides, opts.inputs, f.inputs); + const layer = withPreset(opts.formLayer ? overlay(opts.formLayer, overrides) : overrides, opts.preset); + setAffinity(layer, f.affinity, f.gpuProducts.length > 0); return { catalog: opts.catalog || undefined, @@ -1831,7 +1930,7 @@ export function planRequest( createNamespace: f.createNamespace || undefined, serviceId: f.serviceId.trim() || undefined, localPath: f.localPath.trim() || undefined, - overrides, + overrides: layer, }; } @@ -1884,6 +1983,7 @@ export function formFromPlan(plan: Plan): Form { // The resource the release runs on, so an upgrade stays on it. gpuResource: String(((plan.layers?.derived?.model ?? {}) as Record).gpuResource ?? ""), tolerations: tolerationsOf(o), + affinity: affinityRules(o), replicaCount: str("replicaCount"), route: str("modelRoute.nginx.route"), image: joinImage(str("image.repository"), str("image.tag")), diff --git a/web/src/modules/swiss/components/PresetTable.tsx b/web/src/modules/swiss/components/PresetTable.tsx new file mode 100644 index 0000000..bc8e10f --- /dev/null +++ b/web/src/modules/swiss/components/PresetTable.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { Pencil, Plus, TriangleAlert, X } from "lucide-react"; +import type { SiteProfile } from "@swiss/lib/api"; +import { hasPresetErrors, presetErrors, presetRow, putPreset, type PresetRow } from "@swiss/lib/profile"; +import { Button } from "@swiss/components/ui/button"; +import { Dialog } from "@swiss/components/ui/dialog"; +import { Input } from "@swiss/components/ui/input"; +import { cn } from "@swiss/lib/utils"; + +const BAD = "border-destructive"; + +// The profile's presets, each edited in a dialog: a preset goes into the +// profile only once its name and values are valid. +export function PresetTable({ presets, onChange }: { presets: SiteProfile["presets"]; onChange: (next: SiteProfile["presets"]) => void }) { + // null adds a preset; a name opens that one. + const [open, setOpen] = useState(undefined); + const list = Object.entries(presets ?? {}); + return ( + + {list.length === 0 && None: a deploy has no preset to pick.} + {list.map(([name, p]) => ( + + + + {name} + {p.description && {p.description}} + + {Object.keys(p.values ?? {}).join(", ") || "no values"} + + setOpen(name)}> + + + onChange(putPreset(presets, name))}> + + + + ))} + setOpen(null)} className="text-primary hover:text-primary"> + Add preset + + {open !== undefined && ( + n).filter((n) => n !== open)} + onClose={() => setOpen(undefined)} + onSave={(row) => { + onChange(putPreset(presets, open ?? undefined, row)); + setOpen(undefined); + }} + /> + )} + + ); +} + +function PresetDialog({ from, value, others, onClose, onSave }: { from?: string; value: PresetRow; others: string[]; onClose: () => void; onSave: (row: PresetRow) => void }) { + const [row, setRow] = useState(value); + const [submitted, setSubmitted] = useState(false); + const errs = presetErrors(row, others); + const bad = (e?: unknown) => submitted && !!e; + const edit = (patch: Partial) => setRow((r) => ({ ...r, ...patch })); + const save = () => { + setSubmitted(true); + if (!hasPresetErrors(errs)) onSave(row); + }; + return ( + + + Cancel + + + Save + + + } + > + + + edit({ name: e.target.value })} + placeholder="name" + aria-label="preset name" + /> + edit({ description: e.target.value })} placeholder="description, shown in the deploy picker" aria-label="preset description" /> + + edit({ values: e.target.value })} + spellCheck={false} + rows={16} + aria-label="preset values YAML" + aria-invalid={bad(errs.values)} + placeholder={"tolerations:\n - key: example\n operator: Exists"} + className={cn("w-full rounded-md border bg-muted/40 p-3 font-mono text-xs leading-relaxed", bad(errs.values) && BAD)} + /> + {submitted && hasPresetErrors(errs) && ( + + + {errs.name === "required" && "Needs a name. "} + {errs.name === "duplicate" && "Another preset has this name. "} + {errs.values === "map" && "Values must be a map of chart keys."} + {errs.values === "yaml" && `Not valid YAML: ${errs.yaml}`} + + )} + + + ); +} + +function IconButton({ label, danger, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/src/modules/swiss/components/ProfileForm.tsx b/web/src/modules/swiss/components/ProfileForm.tsx index 38506ba..10aab99 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, ListChecks, MapPin, Plus, Route, Scaling, ToggleRight, TriangleAlert, X, type LucideIcon } from "lucide-react"; +import { FolderTree, Gauge, Globe, KeyRound, Layers, 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 { PresetTable } from "@swiss/components/PresetTable"; import { answersText, parseAnswers } from "@swiss/lib/inputs"; import { cn } from "@swiss/lib/utils"; @@ -136,6 +137,16 @@ export function ProfileForm({ + + + set("presets", presets)} /> + + + setIn("route", { enabled: enabled || undefined })} /> diff --git a/web/src/modules/swiss/lib/affinity.ts b/web/src/modules/swiss/lib/affinity.ts new file mode 100644 index 0000000..b4f9173 --- /dev/null +++ b/web/src/modules/swiss/lib/affinity.ts @@ -0,0 +1,95 @@ +import { parse, stringify } from "yaml"; +import { isMap } from "@swiss/lib/formLayer"; + +type Tree = Record; + +const REQUIRED_NODE = "nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution"; +const LISTS = [ + `${REQUIRED_NODE}.nodeSelectorTerms`, + "nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution", + "podAffinity.requiredDuringSchedulingIgnoredDuringExecution", + "podAffinity.preferredDuringSchedulingIgnoredDuringExecution", + "podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution", + "podAntiAffinity.preferredDuringSchedulingIgnoredDuringExecution", +]; + +// The GPU product label, under whichever vendor's key. +export const PRODUCT_KEY = /\.product$|device-id$|ascend910$/i; + +export interface AffinityRule { + // "affinity", or a PD role's "pd..affinityReplace". + scope: string; + // The list in it the rule is an item of. + list: string; + rule: unknown; + // The rule as typed, and why it does not parse. + text: string; + error?: string; + // Removed on this upgrade. + drop?: boolean; +} + +const at = (v: unknown, path: string) => path.split(".").reduce((acc, k) => (isMap(acc) ? acc[k] : undefined), v); + +// affinityRules lists every rule a form layer's affinities carry, in order. +export function affinityRules(layer: Tree): AffinityRule[] { + const scopes: [string, unknown][] = [["affinity", layer.affinity]]; + if (isMap(layer.pd)) { + for (const [role, cfg] of Object.entries(layer.pd)) { + if (isMap(cfg)) scopes.push([`pd.${role}.affinityReplace`, cfg.affinityReplace]); + } + } + return scopes.flatMap(([scope, aff]) => + LISTS.flatMap((list) => { + const items = at(aff, list); + return Array.isArray(items) ? items.map((rule) => ({ scope, list, rule, text: stringify(rule) })) : []; + }), + ); +} + +export function editRule(r: AffinityRule, text: string): AffinityRule { + try { + const rule: unknown = parse(text); + return isMap(rule) ? { ...r, text, rule, error: undefined } : { ...r, text, error: "not a map" }; + } catch (e) { + return { ...r, text, error: e instanceof Error ? e.message.split("\n")[0] : String(e) }; + } +} + +export const affinityInvalid = (rules: AffinityRule[]) => rules.some((r) => r.error && !r.drop); + +// "nodeAffinity.required", for a rule's heading. +export const listLabel = (list: string) => list.replace("DuringSchedulingIgnoredDuringExecution", "").replace(".nodeSelectorTerms", ""); + +function set(t: Tree, path: string, value: unknown) { + const segs = path.split("."); + let cur = t; + for (const s of segs.slice(0, -1)) { + if (!isMap(cur[s])) cur[s] = {}; + cur = cur[s] as Tree; + } + cur[segs[segs.length - 1]] = value; +} + +// setAffinity writes each list back as its rules now stand. swissd merges an +// upgrade over the release's form layer, where a list replaces but a map key +// left out stays: so a list emptied of rules cannot always go as []. A role's +// affinityReplace with no rule left goes to null, which the chart reads as +// inherit, and empty nodeSelectorTerms, which Kubernetes refuses, take their +// required block to null. Products picked mean swissd writes the top-level +// terms itself. +export function setAffinity(overrides: Tree, rules: AffinityRule[], gpuPicked: boolean) { + for (const scope of new Set(rules.map((r) => r.scope))) { + const mine = rules.filter((r) => r.scope === scope); + if (scope !== "affinity" && mine.every((r) => r.drop)) { + set(overrides, scope, null); + continue; + } + for (const list of new Set(mine.map((r) => r.list))) { + const kept = mine.filter((r) => r.list === list && !r.drop).map((r) => r.rule); + if (kept.length > 0) set(overrides, `${scope}.${list}`, kept); + else if (list !== LISTS[0]) set(overrides, `${scope}.${list}`, []); + else if (!(scope === "affinity" && gpuPicked)) set(overrides, `${scope}.${REQUIRED_NODE}`, null); + } + } +} diff --git a/web/src/modules/swiss/lib/api.ts b/web/src/modules/swiss/lib/api.ts index de838be..3c1d2b5 100644 --- a/web/src/modules/swiss/lib/api.ts +++ b/web/src/modules/swiss/lib/api.ts @@ -319,6 +319,11 @@ export interface GPUScan { unlabelled: { nodes: number; gpus: number }; } +export interface Preset { + description?: string; + values?: Record; +} + export interface SiteProfile { name: string; namespace?: string; @@ -363,6 +368,8 @@ export interface SiteProfile { 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; + // Named values a deploy may pick one of; the console merges it into the form layer. + presets?: Record; nodes?: { gpusPerNode?: number; // Required by swissd; [] is a site without GPUs. diff --git a/web/src/modules/swiss/lib/formLayer.test.ts b/web/src/modules/swiss/lib/formLayer.test.ts new file mode 100644 index 0000000..a2aabce --- /dev/null +++ b/web/src/modules/swiss/lib/formLayer.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { Plan } from "@swiss/lib/api"; +import { EMPTY, formFromPlan, planRequest, type Form } from "@swiss/components/DeploySettings"; +import { affinityInvalid, affinityRules, editRule, setAffinity } from "./affinity"; +import { overlay, withPreset } from "./formLayer"; + +const storageNet = { key: "ebof.modelsphere.io/storage-net", operator: "In", values: ["true"] }; +const gpuTerm = { matchExpressions: [{ key: "nvidia.com/gpu.product", operator: "In", values: ["B300"] }] }; +const plan = (form: Record) => ({ release: { name: "r", namespace: "n" }, layers: { form } }) as unknown as Plan; +// What an upgrade of a release with this form layer sends, after edit. +const upgrade = (layer: Record, edit: (f: Form) => Form = (f) => f) => planRequest(edit(formFromPlan(plan(layer))), { model: "m", formLayer: layer }).overrides!; + +describe("withPreset", () => { + it("keeps both sides' list items once, and the form's scalars", () => { + const preset = { tolerations: [{ key: "ebof-test", operator: "Exists" }], nodeSelector: { a: "1" }, priorityClassName: "low" }; + const got = withPreset({ tolerations: [{ operator: "Exists", key: "ebof-test" }, { key: "gpu", operator: "Exists" }], nodeSelector: { b: "2" }, priorityClassName: "high" }, preset); + expect(got).toEqual({ + tolerations: [{ key: "ebof-test", operator: "Exists" }, { key: "gpu", operator: "Exists" }], + nodeSelector: { a: "1", b: "2" }, + priorityClassName: "high", + }); + expect(preset.tolerations).toHaveLength(1); + }); + + it("is the form's values without a preset", () => { + const o = { a: 1 }; + expect(withPreset(o, undefined)).toBe(o); + }); +}); + +describe("overlay", () => { + it("puts the form's values over the layer, a list replacing", () => { + const layer = { pd: { prefill: { x: 1 } }, tolerations: [{ key: "a" }], extraArgs: ["--a"] }; + expect(overlay(layer, { pd: { decode: { y: 2 } }, tolerations: [{ key: "b" }] })).toEqual({ pd: { prefill: { x: 1 }, decode: { y: 2 } }, tolerations: [{ key: "b" }], extraArgs: ["--a"] }); + expect(layer.tolerations).toEqual([{ key: "a" }]); + }); +}); + +describe("affinity rules", () => { + const layer = { + affinity: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [gpuTerm] } } }, + pd: { + prefill: { affinityReplace: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [storageNet] }] } } } }, + decode: { + affinityReplace: { + nodeAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 100, preference: { matchExpressions: [{ ...storageNet, operator: "NotIn" }] } }], + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [gpuTerm] }, + }, + }, + }, + }, + }; + const drop = (pick: (r: ReturnType[number]) => boolean) => affinityRules(layer).map((r) => (pick(r) ? { ...r, drop: true } : r)); + + it("lists every rule, in order", () => { + expect(affinityRules(layer).map((r) => r.scope)).toEqual(["affinity", "pd.prefill.affinityReplace", "pd.decode.affinityReplace", "pd.decode.affinityReplace"]); + }); + + it("writes each list back as it stands", () => { + const o = {}; + setAffinity(o, affinityRules(layer), false); + expect(o).toEqual(layer); + }); + + it("sends a role with no rule left as null, which the chart reads as inherit", () => { + const o = {}; + setAffinity(o, drop((r) => r.scope === "pd.prefill.affinityReplace"), false); + expect((o as typeof layer).pd.prefill).toEqual({ affinityReplace: null }); + }); + + it("sends an emptied list as [], and emptied required terms as a null block", () => { + const o = {}; + setAffinity(o, drop((r) => r.scope === "pd.decode.affinityReplace" && r.list.includes("required")), false); + expect((o as typeof layer).pd.decode.affinityReplace.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution).toBeNull(); + const p = {}; + setAffinity(p, drop((r) => r.list.includes("preferred")), false); + expect((p as typeof layer).pd.decode.affinityReplace.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution).toEqual([]); + }); + + it("leaves emptied top-level required terms to swissd when products are picked", () => { + const o = {}; + setAffinity(o, drop((r) => r.scope === "affinity"), true); + expect(o).not.toHaveProperty("affinity"); + }); + + it("edits a rule as YAML, keeping a string a string", () => { + const [, prefill] = affinityRules(layer); + const edited = editRule(prefill, prefill.text.replace("storage-net", "storage-nvme")); + expect(edited.rule).toEqual({ matchExpressions: [{ ...storageNet, key: "ebof.modelsphere.io/storage-nvme" }] }); + expect(affinityInvalid([edited])).toBe(false); + const broken = editRule(prefill, "matchExpressions: ["); + expect(broken.error).toBeTruthy(); + expect(broken.rule).toEqual(prefill.rule); + expect(affinityInvalid([broken])).toBe(true); + expect(affinityInvalid([{ ...broken, drop: true }])).toBe(false); + }); +}); + +describe("an upgrade rebuilds the form layer", () => { + it("carries every key the form has no field for", () => { + const got = upgrade({ extraArgs: ["--x"], pd: { prefill: { replicas: 2 } } }); + expect(got.extraArgs).toEqual(["--x"]); + expect(got.pd).toEqual({ prefill: { replicas: 2 } }); + }); + + it("sends an edited affinity rule in its list", () => { + const layer = { pd: { prefill: { affinityReplace: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [storageNet] }, gpuTerm] } } } } } }; + const got = upgrade(layer, (f) => ({ ...f, affinity: f.affinity.map((r, i) => (i === 0 ? editRule(r, r.text.replace('"true"', '"yes"')) : r)) })); + expect(got.pd).toEqual({ prefill: { affinityReplace: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [{ ...storageNet, values: ["yes"] }] }, gpuTerm] } } } } }); + }); +}); + +describe("tolerations on an upgrade", () => { + const deployed = [{ operator: "Exists" }, { key: "ebof-test", operator: "Exists", effect: "NoExecute", tolerationSeconds: 300 }, { key: "x", value: "y" }]; + + it("sends each one back exactly as deployed", () => { + const f = formFromPlan(plan({ tolerations: deployed })); + expect(f.tolerations.map((t) => t.operator)).toEqual(["Exists", "Exists", "Equal"]); + expect(upgrade({ tolerations: deployed }).tolerations).toEqual(deployed); + }); + + it("keeps the keys no field shows on an edited row", () => { + const got = upgrade({ tolerations: deployed }, (f) => ({ ...f, tolerations: f.tolerations.map((t, i) => (i === 1 ? { ...t, effect: "NoSchedule" } : t)) })); + expect((got.tolerations as unknown[])[1]).toEqual({ key: "ebof-test", operator: "Exists", effect: "NoSchedule", tolerationSeconds: 300 }); + }); + + it("sends an empty list once every row is removed", () => { + expect(upgrade({ tolerations: deployed }, (f) => ({ ...f, tolerations: [] })).tolerations).toEqual([]); + expect(planRequest(EMPTY, { model: "m" }).overrides!).not.toHaveProperty("tolerations"); + }); + + it("drops a new row with no key", () => { + const f = { ...EMPTY, tolerations: [{ key: "", operator: "Exists" as const, value: "", effect: "" }] }; + expect(planRequest(f, { model: "m" }).overrides!).not.toHaveProperty("tolerations"); + }); +}); + +describe("planRequest with a preset", () => { + it("merges the preset's tolerations with the rows", () => { + const f = { ...EMPTY, preset: "storage-net", tolerations: [{ key: "gpu", operator: "Exists" as const, value: "", effect: "" }] }; + const got = planRequest(f, { model: "m", preset: { tolerations: [{ key: "ebof-test", operator: "Exists" }], nodeSelector: { "ebof.modelsphere.io/storage-net": "true" } } }).overrides!; + expect(got.tolerations).toEqual([{ key: "ebof-test", operator: "Exists" }, { key: "gpu", operator: "Exists" }]); + expect(got.nodeSelector).toEqual({ "ebof.modelsphere.io/storage-net": "true" }); + }); +}); diff --git a/web/src/modules/swiss/lib/formLayer.ts b/web/src/modules/swiss/lib/formLayer.ts new file mode 100644 index 0000000..cba67b8 --- /dev/null +++ b/web/src/modules/swiss/lib/formLayer.ts @@ -0,0 +1,48 @@ +import type { SiteProfile } from "@swiss/lib/api"; + +type Tree = Record; + +export const isMap = (v: unknown): v is Tree => !!v && typeof v === "object" && !Array.isArray(v); + +// Key order does not make two list items different. +const canon = (v: unknown) => + JSON.stringify(v, (_, x: unknown) => (isMap(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x)); + +export function presetValues(profile: SiteProfile | undefined, name: string): Tree | undefined { + return name ? profile?.presets?.[name]?.values : undefined; +} + +// withPreset puts the form's values over a preset's: maps merge, a list keeps +// the preset's items and adds the form's it lacks, and a scalar the form sets +// wins. +export function withPreset(overrides: Tree, preset: Tree | undefined): Tree { + return preset ? merge(structuredClone(preset), overrides, true) : overrides; +} + +// overlay is a release's form layer with the form's values over it, as an +// upgrade rebuilds it: maps merge, and a list or scalar the form sends +// replaces the release's. +export function overlay(layer: Tree, overrides: Tree): Tree { + return merge(structuredClone(layer), overrides, false); +} + +function merge(base: Tree, over: Tree, union: boolean): Tree { + for (const [k, v] of Object.entries(over)) { + const b = base[k]; + if (isMap(b) && isMap(v)) { + merge(b, v, union); + } else if (union && Array.isArray(b) && Array.isArray(v)) { + const seen = new Set(b.map(canon)); + for (const item of v) { + const c = canon(item); + if (!seen.has(c)) { + seen.add(c); + b.push(item); + } + } + } else { + base[k] = v; + } + } + return base; +} diff --git a/web/src/modules/swiss/lib/profile.test.ts b/web/src/modules/swiss/lib/profile.test.ts index b439e3f..5f3da4d 100644 --- a/web/src/modules/swiss/lib/profile.test.ts +++ b/web/src/modules/swiss/lib/profile.test.ts @@ -10,9 +10,13 @@ import { gpuRows, gpuTable, hasGPUErrors, + hasPresetErrors, inOrder, isVendorDefault, makeVendorDefault, + presetErrors, + presetRow, + putPreset, vendorGroups, withFeature, type GPURow, @@ -130,3 +134,27 @@ describe("feature defaults", () => { expect(withFeature({ features: { cart: true } }, "scaler", false)).toEqual({ features: { cart: true, scaler: false } }); }); }); + +describe("presets", () => { + const presets = { a: { description: "A", values: { tolerations: [{ key: "x", operator: "Exists" }] } }, b: {} }; + + it("round-trips a preset through its row", () => { + const row = presetRow("a", presets.a); + expect(row.values).toContain("tolerations:"); + expect(putPreset(presets, "a", row)).toEqual(presets); + }); + + it("flags a missing or taken name, YAML that does not parse, and values that are not a map", () => { + expect(presetErrors(presetRow(" "), [])).toEqual({ name: "required" }); + expect(presetErrors({ ...presetRow("b"), values: "a: 1" }, ["b"]).name).toBe("duplicate"); + expect(presetErrors({ ...presetRow("c"), values: "a: [" }, []).values).toBe("yaml"); + expect(presetErrors({ ...presetRow("c"), values: "- a" }, []).values).toBe("map"); + expect(hasPresetErrors(presetErrors(presetRow("c"), []))).toBe(false); + }); + + it("renames, adds and removes", () => { + expect(putPreset(presets, "b", { name: "c", description: "", values: "" })).toEqual({ a: presets.a, c: {} }); + expect(putPreset(presets, undefined, { name: " d ", description: " D ", values: "x: 1" })).toEqual({ ...presets, d: { description: "D", values: { x: 1 } } }); + expect(putPreset({ a: {} }, "a")).toBeUndefined(); + }); +}); diff --git a/web/src/modules/swiss/lib/profile.ts b/web/src/modules/swiss/lib/profile.ts index 40f38d2..4fcb12c 100644 --- a/web/src/modules/swiss/lib/profile.ts +++ b/web/src/modules/swiss/lib/profile.ts @@ -1,4 +1,6 @@ -import type { GPUSpec, SiteProfile } from "@swiss/lib/api"; +import { parse, stringify } from "yaml"; +import type { GPUSpec, Preset, SiteProfile } from "@swiss/lib/api"; +import { isMap } from "@swiss/lib/formLayer"; export type CatalogError = "required" | "format" | "duplicate"; @@ -169,3 +171,49 @@ export function withFeature(defaults: SiteProfile["defaults"], key: FeatureKey, if (!next.features) delete next.features; return Object.keys(next).length ? next : undefined; } + +// A preset as its editor holds it, the values as YAML. +export interface PresetRow { + name: string; + description: string; + values: string; +} + +export interface PresetErrors { + name?: "required" | "duplicate"; + values?: "yaml" | "map"; + // The YAML parser's message. + yaml?: string; +} + +export const presetRow = (name = "", p?: Preset): PresetRow => ({ name, description: p?.description ?? "", values: p?.values ? stringify(p.values) : "" }); + +// others: the names of the presets this row is not. +export function presetErrors(row: PresetRow, others: string[]): PresetErrors { + const errs: PresetErrors = {}; + const name = row.name.trim(); + if (!name) errs.name = "required"; + else if (others.includes(name)) errs.name = "duplicate"; + try { + const v: unknown = parse(row.values); + if (v != null && !isMap(v)) errs.values = "map"; + } catch (e) { + errs.values = "yaml"; + errs.yaml = e instanceof Error ? e.message.split("\n")[0] : String(e); + } + return errs; +} + +export const hasPresetErrors = (e: PresetErrors) => !!(e.name || e.values); + +// putPreset stores a row that has no errors, under its name, in place of the +// preset named from; a profile left with none has no presets at all. +export function putPreset(presets: SiteProfile["presets"], from: string | undefined, row?: PresetRow): SiteProfile["presets"] { + const next = { ...presets }; + if (from !== undefined) delete next[from]; + if (row) { + const values = parse(row.values) as Preset["values"] | null; + next[row.name.trim()] = { ...(row.description.trim() && { description: row.description.trim() }), ...(values && { values }) }; + } + return Object.keys(next).length ? next : undefined; +} diff --git a/web/src/modules/swiss/routes/Deploy.tsx b/web/src/modules/swiss/routes/Deploy.tsx index 4cbcb1b..806ba12 100644 --- a/web/src/modules/swiss/routes/Deploy.tsx +++ b/web/src/modules/swiss/routes/Deploy.tsx @@ -15,6 +15,7 @@ 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 { presetValues } from "@swiss/lib/formLayer"; import { deployCapacity, pickableProducts, unlistedProducts, vendorResources } from "@swiss/lib/gpu"; import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { InstallMethodChoice } from "@swiss/components/InstallMethod"; @@ -125,6 +126,7 @@ export function Deploy() { forceGpuProducts: unlistedProducts(model.data?.variant.requires.gpuProduct, form.gpuProducts).length > 0, cacheUnsupported: !!cache.unsupported, inputs: model.data?.variant.inputs, + preset: presetValues(profile.data?.profile, form.preset), }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -285,6 +287,7 @@ export function Deploy() { : undefined } cache={cache} + presets={profile.data?.profile?.presets} /> )} + {p.presets && Object.keys(p.presets).length > 0 && ( + + + Presets + + Named values a deploy may pick one of, merged into its form values. + + + + {Object.entries(p.presets).map(([name, preset]) => ( + + + {name} + {preset.description && {preset.description}} + + {toYaml(preset.values ?? {})} + + ))} + + + )} + {p.extra && Object.keys(p.extra).length > 0 && ( diff --git a/web/src/modules/swiss/routes/Upgrade.tsx b/web/src/modules/swiss/routes/Upgrade.tsx index a93fb44..7d90fff 100644 --- a/web/src/modules/swiss/routes/Upgrade.tsx +++ b/web/src/modules/swiss/routes/Upgrade.tsx @@ -31,6 +31,7 @@ import { CatalogBadge, SchemaBadge, releaseCatalog as catalogOfRelease } from "@ import { movableCatalogs } from "@swiss/lib/upgrade"; import { cacheCard } from "@swiss/lib/cache"; import { CHECK_MS, inputRows, inputsAnswered, siteSaves } from "@swiss/lib/inputs"; +import { affinityInvalid } from "@swiss/lib/affinity"; import { SiteSaves, useSiteSaver } from "@swiss/components/SiteSaves"; import { useDebounced } from "@swiss/lib/useDebounced"; import { chartVersionChoice, Select, TargetRow } from "@swiss/components/ChartVersion"; @@ -170,6 +171,7 @@ export function Upgrade() { fromRelease: release, cacheUnsupported: !!cache.unsupported, inputs: entry.data?.variant.inputs, + formLayer: current.data!.layers?.form, }), ), // The previous plan is superseded the moment a recompose starts. Dropping @@ -498,7 +500,7 @@ export function Upgrade() { onTab={setTab} onCompose={() => planM.mutate()} composing={planM.isPending} - composeDisabled={!rollbackTo && !inputsAnswered(inputs, checkedInputs)} + composeDisabled={!rollbackTo && (!inputsAnswered(inputs, checkedInputs) || affinityInvalid(form.affinity))} composeLabel={planM.isPending ? "Composing…" : plan ? "Recompose upgrade" : "Compose upgrade"} applyFlags={applyFlags(form)} onDiff={setDiff}
{t("profile.hints.presets")}
{picked.description}
{t("deploy.fields.affinityInvalid", { error: r.error })}
{f("presetsNone")}
{toYaml(picked.values ?? {})}
{r.error}
None: a deploy has no preset to pick.
+ + {errs.name === "required" && "Needs a name. "} + {errs.name === "duplicate" && "Another preset has this name. "} + {errs.values === "map" && "Values must be a map of chart keys."} + {errs.values === "yaml" && `Not valid YAML: ${errs.yaml}`} +
+ Named values a deploy may pick one of, merged into its form values. +
{toYaml(preset.values ?? {})}