From df10b5821389aa5b03d43764f6f138852a942f76 Mon Sep 17 00:00:00 2001 From: imneov Date: Thu, 5 Nov 2026 20:40:51 +0800 Subject: [PATCH 1/2] feat(web): edit deployment YAML during final review Populate the review editor from composed values, retain only changed overrides, and require a fresh verified plan and diff before applying. Fixes #116 --- docs/console-design.md | 19 +++++ web/package-lock.json | 38 +++++++++- web/package.json | 3 +- .../inferences/components/DeploySheet.tsx | 51 ++++++++++--- .../components/DeployYamlEditor.tsx | 41 ++++++++++ .../modules/inferences/deploy-yaml.test.ts | 72 ++++++++++++++++++ web/src/modules/inferences/deploy-yaml.ts | 76 +++++++++++++++++++ web/src/modules/inferences/locales/en-US.json | 6 ++ web/src/modules/inferences/locales/zh-CN.json | 6 ++ 9 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 web/src/modules/inferences/components/DeployYamlEditor.tsx create mode 100644 web/src/modules/inferences/deploy-yaml.test.ts create mode 100644 web/src/modules/inferences/deploy-yaml.ts diff --git a/docs/console-design.md b/docs/console-design.md index f4fdad0..9af0994 100644 --- a/docs/console-design.md +++ b/docs/console-design.md @@ -362,6 +362,25 @@ The master switch and every `needsAdaptive` field share one section (console #75 | Integrity | Preserve unrelated form values and explicit advanced YAML; reject source/chart identity or other layer changes between the two compositions | | Application | Only the cleaned plan enters the existing diff/revision/apply flow; composition performs no workload write | +### Deployment YAML review + +The final deploy/upgrade review offers YAML editing, following Rise Global's deployment drawer. + +| Concern | Decision | +|---|---| +| Contents | Show the plan's merged values in deployment order; use the YAML library for serialization and validation | +| Overrides | Store only changed keys in the edit layer; preserve existing edits, replace lists, and use Helm null tombstones for removals | +| Identity | Recompose against the reviewed catalog/model/variant/chart versions; retain release context on upgrades | +| Verification | Reject a recomposed result that differs from the edited values, including autoscale shadowing or changed defaults | +| Drafts | Keep invalid drafts visible; pause background form reseeding while reviewing; closing warns only for actual changes | +| Apply | Re-run diff for the new hash; block deployment while editing, composing, or awaiting a successful diff | + +```text +form -> compose -> review -> edit YAML -> validate -> recompose -> diff -> review -> apply + ^ | + +--- preserve errors ---+ +``` + ### Workload reads (`/api/k8s/...`) Events and logs are Kubernetes data, not swiss's, so console serves them itself diff --git a/web/package-lock.json b/web/package-lock.json index c05f4d0..fdfee82 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,7 +24,8 @@ "recharts": "^3.10.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.7.0" + "tailwind-merge": "^3.7.0", + "yaml": "^2.9.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", @@ -1417,6 +1418,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1433,6 +1435,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1449,6 +1452,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1465,6 +1469,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1481,6 +1486,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1497,6 +1503,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1513,6 +1520,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1529,6 +1537,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1545,6 +1554,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1561,6 +1571,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1577,6 +1588,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1593,6 +1605,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1609,6 +1622,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1625,6 +1639,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1641,6 +1656,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1657,6 +1673,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1673,6 +1690,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1689,6 +1707,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1705,6 +1724,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1721,6 +1741,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5093,6 +5114,21 @@ "node": ">=20.11" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web/package.json b/web/package.json index 3d4c87b..7fd8a57 100644 --- a/web/package.json +++ b/web/package.json @@ -30,7 +30,8 @@ "recharts": "^3.10.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.7.0" + "tailwind-merge": "^3.7.0", + "yaml": "^2.9.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index 9ed8024..07bba97 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { stringify } from "yaml"; import { useNavigate } from "react-router"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -39,6 +40,8 @@ import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { useT } from "@/modules/inferences/i18n"; import { detailPath, sloEnabled } from "@/modules/inferences/lib"; import { SECTIONS, chartChoice, defaultServiceId, pipelineState, serviceIdError, whatMoves, type Section } from "@/modules/inferences/deploy-lib"; +import { matchesDeploymentYaml, yamlPlanRequest } from "../deploy-yaml"; +import { DeployYamlEditor } from "./DeployYamlEditor"; import { DeployForm } from "@/modules/inferences/components/DeployForm"; import { ExperimentalBadge } from "@/modules/inferences/components/InstallMethodLabel"; import { AdaptivePlanChangedError, composeAdaptivePlan } from "../adaptive-concurrency"; @@ -151,6 +154,8 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const dirty = useDirty({ form, version, variant, moveTo, chartVersion }, { form: seed ? reseed(initial, {}, seed) : initial, version: upgrade ? "" : (target.version ?? ""), variant: upgrade ? "" : (target.variant ?? ""), moveTo: "", chartVersion: "" }); const [step, setStep] = useState<"form" | "review">("form"); + const [editingYaml, setEditingYaml] = useState(false); + const [yamlDirty, setYamlDirty] = useState(false); const [submitted, setSubmitted] = useState(false); const [expanded, setExpanded] = useState(OPEN); const [plan, setPlan] = useState(null); @@ -167,7 +172,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const siteDefaults = useMemo(() => siteSwitchDefaults(profile.data), [profile.data]); const variantValues = entry.data?.variant.values; useEffect(() => { - if (!seedReady) return; + if (!seedReady || step !== "form") return; const next = switchDefaults(siteDefaults, variantValues); if (seed && JSON.stringify(seed) === JSON.stringify(next)) return; setForm((f) => reseed(f, seed ?? {}, next)); @@ -177,7 +182,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( setDiff(null); setStep("form"); } - }, [seedReady, siteDefaults, variantValues, seed]); + }, [seedReady, siteDefaults, variantValues, seed, step]); const release = upgrade ? target.release : form.serviceId || defaultServiceId(target.model); const namespace = upgrade ? target.namespace : form.namespace || cluster.data?.namespace || ""; @@ -187,21 +192,28 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const forceGpuProducts = unlistedProducts(v?.requires.gpuProduct, form.gpuProducts).length > 0; const change = (patch: Partial
) => setForm((f) => ({ ...f, ...patch })); const compose = useMutation({ - mutationFn: async () => { + mutationFn: async (yaml?: string) => { try { - return await composeAdaptivePlan(planRequest( + const request = planRequest( { ...form, serviceId: form.serviceId || (upgrade ? form.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 }, profile.data?.profile, - ), form, routeOptions.data, deployApi.plan, catalogName); + ); + const prepared = yaml === undefined ? request : yamlPlanRequest(request, plan!, yaml); + const composed = await composeAdaptivePlan(prepared, form, routeOptions.data, deployApi.plan, catalogName); + if (yaml !== undefined && !matchesDeploymentYaml(composed, yaml)) throw new Error(t("deploy.review.yamlChanged")); + return composed; } catch (error) { if (error instanceof AdaptivePlanChangedError) throw new Error(t("deploy.fields.adaptiveChanged")); throw error; } }, - onSuccess: (pl) => { + onSuccess: (pl, yaml) => { + if (yaml !== undefined) setForm((previous) => ({ ...previous, edits: pl.layers.edit ? stringify(pl.layers.edit) : "" })); + setEditingYaml(false); + setYamlDirty(false); setPlan(pl); setDiff(null); setStep("review"); @@ -378,7 +390,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( ); return ( - !o && onClose()} dirty={dirty && step === "form"} onDiscard={onClose}> + !o && onClose()} dirty={dirty || yamlDirty} onDiscard={onClose}> {upgrade ? t("deploy.titleUpgrade", { release: target.release }) : t("deploy.title")} @@ -443,7 +455,23 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( ) : ( plan && ( - { + setDiff(null); + apply.reset(); + compose.mutate(text); + }} + onCancel={() => { + setEditingYaml(false); + setYamlDirty(false); + compose.reset(); + if (!diff) dryRun.mutate(plan); + }} + /> : ) : ( <> - - + diff --git a/web/src/modules/inferences/components/DeployYamlEditor.tsx b/web/src/modules/inferences/components/DeployYamlEditor.tsx new file mode 100644 index 0000000..67b3f5c --- /dev/null +++ b/web/src/modules/inferences/components/DeployYamlEditor.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { Alert, AlertDescription, Button, FieldTextarea, FloatingField, SectionCard } from "@modelsphere/ui"; +import type { Plan } from "@swiss/lib/api"; +import { deploymentYaml, parseDeploymentYaml } from "../deploy-yaml"; +import { useT } from "../i18n"; + +export function DeployYamlEditor({ plan, busy, error, onSave, onCancel, onDirtyChange }: { + plan: Plan; + busy: boolean; + error?: string; + onSave: (text: string) => void; + onCancel: () => void; + onDirtyChange: (dirty: boolean) => void; +}) { + const t = useT(); + const [baseline] = useState(() => deploymentYaml(plan)); + const [text, setText] = useState(baseline); + const [validationError, setValidationError] = useState(); + const validate = (value: string) => { + try { + parseDeploymentYaml(value); + setValidationError(undefined); + return true; + } catch (error) { + setValidationError(error instanceof Error ? error.message : String(error)); + return false; + } + }; + return ( + + + { setText(event.target.value); onDirtyChange(event.target.value !== baseline); validate(event.target.value); }} rows={24} spellCheck={false} disabled={busy} className="font-mono text-xs" /> + + {(validationError || error) && {validationError || error}} +
+ + +
+
+ ); +} diff --git a/web/src/modules/inferences/deploy-yaml.test.ts b/web/src/modules/inferences/deploy-yaml.test.ts new file mode 100644 index 0000000..b97afe4 --- /dev/null +++ b/web/src/modules/inferences/deploy-yaml.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { stringify } from "yaml"; +import type { Plan } from "@swiss/lib/api"; +import { deploymentValues, deploymentYaml, matchesDeploymentYaml, parseDeploymentYaml, yamlPlanRequest } from "./deploy-yaml"; + +const plan: Plan = { + apiVersion: "v1", hash: "original", engine: "vllm", profile: "site", + source: { catalogName: "private", model: "model", variant: "gpu", version: "1.2.3" }, + chart: { name: "vllm", version: "0.8.9" }, release: { name: "model", namespace: "models" }, + layers: { + defaults: { image: { repository: "example.com/engine", tag: "default" }, replicaCount: 1, flags: ["--default"], removable: true }, + catalog: { image: { tag: "catalog" }, flags: ["--catalog"] }, + form: { modelRoute: { enabled: true }, replicaCount: 2 }, + edit: { image: { tag: "custom" }, custom: { "key: with spaces": "override" } }, + autoscale: { replicaCount: 3 }, + }, +}; + +describe("deployment YAML", () => { + it("shows effective values in deployment layer order and round-trips YAML keys and scalar types", () => { + const values = deploymentValues(plan); + expect(values).toMatchObject({ image: { repository: "example.com/engine", tag: "custom" }, replicaCount: 3, flags: ["--catalog"] }); + expect(parseDeploymentYaml(deploymentYaml(plan))).toEqual(values); + }); + + it("keeps existing edits without copying inherited values into the edit layer on a no-op save", () => { + const request = yamlPlanRequest({ model: "model" }, plan, deploymentYaml(plan)); + expect(parseDeploymentYaml(request.editsYAML!)).toEqual(plan.layers.edit); + expect(request.overrides).toEqual(plan.layers.form); + }); + + it("sends nested changes, replaced arrays and removals while retaining existing edits", () => { + const values = deploymentValues(plan); + values.image = { repository: "example.com/changed", tag: "custom" }; + values.flags = ["--new"]; + delete values.removable; + const request = yamlPlanRequest({}, plan, stringify(values)); + expect(parseDeploymentYaml(request.editsYAML!)).toEqual({ + image: { repository: "example.com/changed", tag: "custom" }, + custom: { "key: with spaces": "override" }, flags: ["--new"], removable: null, + }); + expect(plan.layers.defaults.removable).toBe(true); + }); + + it("accepts removals represented by Helm null tombstones in the recomposed plan", () => { + const values = deploymentValues(plan); + delete values.removable; + values.image = { repository: "example.com/engine" }; + const text = stringify(values); + const request = yamlPlanRequest({}, plan, text); + const composed = { ...plan, layers: { ...plan.layers, edit: parseDeploymentYaml(request.editsYAML!) } }; + expect(matchesDeploymentYaml(composed, text)).toBe(true); + expect(matchesDeploymentYaml(composed, stringify({ ...values, removable: null }))).toBe(true); + expect(deploymentValues(composed)).not.toHaveProperty("removable"); + }); + + it("pins a reviewed upgrade to its resolved identity and retains upgrade state", () => { + const request = yamlPlanRequest({ fromRelease: "model", version: "latest", catalog: "old", editsYAML: "stale: true" }, plan, deploymentYaml(plan)); + expect(request).toMatchObject({ fromRelease: "model", catalog: "private", model: "model", version: "1.2.3", variant: "gpu", chartVersion: "0.8.9", release: "model", namespace: "models" }); + }); + + it("detects autoscale shadowing and underlying value drift after composition", () => { + const values = deploymentValues(plan); + expect(matchesDeploymentYaml(plan, stringify(values))).toBe(true); + values.replicaCount = 9; + expect(matchesDeploymentYaml(plan, stringify(values))).toBe(false); + }); + + it.each(["", "[]", "a: [", "a: 1\na: 2", "a: 1\n---\nb: 2", "!!set { a: null }", "a: !!set { b: null }", "a: .nan", "a: .inf", "a: &loop { recursive: *loop }"])("rejects invalid or non-mapping YAML: %s", (text) => { + expect(() => parseDeploymentYaml(text)).toThrow(); + }); +}); diff --git a/web/src/modules/inferences/deploy-yaml.ts b/web/src/modules/inferences/deploy-yaml.ts new file mode 100644 index 0000000..2276a30 --- /dev/null +++ b/web/src/modules/inferences/deploy-yaml.ts @@ -0,0 +1,76 @@ +import { parseDocument, stringify } from "yaml"; +import { layersOf } from "@swiss/components/Provenance"; +import type { Plan, PlanRequest } from "@swiss/lib/api"; + +type Values = Record; +const isMap = (value: unknown): value is Values => !!value && typeof value === "object" && !Array.isArray(value); + +// Helm merges maps recursively; lists and scalar values replace the previous layer. +function mergeValues(previous: Values, next: Values, removeNulls = false): Values { + return Object.fromEntries([...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => { + if (!Object.hasOwn(next, key)) return [[key, previous[key]]]; + if (removeNulls && next[key] === null) return []; + const value = isMap(next[key]) ? mergeValues(isMap(previous[key]) ? previous[key] : {}, next[key], removeNulls) : next[key]; + return [[key, value]]; + })); +} + +export function deploymentValues(plan: Plan): Values { + return layersOf(plan).reduce((values, layer) => mergeValues(values, plan.layers[layer], true), {}); +} + +export function deploymentYaml(plan: Plan): string { + return stringify(deploymentValues(plan)); +} + +export function parseDeploymentYaml(text: string): Values { + const document = parseDocument(text, { uniqueKeys: true }); + if (document.errors.length) throw new Error(document.errors[0].message); + if (document.warnings.length) throw new Error(document.warnings[0].message); + const values: unknown = document.toJS({ maxAliasCount: 100 }); + JSON.stringify(values, (_key, value: unknown) => { + if (typeof value === "number" && !Number.isFinite(value)) throw new Error("YAML values must contain finite numbers."); + if (value && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) throw new Error("YAML values must contain plain mappings, lists and scalars."); + return value; + }); + if (!isMap(values)) throw new Error("YAML must contain a mapping of deployment values."); + return values; +} + +function sameValue(previous: unknown, next: unknown): boolean { + if (isMap(previous) && isMap(next)) return Object.keys(previous).length === Object.keys(next).length + && Object.keys(previous).every((key) => Object.hasOwn(next, key) && sameValue(previous[key], next[key])); + if (Array.isArray(previous) && Array.isArray(next)) return previous.length === next.length && previous.every((value, index) => sameValue(value, next[index])); + return Object.is(previous, next); +} + +// Only changed values enter the edit layer, so viewing YAML never pins inherited defaults. +function changedValues(previous: Values, next: Values): Values { + return Object.fromEntries([...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => { + if (!Object.hasOwn(next, key)) return [[key, null]]; + if (sameValue(previous[key], next[key])) return []; + const value = isMap(previous[key]) && isMap(next[key]) ? changedValues(previous[key], next[key]) : next[key]; + return [[key, value]]; + })); +} + +export function yamlPlanRequest(request: PlanRequest, plan: Plan, text: string): PlanRequest { + const changes = changedValues(deploymentValues(plan), parseDeploymentYaml(text)); + const edits = mergeValues(plan.layers.edit ?? {}, changes); + return { + ...request, + catalog: plan.source.catalogName || request.catalog, + model: plan.source.model, + version: plan.source.version, + variant: plan.source.variant, + chartVersion: plan.chart.version, + release: plan.release.name, + namespace: plan.release.namespace, + overrides: plan.layers.form ?? {}, + editsYAML: Object.keys(edits).length ? stringify(edits) : undefined, + }; +} + +export function matchesDeploymentYaml(plan: Plan, text: string): boolean { + return sameValue(deploymentValues(plan), mergeValues({}, parseDeploymentYaml(text), true)); +} diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 670b2f5..0b20598 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -624,6 +624,12 @@ "length": "At most 53 characters" }, "review": { + "discardYaml": "Discard YAML changes", + "returnToReview": "Back to review", + "yamlChanged": "The composed configuration differs from this YAML. A value is controlled by autoscaling or the underlying configuration has changed. Return to the form and compose a fresh plan.", + "editYaml": "Edit YAML", + "saveYaml": "Save and preview changes", + "yamlHint": "Edit the deployment values. Saving recomposes the plan and runs a new dry run.", "title": "Review", "plan": "Plan", "moves": "What moves", diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index b859860..f2a20da 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -613,6 +613,12 @@ "length": "最多 53 个字符" }, "review": { + "discardYaml": "放弃 YAML 修改", + "returnToReview": "返回预览", + "yamlChanged": "生成的配置与 YAML 不一致:存在自动扩缩容控制的字段,或基础配置已更新。请返回表单重新生成计划。", + "editYaml": "编辑 YAML", + "saveYaml": "保存并预览变更", + "yamlHint": "编辑当前部署配置。保存后会重新生成部署计划并预检。", "title": "预览变更", "plan": "部署计划", "moves": "将变化的内容", From d0c037d14229c9a85c32e7f970f2064cad2991ab Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Fri, 6 Nov 2026 18:17:24 +0800 Subject: [PATCH 2/2] feat: redesign the edit YAML feature --- docs/console-design.md | 19 -- web/packages/ui/src/components/code-block.tsx | 11 +- web/packages/ui/src/index.ts | 2 +- .../inferences/components/DeployForm.tsx | 12 +- .../inferences/components/DeploySheet.tsx | 70 ++-- .../components/DeployYamlEditor.tsx | 41 --- .../inferences/components/KeySearch.tsx | 70 ++++ .../components/OverrideYaml.test.tsx | 40 +++ .../inferences/components/OverrideYaml.tsx | 302 ++++++++++++++++++ .../inferences/components/YamlTextarea.tsx | 80 +++++ .../modules/inferences/deploy-yaml.test.ts | 72 ----- web/src/modules/inferences/deploy-yaml.ts | 76 ----- web/src/modules/inferences/locales/en-US.json | 24 +- web/src/modules/inferences/locales/zh-CN.json | 24 +- .../modules/inferences/override-yaml.test.ts | 122 +++++++ web/src/modules/inferences/override-yaml.ts | 161 ++++++++++ web/src/modules/swiss/lib/api.ts | 10 + 17 files changed, 859 insertions(+), 277 deletions(-) delete mode 100644 web/src/modules/inferences/components/DeployYamlEditor.tsx create mode 100644 web/src/modules/inferences/components/KeySearch.tsx create mode 100644 web/src/modules/inferences/components/OverrideYaml.test.tsx create mode 100644 web/src/modules/inferences/components/OverrideYaml.tsx create mode 100644 web/src/modules/inferences/components/YamlTextarea.tsx delete mode 100644 web/src/modules/inferences/deploy-yaml.test.ts delete mode 100644 web/src/modules/inferences/deploy-yaml.ts create mode 100644 web/src/modules/inferences/override-yaml.test.ts create mode 100644 web/src/modules/inferences/override-yaml.ts diff --git a/docs/console-design.md b/docs/console-design.md index 9af0994..f4fdad0 100644 --- a/docs/console-design.md +++ b/docs/console-design.md @@ -362,25 +362,6 @@ The master switch and every `needsAdaptive` field share one section (console #75 | Integrity | Preserve unrelated form values and explicit advanced YAML; reject source/chart identity or other layer changes between the two compositions | | Application | Only the cleaned plan enters the existing diff/revision/apply flow; composition performs no workload write | -### Deployment YAML review - -The final deploy/upgrade review offers YAML editing, following Rise Global's deployment drawer. - -| Concern | Decision | -|---|---| -| Contents | Show the plan's merged values in deployment order; use the YAML library for serialization and validation | -| Overrides | Store only changed keys in the edit layer; preserve existing edits, replace lists, and use Helm null tombstones for removals | -| Identity | Recompose against the reviewed catalog/model/variant/chart versions; retain release context on upgrades | -| Verification | Reject a recomposed result that differs from the edited values, including autoscale shadowing or changed defaults | -| Drafts | Keep invalid drafts visible; pause background form reseeding while reviewing; closing warns only for actual changes | -| Apply | Re-run diff for the new hash; block deployment while editing, composing, or awaiting a successful diff | - -```text -form -> compose -> review -> edit YAML -> validate -> recompose -> diff -> review -> apply - ^ | - +--- preserve errors ---+ -``` - ### Workload reads (`/api/k8s/...`) Events and logs are Kubernetes data, not swiss's, so console serves them itself diff --git a/web/packages/ui/src/components/code-block.tsx b/web/packages/ui/src/components/code-block.tsx index f3d48be..d35d5d0 100644 --- a/web/packages/ui/src/components/code-block.tsx +++ b/web/packages/ui/src/components/code-block.tsx @@ -88,6 +88,13 @@ const COLOR: Record, string> = { w: "var(--code-keyword)", } +/** 一行的高亮,与 CodeBlock 画法相同。token 拼回去就是原文,所以可以垫在透明 textarea 下面做可编辑的高亮。 */ +export function highlightLine(line: string, language: CodeBlockLanguage = "shell"): React.ReactNode[] { + return tokenizeLine(line, language).map((tok, j) => + tok.t ? {tok.v} : {tok.v}, + ) +} + export function CodeBlock({ value, language = "shell", @@ -121,9 +128,7 @@ export function CodeBlock({ ) : null} - {tokenizeLine(line, language).map((tok, j) => - tok.t ? {tok.v} : {tok.v}, - )} + {highlightLine(line, language)} {line === "" ? "​" : null} diff --git a/web/packages/ui/src/index.ts b/web/packages/ui/src/index.ts index 1424731..379a176 100644 --- a/web/packages/ui/src/index.ts +++ b/web/packages/ui/src/index.ts @@ -63,7 +63,7 @@ export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGr export { Sheet, SheetBody, SheetCancelButton, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, useSheetClose } from "./components/ui/sheet" export type { SheetSize } from "./components/ui/sheet" export { FieldNotice } from "./components/floating-field" -export { CodeBlock } from "./components/code-block" +export { CodeBlock, highlightLine } from "./components/code-block" export { CodeToolbar } from "./components/code-toolbar" export type { CodeBlockProps, CodeBlockLanguage } from "./components/code-block" export type { CodeToolbarProps } from "./components/code-toolbar" diff --git a/web/src/modules/inferences/components/DeployForm.tsx b/web/src/modules/inferences/components/DeployForm.tsx index 3d7ed80..1c42b77 100644 --- a/web/src/modules/inferences/components/DeployForm.tsx +++ b/web/src/modules/inferences/components/DeployForm.tsx @@ -13,7 +13,6 @@ import { FieldHint, FieldInput, FieldSelect, - FieldTextarea, FloatingField, FormSection, Input, @@ -23,7 +22,7 @@ import { cn, } from "@modelsphere/ui"; import { Plus, RefreshCw, X } from "lucide-react"; -import type { ClusterInfo } from "@swiss/lib/api"; +import type { ClusterInfo, HelmValues } from "@swiss/lib/api"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; import { effective, imageChoices, type Form, type Toleration } from "@swiss/components/DeploySettings"; @@ -31,6 +30,7 @@ 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"; import { isAdaptiveOption, toggleAdaptive } from "../adaptive-concurrency"; +import { OverrideYaml } from "./OverrideYaml"; import { NAMED_VALUES, extraValue, fracOutOfRange, serviceIdError, type Section } from "@/modules/inferences/deploy-lib"; const EFFECTS = ["", "NoSchedule", "PreferNoSchedule", "NoExecute"]; @@ -56,9 +56,11 @@ export interface DeployFormProps { idPrefix: string; routeOptions?: RouteOptions | null; catalogRoute?: string; + // Composes the form with these overrides, storing nothing, for the Override YAML editor. + loadValues?: (edits: string) => Promise; } -export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute }: DeployFormProps) { +export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, loadValues }: DeployFormProps) { const t = useT(); const on = effective(form); const f = (k: string) => t(`deploy.fields.${k}`); @@ -247,9 +249,7 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster {sw("skipSchemaValidation", f("skipSchemaValidation"))} - - - + onChange({ edits })} load={loadValues} /> , )} diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index 07bba97..5cc4658 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import { stringify } from "yaml"; import { useNavigate } from "react-router"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -40,11 +39,9 @@ import { installMethod, llmsvcBlock } from "@swiss/lib/applyWith"; import { useT } from "@/modules/inferences/i18n"; import { detailPath, sloEnabled } from "@/modules/inferences/lib"; import { SECTIONS, chartChoice, defaultServiceId, pipelineState, serviceIdError, whatMoves, type Section } from "@/modules/inferences/deploy-lib"; -import { matchesDeploymentYaml, yamlPlanRequest } from "../deploy-yaml"; -import { DeployYamlEditor } from "./DeployYamlEditor"; import { DeployForm } from "@/modules/inferences/components/DeployForm"; import { ExperimentalBadge } from "@/modules/inferences/components/InstallMethodLabel"; -import { AdaptivePlanChangedError, composeAdaptivePlan } from "../adaptive-concurrency"; +import { AdaptivePlanChangedError, composeAdaptivePlan, withAdaptiveConcurrency } from "../adaptive-concurrency"; import { readRouteOptions } from "@/modules/inferences/api"; export type DeployTarget = @@ -154,8 +151,6 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const dirty = useDirty({ form, version, variant, moveTo, chartVersion }, { form: seed ? reseed(initial, {}, seed) : initial, version: upgrade ? "" : (target.version ?? ""), variant: upgrade ? "" : (target.variant ?? ""), moveTo: "", chartVersion: "" }); const [step, setStep] = useState<"form" | "review">("form"); - const [editingYaml, setEditingYaml] = useState(false); - const [yamlDirty, setYamlDirty] = useState(false); const [submitted, setSubmitted] = useState(false); const [expanded, setExpanded] = useState(OPEN); const [plan, setPlan] = useState(null); @@ -172,7 +167,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const siteDefaults = useMemo(() => siteSwitchDefaults(profile.data), [profile.data]); const variantValues = entry.data?.variant.values; useEffect(() => { - if (!seedReady || step !== "form") return; + if (!seedReady) return; const next = switchDefaults(siteDefaults, variantValues); if (seed && JSON.stringify(seed) === JSON.stringify(next)) return; setForm((f) => reseed(f, seed ?? {}, next)); @@ -182,7 +177,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( setDiff(null); setStep("form"); } - }, [seedReady, siteDefaults, variantValues, seed, step]); + }, [seedReady, siteDefaults, variantValues, seed]); const release = upgrade ? target.release : form.serviceId || defaultServiceId(target.model); const namespace = upgrade ? target.namespace : form.namespace || cluster.data?.namespace || ""; @@ -191,29 +186,28 @@ 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 })); + // 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 }, + profile.data?.profile, + ); + const loadValues = (edits: string) => { + const f = { ...form, edits }; + return deployApi.helmValues(withAdaptiveConcurrency(requestFor(f), f, routeOptions.data)); + }; const compose = useMutation({ - mutationFn: async (yaml?: string) => { + mutationFn: async () => { try { - const request = planRequest( - { ...form, serviceId: form.serviceId || (upgrade ? form.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 }, - profile.data?.profile, - ); - const prepared = yaml === undefined ? request : yamlPlanRequest(request, plan!, yaml); - const composed = await composeAdaptivePlan(prepared, form, routeOptions.data, deployApi.plan, catalogName); - if (yaml !== undefined && !matchesDeploymentYaml(composed, yaml)) throw new Error(t("deploy.review.yamlChanged")); - return composed; + return await composeAdaptivePlan(requestFor(form), form, routeOptions.data, deployApi.plan, catalogName); } catch (error) { if (error instanceof AdaptivePlanChangedError) throw new Error(t("deploy.fields.adaptiveChanged")); throw error; } }, - onSuccess: (pl, yaml) => { - if (yaml !== undefined) setForm((previous) => ({ ...previous, edits: pl.layers.edit ? stringify(pl.layers.edit) : "" })); - setEditingYaml(false); - setYamlDirty(false); + onSuccess: (pl) => { setPlan(pl); setDiff(null); setStep("review"); @@ -390,7 +384,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( ); return ( - !o && onClose()} dirty={dirty || yamlDirty} onDiscard={onClose}> + !o && onClose()} dirty={dirty && step === "form"} onDiscard={onClose}> {upgrade ? t("deploy.titleUpgrade", { release: target.release }) : t("deploy.title")} @@ -450,28 +444,13 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( routeOptions={routeOptions.isSuccess ? routeOptions.data : undefined} catalogRoute={catalogRoute} idPrefix={ID_PREFIX} + loadValues={readOnly ? undefined : loadValues} />
) : ( plan && ( - editingYaml ? { - setDiff(null); - apply.reset(); - compose.mutate(text); - }} - onCancel={() => { - setEditingYaml(false); - setYamlDirty(false); - compose.reset(); - if (!diff) dryRun.mutate(plan); - }} - /> : ) : ( <> - - - diff --git a/web/src/modules/inferences/components/DeployYamlEditor.tsx b/web/src/modules/inferences/components/DeployYamlEditor.tsx deleted file mode 100644 index 67b3f5c..0000000 --- a/web/src/modules/inferences/components/DeployYamlEditor.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useState } from "react"; -import { Alert, AlertDescription, Button, FieldTextarea, FloatingField, SectionCard } from "@modelsphere/ui"; -import type { Plan } from "@swiss/lib/api"; -import { deploymentYaml, parseDeploymentYaml } from "../deploy-yaml"; -import { useT } from "../i18n"; - -export function DeployYamlEditor({ plan, busy, error, onSave, onCancel, onDirtyChange }: { - plan: Plan; - busy: boolean; - error?: string; - onSave: (text: string) => void; - onCancel: () => void; - onDirtyChange: (dirty: boolean) => void; -}) { - const t = useT(); - const [baseline] = useState(() => deploymentYaml(plan)); - const [text, setText] = useState(baseline); - const [validationError, setValidationError] = useState(); - const validate = (value: string) => { - try { - parseDeploymentYaml(value); - setValidationError(undefined); - return true; - } catch (error) { - setValidationError(error instanceof Error ? error.message : String(error)); - return false; - } - }; - return ( - - - { setText(event.target.value); onDirtyChange(event.target.value !== baseline); validate(event.target.value); }} rows={24} spellCheck={false} disabled={busy} className="font-mono text-xs" /> - - {(validationError || error) && {validationError || error}} -
- - -
-
- ); -} diff --git a/web/src/modules/inferences/components/KeySearch.tsx b/web/src/modules/inferences/components/KeySearch.tsx new file mode 100644 index 0000000..9b90c03 --- /dev/null +++ b/web/src/modules/inferences/components/KeySearch.tsx @@ -0,0 +1,70 @@ +import { useMemo, useState } from "react"; +import { InputGroup, InputGroupAddon, InputGroupInput, cn } from "@modelsphere/ui"; +import { Search } from "lucide-react"; +import { useT } from "@/modules/inferences/i18n"; +import { searchKeys, type KeyLine } from "../override-yaml"; + +// A search over a YAML text's keys; picking one jumps to its line. Arrow keys +// move through the matches and Enter takes the highlighted one. +export function KeySearch({ keys, onJump }: { keys: KeyLine[]; onJump: (line: number) => void }) { + const t = useT(); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const hits = useMemo(() => searchKeys(keys, query), [keys, query]); + const jump = (key?: KeyLine) => { + if (!key) return; + setOpen(false); + onJump(key.line); + }; + return ( +
+ + + + + (setQuery(e.target.value), setActive(0), setOpen(true))} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={(e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + setActive((i) => Math.max(0, Math.min(hits.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))); + } else if (e.key === "Enter") { + e.preventDefault(); + jump(hits[active]); + } + }} + /> + + {open && query.trim() && ( +
    + {hits.length ? ( + hits.map((key, i) => ( +
  • (e.preventDefault(), jump(key))} + onMouseEnter={() => setActive(i)} + > + {key.path} + {key.line + 1} +
  • + )) + ) : ( +
  • {t("deploy.overrides.noKey")}
  • + )} +
+ )} +
+ ); +} diff --git a/web/src/modules/inferences/components/OverrideYaml.test.tsx b/web/src/modules/inferences/components/OverrideYaml.test.tsx new file mode 100644 index 0000000..f9418d8 --- /dev/null +++ b/web/src/modules/inferences/components/OverrideYaml.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { OverrideYaml } from "./OverrideYaml"; +import en from "@/modules/inferences/locales/en-US.json"; +import zh from "@/modules/inferences/locales/zh-CN.json"; + +const render = (value: string) => renderToStaticMarkup( {}} />); + +describe("Override YAML in the form", () => { + it("shows the overridden paths in a read-only box beside its edit button", () => { + const html = render("replicaCount: 3\nimage:\n tag: v2\n"); + expect(html).not.toContain("]*readOnly[^>]*value="replicaCount, image.tag"|]*value="replicaCount, image.tag"[^>]*readOnly/); + expect(html).toMatch(/aria-label="(Edit|编辑)"/); + }); + + it("says when the overrides are not valid YAML", () => { + const html = render("a: ["); + expect(html.includes(en.deploy.overrides.invalid) || html.includes(zh.deploy.overrides.invalid)).toBe(true); + }); +}); + +describe("YAML textarea", () => { + it("draws the text highlighted under a transparent textarea holding it", async () => { + const { YamlTextarea } = await import("./YamlTextarea"); + const html = renderToStaticMarkup( {}} />); + expect(html).toMatch(/]*>[\s\S]*replicaCount: 2[\s\S]*<\/textarea>/); + expect(html).toContain('style="color:var(--code-comment)"># chart'); + expect(html).toContain('style="color:var(--code-key)">replicaCount'); + }); +}); + +describe("key search", () => { + it("is a search box, its matches listed only once typed into", async () => { + const { KeySearch } = await import("./KeySearch"); + const html = renderToStaticMarkup( {}} />); + expect(html).toMatch(/placeholder="(Search keys|搜索 key)"/); + expect(html).not.toContain('role="listbox"'); + }); +}); diff --git a/web/src/modules/inferences/components/OverrideYaml.tsx b/web/src/modules/inferences/components/OverrideYaml.tsx new file mode 100644 index 0000000..e14c087 --- /dev/null +++ b/web/src/modules/inferences/components/OverrideYaml.tsx @@ -0,0 +1,302 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + Alert, + AlertDescription, + Button, + CodeBlock, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FieldInput, + FloatingField, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@modelsphere/ui"; +import { Pencil } from "lucide-react"; +import type { HelmValues } from "@swiss/lib/api"; +import { useT } from "@/modules/inferences/i18n"; +import { KeySearch } from "./KeySearch"; +import { YamlTextarea, type YamlTextareaHandle } from "./YamlTextarea"; +import { computedYaml, keyLines, overrideEdit, overridePaths, parseValues, readYaml, valuesYaml, type KeyLine, type Values } from "../override-yaml"; + +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +// The Advanced section's Override YAML: a summary in the form, its editor in a dialog. +export function OverrideYaml({ value, onChange, load }: { + value: string; + onChange: (edits: string) => void; + // Composes the form with these overrides, storing nothing. + load?: (edits: string) => Promise; +}) { + const t = useT(); + const f = (k: string) => t(`deploy.fields.${k}`); + const [open, setOpen] = useState(false); + const paths = overridePaths(value); + const edit = () => setOpen(true); + return ( + <> + + + + } + > + {/* Read-only: a click, Enter or Space opens the editor. */} + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + edit(); + } + }} + className="cursor-pointer font-mono text-xs" + /> + + {open && setOpen(false)} onSave={(edits) => (onChange(edits), setOpen(false))} />} + + ); +} + +interface Loaded { + values: HelmValues; + // The overrides the values were composed with. + edits: Values; + // What the release would render from, as YAML and parsed. + computed: string; + base: Values; +} + +function OverrideDialog({ value, load, onClose, onSave }: { + value: string; + load?: (edits: string) => Promise; + onClose: () => void; + onSave: (edits: string) => void; +}) { + const t = useT(); + const o = (k: string, params?: Record) => t(`deploy.overrides.${k}`, params); + const [draft, setDraft] = useState(value); + const [loaded, setLoaded] = useState(); + const [final, setFinal] = useState(""); + // Parsing a large values.yaml takes tens of milliseconds, so it waits for a + // pause in typing rather than running on every keystroke. + const settledDraft = useSettled(draft); + const draftRead = useMemo(() => readYaml(settledDraft), [settledDraft]); + const draftError = draftRead.error; + const settledFinal = useSettled(final, loaded); + const finalRead = useMemo(() => readYaml(settledFinal), [settledFinal]); + const fetch = useMutation({ + mutationFn: async (): Promise => { + const values = await load!(draft); + const computed = computedYaml(values.defaults, values.overrides); + return { values, edits: parseValues(draft), computed, base: parseValues(computed) }; + }, + onSuccess: (l) => { + setLoaded(l); + setFinal(l.computed); + setTab("final"); + }, + }); + const [tab, setTab] = useState("final"); + const draftEditor = useRef(null); + const finalEditor = useRef(null); + const defaultsBox = useRef(null); + const overridesBox = useRef(null); + const loadedKeys = useMemo(() => loaded && { defaults: keyLines(loaded.values.defaults), overrides: keyLines(loaded.values.overrides) }, [loaded]); + // The search runs over whichever text is on screen. + const view: { keys: KeyLine[]; jump: (line: number) => void } = !loaded || !loadedKeys + ? { keys: draftRead.keys, jump: (line) => draftEditor.current?.jumpTo(line) } + : tab === "defaults" + ? { keys: loadedKeys.defaults, jump: (line) => jumpInBlock(defaultsBox.current, line) } + : tab === "overrides" + ? { keys: loadedKeys.overrides, jump: (line) => jumpInBlock(overridesBox.current, line) } + : { keys: finalRead.keys, jump: (line) => finalEditor.current?.jumpTo(line) }; + const edit = useMemo( + () => loaded && (finalRead.values ? overrideEdit(loaded.base, loaded.edits, finalRead.values) : { error: finalRead.error ?? "" }), + [loaded, finalRead], + ); + const blocked = !edit || "error" in edit; + // Saving reads the text as it is now, not the settled read behind it. + const saveDraft = () => { + if (!readYaml(draft).error) onSave(draft); + }; + const finish = () => { + const now = loaded && readYaml(final).values; + if (!loaded || !now) return; + const e = overrideEdit(loaded.base, loaded.edits, now); + onSave(e.changes ? valuesYaml(e.edits) : draft); + }; + const [size, setSize] = useState(); + + return ( + !open && onClose()}> + + + {t("deploy.fields.edits")} + + {o(loaded ? "layersDescription" : "description")} + + + + + {!loaded ? ( + <> + + {(draftError || fetch.error) && ( + + {draftError ?? message(fetch.error)} + + )} + + ) : ( + <> + {/* One at a time, in merge order: what the chart ships, what this deploy sets over it, and the result to edit. */} + setTab(v as Tab)} className="min-h-0 min-w-0 flex-1 gap-2"> + + {o("chart")} + {o("swiss")} + {o("final")} + + +

{o("chartHint")}

+
+ +
+
+ +

{o("swissHint")}

+
+ +
+
+ +

{o("finalHint")}

+ +
+
+ {edit && "error" in edit ? ( + + {edit.error} + + ) : edit && ( +

{o("changes", { n: edit.changes })}

+ )} + + )} + + + {!loaded ? ( + <> + + {load && ( + + )} + + + ) : ( + <> + + + + + )} + + +
+
+ ); +} + +const TAB = "flex min-h-0 flex-1 flex-col gap-1.5"; + +// Its props do not change while the editor is typed in, so it is not redrawn. +const ReadOnlyYaml = memo(CodeBlock); + +// value once it has not changed for ms. A new reset takes value at once: a +// load replaces the text rather than typing into it. +function useSettled(value: T, reset?: unknown, ms = 200): T { + const [settled, setSettled] = useState(value); + const [last, setLast] = useState(reset); + if (reset !== last) { + setLast(reset); + setSettled(value); + } + useEffect(() => { + const id = setTimeout(() => setSettled(value), ms); + return () => clearTimeout(id); + }, [value, ms]); + return reset !== last ? value : settled; +} + +type Tab = "defaults" | "overrides" | "final"; + +// Scrolls a CodeBlock to a 0-based line, a third of the way down, and flashes it. +function jumpInBlock(box: HTMLElement | null, line: number) { + const pre = box?.querySelector("pre"); + const row = pre?.children[line] as HTMLElement | undefined; + if (!pre || !row) return; + pre.scrollTop += row.getBoundingClientRect().top - pre.getBoundingClientRect().top - pre.clientHeight / 3; + row.animate([{ backgroundColor: "color-mix(in oklab, var(--primary) 25%, transparent)" }, { backgroundColor: "transparent" }], { duration: 1600, easing: "ease-out" }); +} + +interface Size { + w: number; + h: number; +} + +const MIN: Size = { w: 480, h: 360 }; + +// The bottom-right corner drags the dialog's size. It stays centred, so it +// grows by twice the drag, which keeps the corner under the pointer; the +// viewport caps it in CSS. +function ResizeHandle({ onResize }: { onResize: (size: Size) => void }) { + const start = useRef(undefined); + return ( +
{ + const box = e.currentTarget.parentElement!.getBoundingClientRect(); + start.current = { x: e.clientX, y: e.clientY, w: box.width, h: box.height }; + e.currentTarget.setPointerCapture(e.pointerId); + }} + onPointerMove={(e) => { + const s = start.current; + if (s) onResize({ w: Math.max(MIN.w, s.w + 2 * (e.clientX - s.x)), h: Math.max(MIN.h, s.h + 2 * (e.clientY - s.y)) }); + }} + onPointerUp={() => (start.current = undefined)} + > + + + +
+ ); +} diff --git a/web/src/modules/inferences/components/YamlTextarea.tsx b/web/src/modules/inferences/components/YamlTextarea.tsx new file mode 100644 index 0000000..d073a23 --- /dev/null +++ b/web/src/modules/inferences/components/YamlTextarea.tsx @@ -0,0 +1,80 @@ +import { memo, useImperativeHandle, useRef } from "react"; +import { cn, highlightLine } from "@modelsphere/ui"; + +const CODE = { fontSize: "var(--code-font-size)", lineHeight: "var(--code-line-height)" }; + +export interface YamlTextareaHandle { + // Selects a 0-based line and scrolls it a third of the way down. + jumpTo: (line: number) => void; +} + +// A YAML textarea, highlighted as CodeBlock draws: the text is drawn under a +// transparent textarea of the same font and padding, its scroll following the +// textarea's. The extra bottom and right room lets it scroll as far as the +// textarea does beside its scrollbars. +export function YamlTextarea({ value, onChange, invalid, className, ref, ...aria }: { + value: string; + onChange: (value: string) => void; + invalid?: boolean; + className?: string; + "aria-label"?: string; + ref?: React.Ref; +}) { + const under = useRef(null); + const area = useRef(null); + useImperativeHandle(ref, () => ({ + jumpTo(line) { + // The layer under the text has a row per line, so its offset is the line's. + const ta = area.current; + const row = under.current?.firstElementChild?.children[line] as HTMLElement | undefined; + if (!ta || !row) return; + const lines = ta.value.split("\n"); + const start = lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0); + const text = lines[line] ?? ""; + ta.focus({ preventScroll: true }); + ta.setSelectionRange(start + text.length - text.trimStart().length, start + text.length); + ta.scrollTop = Math.max(0, row.offsetTop - ta.clientHeight / 3); + }, + }), []); + return ( +
+
+        
+ {value.split("\n").map((line, i) => ( + + ))} +
+
+