From 0cfe0adb73c9fa13133c788238745c228403ab5e Mon Sep 17 00:00:00 2001 From: imneov Date: Sun, 8 Nov 2026 13:52:50 +0800 Subject: [PATCH] feat(web): share deployment configuration between forms and yaml Keep existing form/edit persistence, synchronized editing and server previews. Record verification as text without committing binary artifacts. Fixes #66 --- CHANGELOG.md | 2 + docs/console-design.md | 40 +- ...6-11-07-deployment-configuration-checks.md | 100 +++++ .../2026-11-07-deployment-configuration.md | 47 +++ .../2026-11-07-deployment-values-contract.md | 29 ++ .../components/ConfigurationOverrides.tsx | 11 + .../components/ConfigurationPanes.tsx | 35 ++ .../components/CurrentConfiguration.tsx | 25 ++ .../inferences/components/DeployForm.tsx | 17 +- .../inferences/components/DeploySheet.tsx | 172 +++++--- .../inferences/components/OverrideYaml.tsx | 374 ++---------------- .../configuration-overrides.test.ts | 26 ++ .../inferences/configuration-overrides.ts | 42 ++ .../inferences/deployment-draft.test.ts | 55 +++ .../modules/inferences/deployment-draft.ts | 65 +++ .../modules/inferences/deployment-gpu.test.ts | 95 +++++ web/src/modules/inferences/deployment-gpu.ts | 98 +++++ .../inferences/deployment-preview.test.ts | 115 ++++++ .../modules/inferences/deployment-preview.ts | 74 ++++ .../modules/inferences/deployment-settings.ts | 2 +- web/src/modules/inferences/locales/en-US.json | 31 +- web/src/modules/inferences/locales/zh-CN.json | 31 +- .../modules/inferences/override-yaml.test.ts | 5 + web/src/modules/inferences/override-yaml.ts | 6 +- .../modules/inferences/plan-composition.ts | 4 + .../inferences/use-deployment-draft.test.ts | 79 ++++ .../inferences/use-deployment-draft.ts | 82 ++++ .../inferences/use-deployment-values.ts | 68 ++++ .../swiss/components/DeploySettings.tsx | 4 +- .../modules/swiss/components/Provenance.tsx | 2 +- web/test/README.md | 26 ++ web/test/deployment-configuration.html | 1 + web/test/deployment-configuration.tsx | 98 +++++ web/tsconfig.json | 2 +- 34 files changed, 1408 insertions(+), 455 deletions(-) create mode 100644 docs/plans/2026-11-07-deployment-configuration-checks.md create mode 100644 docs/plans/2026-11-07-deployment-configuration.md create mode 100644 docs/plans/2026-11-07-deployment-values-contract.md create mode 100644 web/src/modules/inferences/components/ConfigurationOverrides.tsx create mode 100644 web/src/modules/inferences/components/ConfigurationPanes.tsx create mode 100644 web/src/modules/inferences/components/CurrentConfiguration.tsx create mode 100644 web/src/modules/inferences/configuration-overrides.test.ts create mode 100644 web/src/modules/inferences/configuration-overrides.ts create mode 100644 web/src/modules/inferences/deployment-draft.test.ts create mode 100644 web/src/modules/inferences/deployment-draft.ts create mode 100644 web/src/modules/inferences/deployment-gpu.test.ts create mode 100644 web/src/modules/inferences/deployment-gpu.ts create mode 100644 web/src/modules/inferences/deployment-preview.test.ts create mode 100644 web/src/modules/inferences/deployment-preview.ts create mode 100644 web/src/modules/inferences/use-deployment-draft.test.ts create mode 100644 web/src/modules/inferences/use-deployment-draft.ts create mode 100644 web/src/modules/inferences/use-deployment-values.ts create mode 100644 web/test/README.md create mode 100644 web/test/deployment-configuration.html create mode 100644 web/test/deployment-configuration.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 514677c..a625ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,11 @@ chart's `appVersion`; the image and chart are released together under it. - The chart can install a writable catalog — the swiss chart's catalog-server, swiss 0.7.0+; named in the site profile, it lets the Model Library add models even when the site's own catalog is a read-only static index (#90). ### Changed +- Deploy and upgrade share one configuration draft between structured controls and YAML, with read-only baseline/effective panes, current-revision comparison and a visible **Edit YAML** action in the top toolbar. Saving uses the existing form/YAML API and preserves saved form settings omitted from YAML; no deployment-service upgrade is required. - The swiss subchart is 0.7.0 (was 0.6.3). 0.6.3 predates catalog schema v2 and the site profile's `schemaVersion`/`writable`, so with a bundled swiss the Model Library could not read a v2 catalog or save one (#90). ### Fixed +- Editing deployment configuration preserves unrelated values, keeps form/YAML changes synchronized, and invalidates a review when its inputs or running revision change. Final previews include later system layers. - Overlay panels keep their edge. The site-wide "no focus ring" rule zeroed the `ring-1` that draws the border of menus, popovers, selects, comboboxes and dialogs, so an open white panel on the white page showed only a faint shadow — the top-bar avatar menu read as a line of text floating under the header. - The top-bar avatar menu names the signed-in user — avatar, name, role and, when console has one to read, the email — instead of a single `name · role` line. With `server.auth.disabled` the user button itself turns amber and the avatar carries a dot, so the state shows without opening anything and the bar gains no element; the menu carries 登录已关闭,当前为管理员身份 (*Sign-in off — acting as the administrator*) in place of change-password and sign-out, which that mode cannot honour. diff --git a/docs/console-design.md b/docs/console-design.md index 47c3230..24b1136 100644 --- a/docs/console-design.md +++ b/docs/console-design.md @@ -342,7 +342,7 @@ DeploySheet -> GET /api/llm/route-options[?route=] The deploy and upgrade forms expose the charts' existing `continuationGateway` values (console #63). ```text -form -> recovery values -> adaptive compose -> optional timeout reset -> validate -> diff -> apply +form <-> user values <-> YAML -> compose -> validate -> diff -> apply nginx -> continuation gateway -> CART -> engine ``` @@ -351,10 +351,36 @@ nginx -> continuation gateway -> CART -> engine | Controls | Enable gateway, actual model identity, idle timeout, continuation execution and tool-call buffering | | Upgrade state | Seed from the stored plan once; query refreshes preserve edits | | Validation | Check all recorded plan layers, chart compatibility, CART, ModelRoute, actual model, timeout and engine | -| Advanced YAML | Preserve precedence; stop when its effective settings differ from the recovery controls | -| Timeout reset | Clearing removes a retained form override by recomposing the validated form without `fromRelease`, pinned to the resolved source and chart | -| Integrity | Recovery and adaptive cleanup share source pinning and normalized plan comparison; reject unrelated changes | -| Ownership | Existing plan API and chart values; copied Swiss sources remain unchanged | +| Configuration draft | Structured controls and YAML share one document; validate recovery against the complete server composition | +| Timeout reset | Keep the existing guarded cleanup for an explicit form reset; omitted YAML alone retains saved form settings | +| Integrity | Keep `fromRelease` validation and later system layers; verify the running revision before and after planning and before apply | +| Ownership | Existing chart values, form/edit layers and plan/diff/apply API; no backend change | + +### Unified deployment configuration + +Deploy and upgrade use one shared editing model; structured controls patch their owned paths and YAML edits project back into those controls. + +| Surface | Source | +|---|---| +| Deployment baseline | Chart defaults plus non-user target layers, composed by the deployment service | +| User configuration | YAML draft over retained form settings; unknown values and comments survive form edits | +| Effective configuration | The deployment service's complete layer order; read-only | +| Current configuration | The running revision's actual Helm values; independent of target defaults | + +```text +form <-> user document <-> YAML + | + overrides + editsYAML + fromRelease -> compose -> diff -> apply + ^ | + +---------- re-preview <---------+ (revision moved) +``` + +- Open the editor through **Edit YAML** in the deployment form's top toolbar; the YAML summary field also names its format explicitly. +- Use existing `overrides` and `editsYAML` requests. Upgrades merge form values and replace the YAML override layer; omitted YAML fields preserve saved form settings. No backend capability is required. +- Invalid YAML remains editable and blocks structured changes and planning. Preview failures retain the draft and clear the effective display. +- Disabling a feature retains its settings unless an existing explicit cleanup action removes dependent fields. Removing a YAML-only override reveals the underlying form or target value. +- System layers that change a user value appear with the configured value, effective value and responsible layer. +- Design and local checks: `docs/plans/2026-11-07-deployment-configuration.md` and `2026-11-07-deployment-configuration-checks.md`. ### Unpublished Monitor integration @@ -377,8 +403,8 @@ The master switch and every `needsAdaptive` field share one section (console #75 | Groups | Gateway `needsAdaptive` metadata decides membership; known `adaptive_cc_*` keys are recognized without a live catalog | | Off | Hide dependent common/advanced inputs, clear their entered values, and prevent adding adaptive custom keys while off | | Capacity | `default_max` remains in Capacity & failover; it governs limits even with adaptive control off | -| Upgrade removal | Swiss `fromRelease` recursively retains omitted map keys. Compose normally first for its model-move/chart validation; if stale adaptive form keys survive, recompose the full validated form with those keys removed, pinned to the just-resolved model/version/chart | -| Integrity | Preserve unrelated form values and explicit advanced YAML; reject source/chart identity or other layer changes between the two compositions | +| Upgrade removal | Keep the existing guarded recomposition for explicit dependent-parameter cleanup; effective preview follows the same final request | +| Integrity | Preserve unrelated user values; normal upgrade composition retains source/chart validation and later system layers | | Application | Only the cleaned plan enters the existing diff/revision/apply flow; composition performs no workload write | ### Workload reads (`/api/k8s/...`) diff --git a/docs/plans/2026-11-07-deployment-configuration-checks.md b/docs/plans/2026-11-07-deployment-configuration-checks.md new file mode 100644 index 0000000..dfd2aea --- /dev/null +++ b/docs/plans/2026-11-07-deployment-configuration-checks.md @@ -0,0 +1,100 @@ +# Deployment configuration check log + +This records the current Console-only implementation, verification evidence and review history for [PR #81](https://github.com/modelsphere/console/pull/81), related to [issue #66](https://github.com/modelsphere/console/issues/66). + +## Scope after review — 2026-11-08 + +| Concern | Current decision | +|---|---| +| Backend | Use the existing form/edit API; withdraw the complete-user-values proposal in [Swiss PR #52](https://github.com/modelsphere/swiss/pull/52) | +| Shared editing | Keep the approved three-pane UI and bidirectional form/YAML updates | +| Saving partial YAML | Preserve omitted saved form settings; YAML remains an override layer | +| Explicit cleanup | Retain the existing guarded timeout/adaptive cleanup flows; preview reuses their final request | +| System layers | Show the actual server result; no new canary lifecycle or autoscaling runtime behavior | +| Identity | Keep the actual service identity locked; reject conflicting YAML before composition | +| Compatibility | No new request field, capability or backend release dependency | + +- Design: [deployment configuration](2026-11-07-deployment-configuration.md). +- Request semantics: [existing API contract](2026-11-07-deployment-values-contract.md). +- Browser scenarios: [fixture guide](../../web/test/README.md). +- Release notes: `CHANGELOG.md`. + +```text +form <-> shared YAML draft + | + existing overrides + editsYAML -> compose -> diff -> apply + | + same final request -> effective preview +``` + +## Regression checks + +| Behavior | Evidence | +|---|---| +| Partial YAML | Missing replica/image/form-only keys retain the saved form; only supplied YAML keys override them | +| YAML-only clear | A form submission with empty edit YAML clears the prior edit layer while retaining saved form settings | +| Bare upgrade | Existing requests without form overrides retain their previous edit layer | +| Structured changes | Owned paths update the YAML; unknown keys and comments survive | +| Timeout/adaptive reset | Combined cleanup preserves unrelated values; dry-run uses the final pinned request and does not resurrect removed fields | +| GPU clearing | Distinguish omission from an explicit selector clear; remove the whole required rule when no constraints remain | +| GPU automatic selection | Read target defaults/catalog/site/derived constraints and the supported server-resolved resource; do not revive a saved selector/resource | +| Older GPU charts | Continue selecting products when the chart intentionally omits the default GPU resource field | +| GPU constraints | Preserve unrelated zones, matchFields, preferred affinity and selector grouping | +| Revision checks | Check the live revision around snapshot/planning and before apply; keep drafts after stale-result rejection | +| Identity | Reject a changed or null YAML serviceId while retaining the locked actual identity | + +## Current automated verification + +| Check | Result | +|---|---| +| Frontend suite | 65 files / 605 tests passed (`pnpm --dir web test`) | +| Both TypeScript configurations | Passed (`pnpm --dir web run typecheck`) | +| Console production build | Passed (`pnpm --dir web run build`) | +| Standalone frontend build | Passed (`pnpm --dir web run build:swiss`) | +| Console Go checks | `go vet ./...` and `go test ./...` passed | +| Review | React, TypeScript and legacy API-contract review completed; reported regressions fixed and rechecked | +| Whitespace | `git diff --check` passed | + +Both builds retain the existing chunk-size advisories. No chart or backend source was changed. + +## Browser verification — existing API fixture + +- All scenarios omit the new capability field and use `overrides` plus `editsYAML`. +- Upgrade baseline/effective panes load through the existing plan/dry-run endpoints. +- Pasting only `image.tag` leaves the form's stored replica count at 2. +- Changing the form replica count to 3 inserts `replicaCount: 3` into that partial YAML and preserves the image tag. +- Changing YAML to `replicaCount: 4` updates the form to 4 and reaches deployment review. +- Clearing the saved H100 selection resolves to the target default H100/H800 set and preserves preferred affinity. +- Autoscale shows the configured count 2 alongside the server-effective count 4. +- The 390px layout has no document overflow; desktop and mobile layouts were inspected. +- The fixture blocks live install/apply and unexpected network access. + +Browser checks use fixture API data; no live model deployment or crash-recovery validation was performed. + +## Production integration + +| Entry | Shared implementation | +|---|---| +| Model library deployment | `components/DeploySheet.tsx` | +| Model detail deployment | `components/DeploySheet.tsx` | +| Service list upgrade | `components/DeploySheet.tsx` | +| Service detail upgrade | `components/DeploySheet.tsx` | + +```text +production page -> DeploySheet -> shared draft / three-pane editor + | + /api/deploy/plans -> authenticated Console proxy -> existing deployment API +``` + +The production app enters through `web/index.html` and `src/main.tsx`; the fixture is isolated under `web/test`. + +## Review history + +- Console baseline: `bac3654b249cc67f3ed03e3807ff0bc51de9f8a6`. +- The original paired backend commit `9a0cd9e` introduced complete replacement and a capability requirement. Its local/CI tests passed, but review identified an ownership/compatibility design blocker. That implementation is withdrawn and its tests are not evidence for the current Console-only solution. +- Review corrections restore existing persistence semantics, retain explicit cleanup, and cover stored GPU affinity/resource and service identity at the Console boundary. + +## Repository artifacts + +- Verification screenshots are kept outside the repository; the PR contains text verification records only. +- Binary files must not be committed to this repository. diff --git a/docs/plans/2026-11-07-deployment-configuration.md b/docs/plans/2026-11-07-deployment-configuration.md new file mode 100644 index 0000000..71f7e30 --- /dev/null +++ b/docs/plans/2026-11-07-deployment-configuration.md @@ -0,0 +1,47 @@ +# Unified deployment configuration + +The approved prototype uses one user draft for structured controls and YAML. The implementation decisions and verification are recorded in this document and its check log. + +| Surface | Data | +|---|---| +| Target baseline, read-only | Chart values and non-user configuration for the selected target | +| User configuration, editable | Shared YAML draft projected over retained form settings; invalid text remains editable | +| Effective configuration, read-only | Swiss's complete composition including system layers | +| Current deployment comparison | The current Helm revision's actual values, independent of target defaults | + +```text +form <-> user draft <-> YAML + | + Swiss compose -> diff -> apply + ^ | + +--- re-preview <--+ (revision changed) +``` + +## Data and compatibility + +- Reuse the existing form serializer/deserializer to project structured controls; preserve unknown YAML keys. +- Keep release identity and apply flags separate from chart values. +- Seed upgrades from explicit form/edit layers; never seed user overrides from Helm's merged defaults. +- Present stored form/edit settings in one draft, then submit the existing `overrides` and `editsYAML` fields. A partial YAML document retains omitted form settings. +- Keep the existing guarded timeout/adaptive cleanup paths for explicit form actions; reuse their final request for the effective preview. +- Use the same composition path for the editor's effective values and deploy review. The UI never appends user YAML after system layers. +- Read and verify the current Helm revision before composing/applying an upgrade. On a mismatch, retain the draft and allow an explicit refresh and new preview. +- Reuse authenticated plan, dry-run Helm values, status and revision-values endpoints. No backend API or capability change is required. See [existing API contract](2026-11-07-deployment-values-contract.md). +- Invalid YAML disables structured changes and composition; source/read failures never become empty successful previews. + +## Delivery + +1. Test the shared draft and version/compose boundaries. +2. Replace the multi-tab editor with read-only baseline, editable user YAML and read-only effective panes. +3. Integrate the draft, current-revision comparison and preflight checks into deploy/upgrade. +4. Record validation in `2026-11-07-deployment-configuration-checks.md` and user-visible changes in CHANGELOG. + +## Interaction decisions + +- Structured controls and YAML share one editing model; persisted form and YAML layers retain their existing ownership. +- Project fields from YAML over retained form settings and the selected target baseline. YAML omission preserves the saved form value. +- Patch only the paths owned by changed controls; retain unknown keys and comments. +- Disabling a feature preserves its configuration for re-enabling. Enabling a scaler removes the conflicting fixed replica override. +- Resolve GPU product/resource changes through the deployment service before committing its generated terms to the document. Preserve other required/preferred affinity and original selector groups. +- Bind model paths/resources to their YAML paths; keep release identity and apply flags separate. +- Keep the baseline and final panes read-only; obtain the final configuration through the deployment composition path. diff --git a/docs/plans/2026-11-07-deployment-values-contract.md b/docs/plans/2026-11-07-deployment-values-contract.md new file mode 100644 index 0000000..ba9a9b4 --- /dev/null +++ b/docs/plans/2026-11-07-deployment-values-contract.md @@ -0,0 +1,29 @@ +# Deployment configuration on the existing API + +Console synchronizes form controls and YAML while retaining the deployment service's existing form/edit ownership. + +| Input | Existing behavior | +|---|---| +| `overrides` | Structured form values; upgrades merge these into the saved form layer | +| `editsYAML` | YAML override layer; a form submission replaces the previous edit layer | +| Omitted YAML field | The form or target baseline remains effective; omission does not erase saved form settings | +| Explicit YAML `null` | Existing Helm deletion semantics | +| `fromRelease` | Validate the release/source and carry the settings and system state supported by the existing service | + +```text +stored form + target baseline -> form projection <-> YAML draft + | + overrides + editsYAML + fromRelease + | + compose -> diff -> apply +``` + +- The editor initially presents the stored form and edit values together. Structured edits patch only their owned paths and preserve other YAML and comments. +- Form projection includes retained saved form values beneath the draft, so pasting partial YAML does not reset the omitted controls. +- Keep the existing guarded recovery-timeout and adaptive-parameter cleanup flows for explicit form actions. Their source/layer checks still reject an unsafe recomposition. +- Effective preview reuses the final request selected by composition, including whether an existing cleanup flow removed `fromRelease`. It uses the same resolved target/chart and form/edit payload. +- Compose the read-only baseline separately against that resolved target without user layers. Obtain effective values through the server's full layer order. +- GPU changes resolve against the validated target, preserve unrelated required/preferred affinity, and use an explicit deletion when clearing a retained selector. Empty automatic choices use the target's resolved selector rather than restoring the saved selection. Where the chart exposes a GPU resource, write the actual resolved resource back to the shared document; older charts may legitimately omit that key. +- There is no new request field, capability, backend upgrade requirement or autoscaling runtime change. + +The earlier complete-`userValues` proposal was withdrawn after review. [Swiss PR #52](https://github.com/modelsphere/swiss/pull/52) is no longer a dependency of [Console PR #81](https://github.com/modelsphere/console/pull/81). diff --git a/web/src/modules/inferences/components/ConfigurationOverrides.tsx b/web/src/modules/inferences/components/ConfigurationOverrides.tsx new file mode 100644 index 0000000..2cc1848 --- /dev/null +++ b/web/src/modules/inferences/components/ConfigurationOverrides.tsx @@ -0,0 +1,11 @@ +import { Alert, AlertDescription } from "@modelsphere/ui"; +import { useT } from "../i18n"; +import type { ConfigurationOverride } from "../configuration-overrides"; + +const display = (value: unknown) => value === undefined ? "—" : JSON.stringify(value); + +export function ConfigurationOverrides({ items }: { items: ConfigurationOverride[] }) { + const t = useT(); + if (!items.length) return null; + return
    {items.map((item) =>
  • {t("deploy.draft.managed", { field: item.path, requested: display(item.requested), actual: display(item.actual), source: item.source })}
  • )}
; +} diff --git a/web/src/modules/inferences/components/ConfigurationPanes.tsx b/web/src/modules/inferences/components/ConfigurationPanes.tsx new file mode 100644 index 0000000..c7d850e --- /dev/null +++ b/web/src/modules/inferences/components/ConfigurationPanes.tsx @@ -0,0 +1,35 @@ +import { useRef, type ReactNode } from "react"; +import { CodeBlock, Spinner } from "@modelsphere/ui"; +import { useT } from "../i18n"; +import type { HelmValuesSource } from "../use-deployment-values"; +import { KeySearch } from "./KeySearch"; +import { YamlTextarea, type YamlTextareaHandle } from "./YamlTextarea"; +import { readYaml } from "../override-yaml"; + +function Pane({ title, children }: { title: string; children: ReactNode }) { + return

{title}

{children}
; +} + +function ReadOnlyValues({ value, loading, unavailable, label }: { value?: string; loading?: boolean; unavailable: string; label: string }) { + return value !== undefined + ?
+ :
{loading ? : unavailable}
; +} + +export function ConfigurationPanes({ value, onChange, source }: { value: string; onChange: (value: string) => void; source?: HelmValuesSource }) { + const t = useT(); + const read = readYaml(value); + const editor = useRef(null); + const validSource = !read.error ? source?.data : undefined; + const unavailable = t("deploy.draft.unavailable"); + return ( + <> + editor.current?.jumpTo(line)} /> +
+ + + +
+ + ); +} diff --git a/web/src/modules/inferences/components/CurrentConfiguration.tsx b/web/src/modules/inferences/components/CurrentConfiguration.tsx new file mode 100644 index 0000000..8596def --- /dev/null +++ b/web/src/modules/inferences/components/CurrentConfiguration.tsx @@ -0,0 +1,25 @@ +import { useState } from "react"; +import { Alert, AlertDescription, Button, CodeBlock, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@modelsphere/ui"; +import type { RevisionValues } from "@swiss/lib/api"; +import { useT } from "../i18n"; + +export function CurrentConfiguration({ data, loading, error, onRefresh }: { data?: RevisionValues; loading: boolean; error?: Error | null; onRefresh: () => void }) { + const t = useT(); + const [open, setOpen] = useState(false); + return ( + <> +
+ + +
+ {error && {error.message}} + + + {t("deploy.draft.current")} · {data?.revision} + + + + + + ); +} diff --git a/web/src/modules/inferences/components/DeployForm.tsx b/web/src/modules/inferences/components/DeployForm.tsx index c0e745f..ef1d1cd 100644 --- a/web/src/modules/inferences/components/DeployForm.tsx +++ b/web/src/modules/inferences/components/DeployForm.tsx @@ -58,10 +58,13 @@ export interface DeployFormProps { catalogRoute?: string; // The chart's values and swissd's layers for the Override YAML editor. helmValues?: HelmValuesSource; + configurationOpen?: boolean; + onConfigurationOpenChange?: (open: boolean) => void; recovery?: ReactNode; + disabled?: boolean; } -export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery }: DeployFormProps) { +export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, configurationOpen, onConfigurationOpenChange, recovery, disabled }: DeployFormProps) { const t = useT(); const on = effective(form); const f = (k: string) => t(`deploy.fields.${k}`); @@ -85,7 +88,7 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster const section = (id: Section, children: ReactNode) => (
onExpand(id, o)}> -
{children}
+
{children}
); @@ -131,6 +134,13 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster {section( "resources", <> + + + + + }>{sw("scaler", f("scaler"))} + {!form.scaler && } {gpuResources && onChange({ gpuResource })} />} 0 ? t("deploy.fields.gpuForced", { products: forced.join(", ") }) : undefined}> 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} /> @@ -251,9 +261,10 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster {sw("skipSchemaValidation", f("skipSchemaValidation"))} - onChange({ edits })} source={helmValues} /> + , )} + onChange({ edits })} source={helmValues} open={configurationOpen} onOpenChange={onConfigurationOpenChange} /> ); } diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index e03c216..a44a680 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -40,12 +40,15 @@ 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 { DeployForm } from "@/modules/inferences/components/DeployForm"; -import type { HelmValuesSource } from "@/modules/inferences/components/OverrideYaml"; import { ExperimentalBadge } from "@/modules/inferences/components/InstallMethodLabel"; -import { AdaptivePlanChangedError, composeAdaptivePlan, withAdaptiveConcurrency } from "../adaptive-concurrency"; -import { composeRecoveryPlan, composeRecoveryValuesRequest, RecoveryPlanChangedError } from "../recovery-compose"; +import { computedYaml, parseValues, valuesYaml } from "../override-yaml"; +import { useDeploymentDraft } from "../use-deployment-draft"; +import { useDeploymentValues } from "../use-deployment-values"; +import { assertDeploymentRevision, composeDeploymentDraft, deploymentValuesRequest, DeploymentChangedError, readDeploymentRevision } from "../deployment-preview"; +import { CurrentConfiguration } from "./CurrentConfiguration"; +import { gpuResolutionValues, resolvedGpuChanges } from "../deployment-gpu"; import { RecoveryFields } from "./RecoveryFields"; -import { mergeValues, planValues, recoveryError, recoveryInputError, recoverySettings, withRecovery } from "../recovery"; +import { mergeValues, planValues, recoveryError, recoveryInputError } from "../recovery"; import { readRouteOptions } from "@/modules/inferences/api"; export type DeployTarget = @@ -57,11 +60,11 @@ const ID_PREFIX = "deploy-sec-"; const OPEN: Record = { basic: true, resources: true, routing: false, advanced: false }; const REFRESH = [["status"], ["revisions"], ["releasePlan"], ["release-plan"], ["runs"], ["deployments"], ["objects"]]; -export function DeploySheet({ target, onClose }: { target: DeployTarget | null; onClose: () => void }) { - return target ? : null; +export function DeploySheet({ target, onClose, initialView = "form" }: { target: DeployTarget | null; onClose: () => void; initialView?: "form" | "yaml" }) { + return target ? : null; } -function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: () => void }) { +function DeploySheetBody({ target, onClose, initialView }: { target: DeployTarget; onClose: () => void; initialView: "form" | "yaml" }) { const t = useT(); const p = useModulePath(); const navigate = useNavigate(); @@ -140,13 +143,11 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const [seedPlan, setSeedPlan] = useState(); if (current.data && !seedPlan) setSeedPlan(current.data); const deployModel = upgrade ? "" : target.model; - const initial = useMemo
(() => (upgrade ? (seedPlan ? formFromPlan(seedPlan) : EMPTY) : { ...EMPTY, serviceId: defaultServiceId(deployModel) }), [upgrade, seedPlan, deployModel]); - const initialRecovery = useMemo(() => recoverySettings( - upgrade && seedPlan ? planValues(seedPlan) : mergeValues(entry.data?.variant.values, profile.data?.profile?.extra), - ), [upgrade, seedPlan, entry.data?.variant.values, profile.data?.profile?.extra]); - const [recoveryPatch, setRecoveryPatch] = useState>>({}); - const recovery = { ...initialRecovery, ...recoveryPatch }; - const [form, setForm] = useState(initial); + const initial = useMemo(() => (upgrade ? (seedPlan ? { ...formFromPlan(seedPlan), serviceId: String(planValues(seedPlan).serviceId || seedPlan.release.name) } : EMPTY) : { ...EMPTY, serviceId: defaultServiceId(deployModel) }), [upgrade, seedPlan, deployModel]); + const baselineValues = useMemo(() => mergeValues(mergeValues(entry.data?.variant.values, profile.data?.profile?.extra), seedPlan?.layers.form ?? {}), [entry.data?.variant.values, profile.data?.profile?.extra, seedPlan]); + const draft = useDeploymentDraft(initial, seedPlan?.layers.form, baselineValues, profile.data?.profile?.nodes?.gpus); + const { form, recovery, setForm } = draft; + const invalidNumberError = draft.invalidField ? t("deploy.draft.invalidNumber", { field: t(`deploy.fields.${draft.invalidField === "replicaCount" ? "replicas" : draft.invalidField}`) }) : undefined; const catalogRoute = upgrade && current.data ? initial.route || initial.serviceId || target.release : undefined; const routeOptions = useQuery({ queryKey: ["route-options", catalogRoute ?? ""], @@ -155,11 +156,11 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( staleTime: 30_000, retry: false, }); - useEffect(() => setForm(initial), [initial]); const [seed, setSeed] = useState(null); - const dirty = useDirty({ form, version, variant, moveTo, chartVersion, recovery }, { form: seed ? reseed(initial, {}, seed) : initial, version: upgrade ? "" : (target.version ?? ""), variant: upgrade ? "" : (target.variant ?? ""), moveTo: "", chartVersion: "", recovery: initialRecovery }); + const dirty = useDirty({ edited: draft.dirty, version, variant, moveTo, chartVersion }, { edited: false, version: upgrade ? "" : (target.version ?? ""), variant: upgrade ? "" : (target.variant ?? ""), moveTo: "", chartVersion: "" }); const [step, setStep] = useState<"form" | "review">("form"); + const [configurationOpen, setConfigurationOpen] = useState(initialView === "yaml"); const [submitted, setSubmitted] = useState(false); const [expanded, setExpanded] = useState(OPEN); const [plan, setPlan] = useState(null); @@ -179,7 +180,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( if (!seedReady) return; const next = switchDefaults(siteDefaults, variantValues); if (seed && JSON.stringify(seed) === JSON.stringify(next)) return; - setForm((f) => reseed(f, seed ?? {}, next)); + draft.reseedForm((f) => reseed(f, seed ?? {}, next)); setSeed(next); if (seed) { setPlan(null); @@ -193,63 +194,94 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( const live = useQuery({ queryKey: ["status", namespace, release], queryFn: () => api.status(namespace, release), enabled: step === "review" && !!release && !!namespace }); // A product the variant does not list is one swissd takes only as forced. - const forceGpuProducts = unlistedProducts(v?.requires.gpuProduct, form.gpuProducts).length > 0; - const change = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + const change = (patch: Partial) => { + if ("gpuProducts" in patch || "gpuResource" in patch) gpuChange.mutate(patch); + else setForm(patch); + }; // What a compose of f sends; the Override YAML editor's preview sends the same. - const requestFor = (f: Form) => planRequest( + const requestFor = (f: Form) => { + const forceGpuProducts = unlistedProducts(v?.requires.gpuProduct, f.gpuProducts).length > 0; + return 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, - ); - // The chart's values and swissd's layers for this form, without the - // overrides, which the editor merges itself. Cached per request while the - // sheet is open, so saving the overrides and editing them again does not - // read the chart again. - const compositionError = (error: unknown) => { - if (error instanceof RecoveryPlanChangedError) return new Error(t("deploy.recovery.errors.changedPlan")); - if (error instanceof AdaptivePlanChangedError) return new Error(t("deploy.fields.adaptiveChanged")); - return error; + ); }; - const valuesForm = { ...form, edits: "" }; - const valuesRequest = upgrade && !recorded ? undefined : withAdaptiveConcurrency(withRecovery(requestFor(valuesForm), recovery), valuesForm, routeOptions.data); - const helmValues = useQuery({ - queryKey: ["helmValues", JSON.stringify(valuesRequest ?? null)], - queryFn: async () => { + const revisionSnapshot = useQuery({ + queryKey: ["upgrade-snapshot", namespace, release], + queryFn: () => readDeploymentRevision(namespace, release), + enabled: upgrade && !!namespace && !!release, + staleTime: Infinity, gcTime: 0, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false, + }); + const request = (upgrade && !recorded) ? undefined : requestFor(form); + const context = JSON.stringify([request, form, recovery, routeOptions.data, profile.data, revisionSnapshot.data?.revision]); + const latestContext = useRef(context); + const activePlan = useRef(undefined); + useEffect(() => { latestContext.current = context; }, [context]); + const [reviewContext, setReviewContext] = useState(); + useEffect(() => { + if (reviewContext && reviewContext !== context) { setPlan(null); setDiff(null); setStep("form"); activePlan.current = undefined; } + }, [context, reviewContext]); + const settings = { form, recovery, routeOptions: routeOptions.data }; + const valuesSource = useDeploymentValues(request, form.edits, catalogName, JSON.stringify([profile.data, revisionSnapshot.data?.revision]), settings); + const checkRevision = async () => { + if (!upgrade) return; + if (!revisionSnapshot.data || revisionSnapshot.isError || revisionSnapshot.isFetching) throw new DeploymentChangedError(); + await assertDeploymentRevision(namespace, release, revisionSnapshot.data.revision); + }; + const compositionError = (error: unknown) => error instanceof DeploymentChangedError ? new Error(t("deploy.draft.changed")) : error; + const gpuChange = useMutation({ + mutationFn: async (patch: Partial) => { + const captured = context; + const next = { ...form, ...patch, edits: valuesYaml(mergeValues(baselineValues, draft.values)) }; + const input = requestFor(next); + const values = gpuResolutionValues(form.edits, profile.data?.profile?.nodes?.gpus); try { - const request = await composeRecoveryValuesRequest(requestFor(valuesForm), recovery, (next) => composeAdaptivePlan(next, valuesForm, routeOptions.data, deployApi.plan, catalogName), catalogName); - return await deployApi.helmValues(request); - } catch (error) { - throw compositionError(error); - } + await checkRevision(); + if (captured !== latestContext.current) throw new DeploymentChangedError(); + const validated = await composeDeploymentDraft(input, values, deployApi.plan, catalogName); + const resolution = deploymentValuesRequest(input, validated, catalogName); + delete resolution.fromRelease; + resolution.overrides = input.overrides; + const resolved = await composeDeploymentDraft(resolution, values, deployApi.plan, catalogName); + const helm = await deployApi.helmValues(deploymentValuesRequest(resolution, resolved, catalogName)); + await checkRevision(); + if (captured !== latestContext.current) throw new DeploymentChangedError(); + return { changes: resolvedGpuChanges(resolved, next, profile.data?.profile?.nodes?.gpus, parseValues(helm.defaults), parseValues(computedYaml(helm.defaults, helm.overrides))), captured }; + } catch (error) { throw compositionError(error); } + }, + onSuccess: ({ changes, captured }) => { + if (captured !== latestContext.current) throw new Error(t("deploy.draft.changed")); + draft.setResolvedGpu(changes); }, - enabled: false, - staleTime: Infinity, - // Dropped when the sheet closes: they last one install or upgrade. - gcTime: 0, - retry: false, }); - const valuesSource: HelmValuesSource = { - data: helmValues.data, - loading: helmValues.isFetching, - error: helmValues.error, - load: async () => (valuesRequest ? (await helmValues.refetch()).data : undefined), - }; const compose = useMutation({ mutationFn: async () => { + const captured = context; + const input = requestFor(form); + const values = draft.values; try { const inputError = recoveryInputError(recovery); if (inputError) throw new Error(t(`deploy.recovery.errors.${inputError}`)); - const proposed = await composeRecoveryPlan(requestFor(form), recovery, (request) => composeAdaptivePlan(request, form, routeOptions.data, deployApi.plan, catalogName), catalogName); + if (draft.error || invalidNumberError) throw new Error(draft.error ?? invalidNumberError); + await checkRevision(); + if (captured !== latestContext.current) throw new DeploymentChangedError(); + const proposed = await composeDeploymentDraft(input, values!, deployApi.plan, catalogName, settings); const invalid = recoveryError(proposed, recovery); if (invalid) throw new Error(t(`deploy.recovery.errors.${invalid}`)); - return proposed; + await checkRevision(); + if (captured !== latestContext.current) throw new DeploymentChangedError(); + return { proposed, captured }; } catch (error) { throw compositionError(error); } }, - onSuccess: (pl) => { + onSuccess: ({ proposed: pl, captured }) => { + if (captured !== latestContext.current) throw new Error(t("deploy.draft.changed")); + activePlan.current = pl.hash; + setReviewContext(captured); setPlan(pl); setDiff(null); setStep("review"); @@ -259,13 +291,20 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( // The form is fixed while a plan is on screen, so its flags are the ones // the plan was composed beside. const flags = applyFlags(form); - const dryRun = useMutation({ mutationFn: (pl: Plan) => deployApi.diff({ planHash: pl.hash, applyFlags: flags }), onSuccess: setDiff }); + const dryRun = useMutation({ + mutationFn: async (pl: Plan) => ({ result: await deployApi.diff({ planHash: pl.hash, applyFlags: flags }), captured: context }), + onSuccess: ({ result, captured }) => { if (captured === latestContext.current && activePlan.current === result.planHash) setDiff(result); }, + }); const state = pipelineState({ diff, exists: live.data?.exists, applied: !!applied }); const apply = useMutation({ - mutationFn: () => (state.action === "install" ? - deployApi.install(plan!.hash, note.trim(), method, flags) : - deployApi.apply(plan!.hash, diff?.revision, note.trim(), force ? { ...flags, forceConflicts: true } : flags) - ), + mutationFn: async () => { + if (!plan || !diff || diff.planHash !== plan.hash || reviewContext !== latestContext.current) throw new Error(t("deploy.draft.changed")); + await checkRevision(); + if (reviewContext !== latestContext.current) throw new Error(t("deploy.draft.changed")); + return state.action === "install" + ? deployApi.install(plan.hash, note.trim(), method, flags) + : deployApi.apply(plan.hash, diff.revision, note.trim(), force ? { ...flags, forceConflicts: true } : flags); + }, onSuccess: (r) => { setApplied(r); for (const key of REFRESH) qc.invalidateQueries({ queryKey: key }); @@ -431,15 +470,17 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( {upgrade ? t("deploy.titleUpgrade", { release: target.release }) : t("deploy.title")} {upgrade ? t("deploy.descriptionUpgrade") : t("deploy.description")} + {upgrade && { compose.reset(); apply.reset(); setPlan(null); setDiff(null); setStep("form"); revisionSnapshot.refetch(); }} />} {step === "form" && !loading && !error && ( -
+
{SECTIONS.map((s) => ( ))} +
)} @@ -453,10 +494,10 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( {routeOptions.error && ( {t("deploy.routeOptions.error", { error: routeOptions.error.message })} )} - {(error || compose.error || apply.error) && ( + {(error || draft.error || draft.invalidField || gpuChange.error || compose.error || apply.error) && ( - {(error ?? compose.error ?? apply.error)?.message} + {draft.error ?? invalidNumberError ?? (error ?? gpuChange.error ?? compose.error ?? apply.error)?.message} )} {loading ? ( @@ -470,7 +511,8 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( onChange={change} upgrade={upgrade} submitted={submitted} - recovery={ setRecoveryPatch((previous) => ({ ...previous, ...patch }))} />} + recovery={} + disabled={!!draft.error || compose.isPending || gpuChange.isPending} target={targetFields} cluster={cluster.data} defaults={{ serviceId: upgrade ? undefined : defaultServiceId(target.model), localPath: entry.data?.localPath, localPathTemplate: entry.data?.pathTemplate, image: imageOf(v, entry.data?.imageRepository), cache }} @@ -488,6 +530,8 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( catalogRoute={catalogRoute} idPrefix={ID_PREFIX} helmValues={readOnly ? undefined : valuesSource} + configurationOpen={configurationOpen} + onConfigurationOpenChange={setConfigurationOpen} />
@@ -516,7 +560,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( {step === "form" ? ( <> - diff --git a/web/src/modules/inferences/components/OverrideYaml.tsx b/web/src/modules/inferences/components/OverrideYaml.tsx index 8b5b93b..a976a15 100644 --- a/web/src/modules/inferences/components/OverrideYaml.tsx +++ b/web/src/modules/inferences/components/OverrideYaml.tsx @@ -1,360 +1,48 @@ -import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { - Alert, - AlertDescription, - Button, - CodeBlock, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - FieldHint, - 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, mergeValues, 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. -// The chart's values and swissd's layers for the form as it stands, without -// the overrides. The deploy sheet caches them per request; load reads them -// again and resolves undefined on an error. -export interface HelmValuesSource { - data?: HelmValues; - loading: boolean; - error?: Error | null; - load: () => Promise; -} - -export function OverrideYaml({ value, onChange, source }: { - value: string; - onChange: (edits: string) => void; - source?: HelmValuesSource; -}) { +import { useEffect, useState } from "react"; +import { Alert, AlertDescription, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, FieldInput, FloatingField } from "@modelsphere/ui"; +import { Pencil, RefreshCw } from "lucide-react"; +import { useT } from "../i18n"; +import { overridePaths, parseValues, readYaml } from "../override-yaml"; +import { configurationOverrides } from "../configuration-overrides"; +import { ConfigurationOverrides } from "./ConfigurationOverrides"; +import { ConfigurationPanes } from "./ConfigurationPanes"; +import type { HelmValuesSource } from "../use-deployment-values"; +export type { HelmValuesSource } from "../use-deployment-values"; + +export function OverrideYaml({ value, onChange, source, open, onOpenChange }: { value: string; onChange: (value: string) => void; source?: HelmValuesSource; open?: boolean; onOpenChange?: (open: boolean) => void }) { const t = useT(); - const f = (k: string) => t(`deploy.fields.${k}`); - const [open, setOpen] = useState(false); + const [localOpen, setLocalOpen] = useState(false); + const visible = open ?? localOpen; + useEffect(() => { source?.onOpenChange(visible); }, [visible, source?.onOpenChange]); const paths = overridePaths(value); - const edit = () => setOpen(true); + const toggle = (next: boolean) => { setLocalOpen(next); onOpenChange?.(next); }; 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" - /> + toggle(true)}>}> + toggle(true)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(true); } }} className="cursor-pointer font-mono text-xs" /> - {open && setOpen(false)} onSave={(edits) => (onChange(edits), setOpen(false))} />} + {visible && toggle(false)} />} ); } -interface Loaded { - values: HelmValues; - // The overrides merged in, and swissd's layers with them over. - edits: Values; - overrides: string; - // What the release would render from, as YAML and parsed. - computed: string; - base: Values; -} - -// The tabs for these overrides: merged over swissd's layers, as the edit layer -// is, and those over the chart's values. -function loadedFor(values: HelmValues, editsText: string): Loaded | undefined { - try { - const edits = parseValues(editsText); - const overrides = valuesYaml(mergeValues(parseValues(values.overrides), edits)); - const computed = computedYaml(values.defaults, overrides); - return { values, edits, overrides, computed, base: parseValues(computed) }; - } catch { - return undefined; - } -} - -function OverrideDialog({ value, source, onClose, onSave }: { - value: string; - source?: HelmValuesSource; - onClose: () => void; - onSave: (edits: string) => void; -}) { +function ConfigurationDialog({ value, onChange, source, onClose }: { value: string; onChange: (value: string) => void; source?: HelmValuesSource; onClose: () => void }) { const t = useT(); - const o = (k: string, params?: Record) => t(`deploy.overrides.${k}`, params); - const [draft, setDraft] = useState(value); - // Values read earlier for this form open the tabs straight away. - const [loaded, setLoaded] = useState(() => (source?.data ? loadedFor(source.data, value) : undefined)); - const [final, setFinal] = useState(() => loaded?.computed ?? ""); - // Values that arrive with the dialog open, as when the form settles onto a - // request read before, fill the tabs with the overrides as they stand. - useEffect(() => { - if (loaded || !source?.data) return; - const l = loadedFor(source.data, draft); - if (!l) return; - setLoaded(l); - setFinal(l.computed); - }, [source?.data]); - const [tab, setTab] = useState(loaded ? "final" : "edit"); - // Two editors: edit.yaml is the overrides as stored, Final values the merged - // values once the chart's are loaded. Switching tabs carries one into the other. - const onFinal = tab === "final" && !!loaded; - const text = onFinal ? final : draft; - const setText = onFinal ? setFinal : setDraft; - // Parsing a large values.yaml takes tens of milliseconds, so it waits for a - // pause in typing rather than running on every keystroke; switching editor - // or landing a load reads the new text at once. - const settleKey = useMemo(() => ({}), [loaded, onFinal]); - const settled = useSettled(text, settleKey); - const read = useMemo(() => readYaml(settled), [settled]); - const edit = useMemo(() => onFinal && loaded && read.values && overrideEdit(loaded.base, loaded.edits, read.values), [onFinal, loaded, read]); - // The overrides as the editors hold them now, not as last settled: edit.yaml, - // with Final values' changes folded in when that is the open tab. Undefined - // while the text does not parse. - const overridesNow = (): string | undefined => { - const now = readYaml(text).values; - if (!now) return undefined; - if (!onFinal || !loaded) return draft; - const e = overrideEdit(loaded.base, loaded.edits, now); - return e.changes ? valuesYaml(e.edits) : draft; - }; - // Rebuilds the merged tabs over these overrides, with the values already read. - const rebuild = (edits: string) => { - const l = loaded && loadedFor(loaded.values, edits); - if (!l) return; - setLoaded(l); - setFinal(l.computed); - }; - // Leaving an editor carries its text into the other; a text that does not parse keeps its tab. - const switchTab = (next: Tab) => { - if (next === tab) return; - const edits = overridesNow(); - if (edits === undefined) return; - if (onFinal || tab === "edit") { - setDraft(edits); - rebuild(edits); - } - setTab(next); - }; - // A reload keeps what was typed: the changes go into the overrides first. - const loadValues = async () => { - const edits = overridesNow(); - if (edits === undefined || !source) return; - setDraft(edits); - const values = await source.load(); - const l = values && loadedFor(values, edits); - if (!l) return; - setLoaded(l); - setFinal(l.computed); - setTab("final"); - }; - const save = () => { - const edits = overridesNow(); - if (edits !== undefined) onSave(edits); - }; - const finalEditor = useRef(null); - const editEditor = useRef(null); - const defaultsBox = useRef(null); - const overridesBox = useRef(null); - const loadedKeys = useMemo(() => loaded && { defaults: keyLines(loaded.values.defaults), overrides: keyLines(loaded.overrides) }, [loaded]); - // The search runs over whichever text is on screen. - const view: { keys: KeyLine[]; jump: (line: number) => void } = - tab === "defaults" - ? { keys: loadedKeys?.defaults ?? [], jump: (line) => jumpInBlock(defaultsBox.current, line) } - : tab === "overrides" - ? { keys: loadedKeys?.overrides ?? [], jump: (line) => jumpInBlock(overridesBox.current, line) } - : tab === "edit" - ? { keys: read.keys, jump: (line) => editEditor.current?.jumpTo(line) } - : { keys: loaded ? read.keys : [], jump: (line) => finalEditor.current?.jumpTo(line) }; - const [size, setSize] = useState(); - + const read = readYaml(value); + const error = read.error ?? source?.error?.message; + const overrides = read.values && source?.data ? configurationOverrides(source.data.plan, read.values, parseValues(source.data.effective), source.data.policy) : []; return ( - !open && onClose()}> - - - {/* The explanation waits behind the hint; screen readers still get it as the description. */} -
- {t("deploy.fields.edits")} - {o(loaded ? "layersDescription" : "description")} -
- {o(loaded ? "layersDescription" : "description")} -
- - - {/* In merge order: what the chart ships, what this deploy sets over it, the overrides as stored, and the result. */} - switchTab(v as Tab)} className="min-h-0 min-w-0 flex-1 gap-2"> -
- {source && ( - - )} - - {o("chart")} - {o("swiss")} - {o("edit")} - {o("final")} - -
- - {loaded ? ( - <> -

{o("chartHint")}

-
- -
- - ) : ( - - )} -
- - {loaded ? ( - <> -

{o("swissHint")}

-
- -
- - ) : ( - - )} -
- -

{o("editHint")}

- -
- - {loaded ? ( - <> -

{o("finalHint")}

- - - ) : ( - - )} -
-
- {read.error || source?.error ? ( - - {read.error ?? message(source?.error)} - - ) : edit && ( -

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

- )} - + !next && onClose()}> + + {t("deploy.fields.edits")}{t("deploy.draft.description")} + + + {error && {error}} - - + {source && } + - ); } - -function NotLoaded({ text }: { text: string }) { - return
{text}
; -} - -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" | "edit" | "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/configuration-overrides.test.ts b/web/src/modules/inferences/configuration-overrides.test.ts new file mode 100644 index 0000000..166b7c3 --- /dev/null +++ b/web/src/modules/inferences/configuration-overrides.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import type { Plan } from "@swiss/lib/api"; +import { configurationOverrides } from "./configuration-overrides"; + +function plan(layers: Plan["layers"]): Plan { + return { apiVersion: "plan.swiss/v1", release: { name: "example", namespace: "models" }, source: { model: "weights", variant: "default" }, chart: { name: "sglang", version: "0.9.32" }, engine: "sglang", profile: "site", hash: "fixture", layers }; +} + +it("reports only changed user values owned by later layers", () => { + const requested = { replicaCount: 2, custom: "keep" }; + const composed = plan({ form: requested, edit: {}, autoscale: { replicaCount: 4 } }); + expect(configurationOverrides(composed, requested, { replicaCount: 4, custom: "keep" })).toEqual([{ path: "replicaCount", requested: 2, actual: 4, source: "autoscale" }]); +}); + +it("attributes a parent deletion to its winning system layer", () => { + const requested = { modelRoute: { nginx: { route: "/weights" } } }; + const composed = plan({ form: requested, edit: {}, "canary-system": { modelRoute: { nginx: null } } }); + expect(configurationOverrides(composed, requested, {})).toEqual([{ path: "modelRoute.nginx.route", requested: "/weights", actual: undefined, source: "canary-system" }]); +}); + +it("shows a site policy only when it changes the user's value", () => { + const requested = { modelRoute: { monitor: { enabled: true } } }; + const policy = { modelRoute: { monitor: { enabled: false } } }; + expect(configurationOverrides(plan({ form: policy }), requested, policy, policy)).toEqual([{ path: "modelRoute.monitor.enabled", requested: true, actual: false, source: "site" }]); + expect(configurationOverrides(plan({ form: requested }), requested, requested, {})).toEqual([]); +}); diff --git a/web/src/modules/inferences/configuration-overrides.ts b/web/src/modules/inferences/configuration-overrides.ts new file mode 100644 index 0000000..9876b72 --- /dev/null +++ b/web/src/modules/inferences/configuration-overrides.ts @@ -0,0 +1,42 @@ +import type { Plan } from "@swiss/lib/api"; +import { layersOf } from "@swiss/components/Provenance"; +import type { Values } from "./override-yaml"; + +export interface ConfigurationOverride { + path: string; + requested: unknown; + actual: unknown; + source: string; +} + +function leaves(values: Values, prefix: string[] = []): { path: string[]; value: unknown }[] { + return Object.entries(values).flatMap(([key, value]) => { + const path = [...prefix, key]; + return value && typeof value === "object" && !Array.isArray(value) ? leaves(value as Values, path) : [{ path, value }]; + }); +} + +function at(values: Values, path: string[]): unknown { + return path.reduce((current, key) => current && typeof current === "object" && Object.hasOwn(current, key) ? (current as Values)[key] : undefined, values); +} + +function writesPath(values: Values, path: string[]): boolean { + let current: unknown = values; + for (const key of path) { + if (!current || typeof current !== "object" || !Object.hasOwn(current, key)) return false; + current = (current as Values)[key]; + if (current === null || typeof current !== "object" || Array.isArray(current)) return true; + } + return true; +} + +export function configurationOverrides(plan: Plan, requested: Values, effective: Values, policy: Values = {}): ConfigurationOverride[] { + const ordered = layersOf(plan); + const system = ordered.slice(Math.max(ordered.indexOf("form"), ordered.indexOf("edit")) + 1).reverse(); + return leaves(requested).flatMap(({ path, value }) => { + const actual = at(effective, path); + if ((value === null && actual === undefined) || JSON.stringify(value) === JSON.stringify(actual)) return []; + const source = system.find((key) => writesPath(plan.layers[key], path)) ?? (writesPath(policy, path) ? "site" : undefined); + return source ? [{ path: path.join("."), requested: value, actual, source }] : []; + }); +} diff --git a/web/src/modules/inferences/deployment-draft.test.ts b/web/src/modules/inferences/deployment-draft.test.ts new file mode 100644 index 0000000..3ef05e5 --- /dev/null +++ b/web/src/modules/inferences/deployment-draft.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { EMPTY } from "./deployment-settings"; +import { draftForm, editDraftForm, seedDraft } from "./deployment-draft"; +import { parseValues } from "./override-yaml"; + +const controls = { ...EMPTY, scaler: false, serviceId: "model", namespace: "models" }; + +describe("shared deployment draft", () => { + it("projects the same YAML value into the structured form", () => { + expect(draftForm(controls, "replicaCount: 2\n").replicaCount).toBe("2"); + }); + it("writes a form edit into the document without losing unrelated fields or comments", () => { + const text = "# user configuration\nreplicaCount: 2\ncustom:\n mode: fast\n"; + const edited = editDraftForm(text, draftForm(controls, text), { replicaCount: "3" }); + expect(parseValues(edited)).toEqual({ replicaCount: 3, custom: { mode: "fast" } }); + expect(edited).toContain("# user configuration"); + }); + it("preserves inherited defaults as absent until the user edits them", () => { + const form = draftForm(controls, "", { replicaCount: 1, scaler: { enabled: false } }); + expect(form.replicaCount).toBe("1"); + expect(parseValues(editDraftForm("", form, { replicaCount: "3" }))).toEqual({ replicaCount: 3 }); + }); + it("clears a structured field by removing its override, rather than freezing a default", () => { + const text = "replicaCount: 2\ncustom: true\n"; + expect(parseValues(editDraftForm(text, draftForm(controls, text), { replicaCount: "" }))).toEqual({ custom: true }); + }); + it("synchronizes nested image and resource fields while retaining unrepresented keys", () => { + const text = "image:\n repository: registry/engine\n tag: old\n pullPolicy: Always\n"; + const next = editDraftForm(text, draftForm(controls, text), { image: "registry/engine:new" }); + expect(parseValues(next)).toEqual({ image: { repository: "registry/engine", tag: "new", pullPolicy: "Always" } }); + }); + it("retains deployment identity and flags outside the editable values", () => { + const form = draftForm({ ...controls, skipSchemaValidation: true }, "namespace: untrusted\nserviceId: other\n"); + expect(form.namespace).toBe("models"); + expect(form.serviceId).toBe("model"); + expect(form.skipSchemaValidation).toBe(true); + }); + it("retains every old explicit value, giving legacy YAML the previous precedence", () => { + const initial = { ...controls, edits: "replicaCount: 4\ncustom: true\n" }; + expect(parseValues(seedDraft(initial, { replicaCount: 2, unknown: { keep: true } }))).toEqual({ replicaCount: 4, unknown: { keep: true }, custom: true }); + }); + it("refuses invalid YAML before a structured edit can replace its draft", () => { + expect(() => editDraftForm("replicaCount: [", controls, { replicaCount: "3" })).toThrow(); + }); + it("keeps null deletion distinct from an absent inherited value", () => { + expect(draftForm(controls, "replicaCount: null\n", { replicaCount: 1 }).replicaCount).toBe(""); + }); +}); + +it("replaces a null ancestor when editing an image, preserving unrelated values", () => { + const text = "# retained\nimage: null\ncustom: true\n"; + const next = editDraftForm(text, draftForm(controls, text), { image: "registry/engine:new" }); + expect(parseValues(next)).toEqual({ image: { repository: "registry/engine", tag: "new" }, custom: true }); + expect(next).toContain("# retained"); +}); diff --git a/web/src/modules/inferences/deployment-draft.ts b/web/src/modules/inferences/deployment-draft.ts new file mode 100644 index 0000000..f9c1b87 --- /dev/null +++ b/web/src/modules/inferences/deployment-draft.ts @@ -0,0 +1,65 @@ +import { isMap, parseDocument } from "yaml"; +import type { Form } from "@swiss/components/DeploySettings"; +import { formFromPlan, planRequest } from "./deployment-settings"; +import { mergeValues, overrideEdit, parseValues, valuesYaml, type Values } from "./override-yaml"; + +const CONTROL_FIELDS = ["namespace", "serviceId", "createNamespace", "skipSchemaValidation"] as const; + +function formValues(form: Form): Values { + const serialize = (settings: Form) => planRequest({ ...settings, edits: "" }, { model: settings.serviceId }).overrides ?? {}; + // Disabling a feature changes its switch, not the settings hidden by that switch. + const settings = serialize({ ...form, scaler: true, cart: true, cache: true, monitor: true, adaptiveCc: true }); + const values = mergeValues(settings, serialize(form)); + values.cache = { ...(values.cache as Values), enabled: form.cache }; + if (form.localPath.trim()) values.model = mergeValues((values.model as Values | undefined) ?? {}, { localPath: form.localPath.trim() }); + if (form.gpuResource) values.model = mergeValues((values.model as Values | undefined) ?? {}, { gpuResource: form.gpuResource }); + return values; +} + +export function seedDraft(form: Form, explicit = formValues(form)): string { + const resource = form.gpuResource ? { model: { gpuResource: form.gpuResource } } : {}; + return valuesYaml(mergeValues(mergeValues(resource, explicit), parseValues(form.edits))); +} + +export function draftForm(controls: Form, text: string, baseline: Values = {}): Form { + const values = mergeValues(baseline, parseValues(text)); + const resource = (values.model as Values | undefined)?.gpuResource; + const projected = formFromPlan({ release: { name: controls.serviceId, namespace: controls.namespace }, layers: { form: values, derived: { model: { gpuResource: typeof resource === "string" ? resource : "" } } } }); + const metadata = Object.fromEntries(CONTROL_FIELDS.map((key) => [key, controls[key]])); + return { ...projected, ...metadata, edits: text }; +} + +// Use the existing serializer to identify a control's values, including conditional fields. +export function editDraftForm(text: string, form: Form, patch: Partial): string { + const before = formValues(form); + const after = formValues({ ...form, ...patch }); + const changes = overrideEdit(before, {}, after).edits; + return editDraftValues(text, changes, before); +} + +export function editDraftValues(text: string, changes: Values, before: Values = {}, nulls: "remove" | "retain" = "remove"): string { + parseValues(text); + if (!Object.keys(changes).length) return text; + const document = parseDocument(text, { uniqueKeys: true }); + const apply = (values: Values, previous: Values, parent: string[]) => { + for (const [key, value] of Object.entries(values)) { + const path = [...parent, key]; + const old = previous[key]; + if (value === null && nulls === "remove") { + if (old && typeof old === "object" && !Array.isArray(old)) { + apply(Object.fromEntries(Object.keys(old).map((child) => [child, null])), old as Values, path); + } else if (document.hasIn(path)) document.deleteIn(path); + } else if (value && typeof value === "object" && !Array.isArray(value)) { + if (!isMap(document.getIn(path, true))) document.setIn(path, document.createNode({})); + apply(value as Values, old && typeof old === "object" ? old as Values : {}, path); + } else document.setIn(path, value); + } + }; + apply(changes, before, []); + return document.contents === null ? "" : document.toString(); +} + +export function draftControls(controls: Form, patch: Partial): Form { + const metadata = Object.fromEntries(CONTROL_FIELDS.filter((key) => key in patch).map((key) => [key, patch[key]])); + return { ...controls, ...metadata }; +} diff --git a/web/src/modules/inferences/deployment-gpu.test.ts b/web/src/modules/inferences/deployment-gpu.test.ts new file mode 100644 index 0000000..56c5513 --- /dev/null +++ b/web/src/modules/inferences/deployment-gpu.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "vitest"; +import { applyResolvedGpu, configuredGpuProducts, gpuResolutionValues, resolvedGpuChanges } from "./deployment-gpu"; +import { editDraftValues, seedDraft } from "./deployment-draft"; +import { parseValues } from "./override-yaml"; +import { EMPTY } from "./deployment-settings"; +import { initialDraft, projection } from "./use-deployment-draft"; +import type { Plan, GPUSpec } from "@swiss/lib/api"; + +const specifications: GPUSpec[] = [{ vendor: "nvidia", resource: "example.com/gpu", labelKey: "example.com/model", products: [{ product: "H100", values: ["h100"] }, { product: "H800", values: ["h800"] }] }]; +const affinity = (product: string) => ({ nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [{ key: "example.com/model", operator: "In", values: [product] }] }] }, preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 10, preference: { matchExpressions: [{ key: "region", operator: "In", values: ["local"] }] } }] } }); + +it("replaces old required GPU terms with the server's resolution and retains preferred affinity", () => { + const text = seedDraft(EMPTY, { affinity: affinity("h100"), custom: true }); + const input = gpuResolutionValues(text, specifications); + expect(input).toMatchObject({ affinity: { nodeAffinity: { preferredDuringSchedulingIgnoredDuringExecution: affinity("h100").nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution } }, custom: true }); + expect(JSON.stringify(input)).not.toContain("h100"); + const resolved = { layers: { form: { affinity: affinity("h800") }, derived: { model: { gpuResource: "example.com/gpu" } } } } as unknown as Plan; + const changed = editDraftValues(text, resolvedGpuChanges(resolved, { ...EMPTY, edits: text, gpuProducts: ["H800"] }, specifications), parseValues(text)); + expect(configuredGpuProducts(parseValues(changed), specifications, [])).toEqual(["H800"]); + expect(parseValues(changed)).toMatchObject({ custom: true, affinity: affinity("h800") }); +}); + +it("clears a product restriction and projects YAML changes into the product selector", () => { + const draft = initialDraft(EMPTY, { affinity: affinity("h800") }); + expect(projection(draft, {}, specifications).gpuProducts).toEqual(["H800"]); + const changes = resolvedGpuChanges({ layers: { derived: { model: { gpuResource: "example.com/gpu" } } } } as unknown as Plan, { ...EMPTY, edits: draft.text }, specifications); + const cleared = editDraftValues(draft.text, changes, parseValues(draft.text)); + expect(configuredGpuProducts(parseValues(cleared), specifications, [])).toEqual([]); + expect(JSON.stringify(parseValues(cleared))).toContain("preferredDuringSchedulingIgnoredDuringExecution"); + const deleted = seedDraft(EMPTY, { affinity: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: null } } } }); + expect(() => gpuResolutionValues(deleted, specifications)).not.toThrow(); + const resolved = { layers: { form: { affinity: affinity("h800") }, derived: { model: { gpuResource: "example.com/gpu" } } } } as unknown as Plan; + const restored = editDraftValues(deleted, resolvedGpuChanges(resolved, { ...EMPTY, edits: deleted, gpuProducts: ["H800"] }, specifications), parseValues(deleted)); + expect(configuredGpuProducts(parseValues(restored), specifications, [])).toEqual(["H800"]); +}); + +it("retains required zones, matchFields and selector term grouping when changing or clearing products", () => { + const terms = ["west", "east"].map((zone) => ({ + matchExpressions: [{ key: "example.com/model", operator: "In", values: ["h100"] }, { key: "topology.kubernetes.io/zone", operator: "In", values: [zone] }], + matchFields: [{ key: "metadata.name", operator: "NotIn", values: ["maintenance"] }], + })); + terms[1].matchExpressions[0].values = ["h800"]; + const text = seedDraft(EMPTY, { affinity: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: terms } } } }); + expect(configuredGpuProducts(parseValues(text), specifications, [])).toEqual(["H100", "H800"]); + const resolved = { layers: { form: { affinity: affinity("h800") }, derived: { model: { gpuResource: "example.com/gpu" } } } } as unknown as Plan; + const form = { ...EMPTY, edits: text, gpuProducts: ["H800"] }; + const changed = editDraftValues(text, resolvedGpuChanges(resolved, form, specifications), parseValues(text)); + const serialized = JSON.stringify(parseValues(changed)); + expect(serialized).not.toContain("h100"); + for (const value of ["h800", "west", "east", "maintenance", "matchFields"]) expect(serialized).toContain(value); + const cleared = editDraftValues(text, resolvedGpuChanges(resolved, { ...form, gpuProducts: [] }, specifications), parseValues(text)); + expect(JSON.stringify(parseValues(cleared))).not.toContain("h100"); + expect(JSON.stringify(parseValues(cleared))).toContain("topology.kubernetes.io/zone"); +}); + + +it("keeps an explicit GPU clear distinct from omitted YAML on upgrades", () => { + const stored = { affinity: affinity("h100") }; + const draft = initialDraft(EMPTY, stored); + const resolved = { layers: { derived: { model: { gpuResource: "example.com/gpu" } } } } as unknown as Plan; + const changes = resolvedGpuChanges(resolved, { ...EMPTY, edits: draft.text, gpuProducts: [] }, specifications); + const cleared = { ...draft, text: applyResolvedGpu(draft.text, changes) }; + expect(projection(cleared, stored, specifications).gpuProducts).toEqual([]); + expect(parseValues(cleared.text)).toMatchObject({ affinity: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: null } } }); + expect(projection({ ...draft, text: "custom: true" }, stored, specifications).gpuProducts).toEqual(["H100"]); +}); + +it("resolves an automatic GPU choice from target defaults instead of reviving the saved choice", () => { + const stored = { affinity: affinity("h100"), model: { gpuResource: "example.com/old" } }; + const draft = initialDraft(EMPTY, stored); + const resolved = { layers: { derived: { affinity: affinity("h800"), model: { gpuResource: "example.com/gpu" } } } } as unknown as Plan; + const changes = resolvedGpuChanges(resolved, { ...EMPTY, edits: draft.text }, specifications); + const updated = { ...draft, text: applyResolvedGpu(draft.text, changes) }; + expect(projection(updated, stored, specifications)).toMatchObject({ gpuProducts: ["H800"], gpuResource: "example.com/gpu" }); +}); + + +it("uses target catalog/site GPU constraints even when derived omits them", () => { + const text = seedDraft(EMPTY, { affinity: affinity("h100"), model: { gpuResource: "example.com/old" } }); + const plan = { layers: { catalog: { affinity: affinity("h100") }, site: { affinity: affinity("h800") }, derived: {} } } as unknown as Plan; + const defaults = { model: { gpuResource: "example.com/default" } }; + const changes = resolvedGpuChanges(plan, { ...EMPTY, edits: text }, specifications, defaults); + const updated = applyResolvedGpu(text, changes); + expect(projection({ ...initialDraft(EMPTY), text: updated }, {}, specifications)).toMatchObject({ gpuProducts: ["H800"], gpuResource: "example.com/default" }); +}); + + +it("keeps default-resource GPU selection compatible with charts that omit gpuResource", () => { + const text = seedDraft(EMPTY, { affinity: affinity("h100") }); + const plan = { chart: { name: "sglang", version: "0.8.0" }, layers: { form: { affinity: affinity("h800") }, derived: {} } } as unknown as Plan; + const changes = resolvedGpuChanges(plan, { ...EMPTY, edits: text, gpuProducts: ["H800"] }, specifications, {}, {}); + const updated = parseValues(applyResolvedGpu(text, changes)); + expect(configuredGpuProducts(updated, specifications, [])).toEqual(["H800"]); + expect(updated.model).not.toHaveProperty("gpuResource"); +}); diff --git a/web/src/modules/inferences/deployment-gpu.ts b/web/src/modules/inferences/deployment-gpu.ts new file mode 100644 index 0000000..3671cb3 --- /dev/null +++ b/web/src/modules/inferences/deployment-gpu.ts @@ -0,0 +1,98 @@ +import { layersOf } from "@swiss/components/Provenance"; +import type { GPUSpec, Plan } from "@swiss/lib/api"; +import type { Form } from "@swiss/components/DeploySettings"; +import { editDraftValues } from "./deployment-draft"; +import { mergeValues, parseValues, type Values } from "./override-yaml"; + +const TERMS = ["affinity", "nodeAffinity", "requiredDuringSchedulingIgnoredDuringExecution", "nodeSelectorTerms"]; + +function at(values: Values, path: string[]): unknown { + return path.reduce((value, key) => value && typeof value === "object" ? (value as Values)[key] : undefined, values); +} + +function selector(terms: unknown): Values { + return { affinity: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: terms === null ? null : { nodeSelectorTerms: terms } } } }; +} + +function gpuKeys(specifications: GPUSpec[], generated: Values[]): Set { + const keys = specifications.map((entry) => entry.labelKey); + for (const term of generated) { + if (!Array.isArray(term.matchExpressions)) continue; + for (const expression of term.matchExpressions) if (typeof expression?.key === "string") keys.push(expression.key); + } + return new Set(keys); +} + +function intersectTerms(previous: Values, generated: Values): Values { + const combined = { ...previous, ...generated }; + for (const key of ["matchExpressions", "matchFields"]) { + const entries = [...(Array.isArray(previous[key]) ? previous[key] : []), ...(Array.isArray(generated[key]) ? generated[key] : [])]; + if (entries.length) combined[key] = entries; + else delete combined[key]; + } + return combined; +} + +function rewriteTerms(previous: unknown, generated: unknown, specifications: GPUSpec[]): Values[] | null { + if (previous != null && !Array.isArray(previous)) throw new Error("Required node affinity must contain a list of selector terms."); + const current: Values[] = previous ?? []; + if (current.some((term) => !term || typeof term !== "object" || Array.isArray(term))) throw new Error("Required node affinity contains an invalid selector term."); + if (current.some((term) => ["matchExpressions", "matchFields"].some((key) => term[key] !== undefined && !Array.isArray(term[key])))) throw new Error("Required node affinity contains an invalid selector expression list."); + const next: Values[] = Array.isArray(generated) ? generated : []; + const keys = gpuKeys(specifications, next); + const isGpu = (expression: Values) => expression && typeof expression.key === "string" && keys.has(expression.key); + // Replace GPU expressions within their original OR branches, retaining other required constraints. + const hasGpu = current.some((term) => Array.isArray(term.matchExpressions) && term.matchExpressions.some(isGpu)); + const updated = current.flatMap((term) => { + const expressions = Array.isArray(term.matchExpressions) ? term.matchExpressions : []; + const owned = expressions.some(isGpu); + if (!owned) return !hasGpu && next.length ? next.map((generatedTerm) => intersectTerms(term, generatedTerm)) : [term]; + const retained = { ...term }; + const others = expressions.filter((expression) => !isGpu(expression)); + if (others.length) retained.matchExpressions = others; + else delete retained.matchExpressions; + if (next.length) return next.map((generatedTerm) => intersectTerms(retained, generatedTerm)); + return Object.keys(retained).length ? [retained] : []; + }); + return (current.length ? updated : next).length ? (current.length ? updated : next) : null; +} + +export function gpuResolutionValues(text: string, specifications: GPUSpec[] = []): Values { + const previous = parseValues(text); + const terms = rewriteTerms(at(previous, TERMS), null, specifications); + return parseValues(editDraftValues(text, { ...selector(terms), model: { gpuResource: null } }, previous)); +} + +export function resolvedGpuChanges(plan: Plan, form: Form, specifications: GPUSpec[] = [], defaults: Values = {}, effective?: Values): Values { + const target = layersOf(plan).filter((key) => !["form", "edit", "autoscale", "canary-system"].includes(key)).reduce((values, key) => mergeValues(values, plan.layers[key]), defaults); + const terms = at(form.gpuProducts.length ? plan.layers.form ?? {} : target, TERMS) ?? null; + if (form.gpuProducts.length && !Array.isArray(terms)) throw new Error("The deployment service did not resolve the selected GPU products."); + const previous = parseValues(form.edits); + const resource = form.gpuResource || at(effective ?? target, ["model", "gpuResource"]); + if (resource !== undefined && (typeof resource !== "string" || !resource)) throw new Error("The deployment service returned an invalid GPU resource."); + return { ...selector(rewriteTerms(at(previous, TERMS), terms, specifications)), model: { gpuResource: resource ?? null } }; +} + +export function applyResolvedGpu(text: string, changes: Values): string { + const withoutResource = editDraftValues(text, { model: changes.model }, parseValues(text)); + // A cleared selector must mask the retained form layer rather than reveal the old selection. + return editDraftValues(withoutResource, { affinity: changes.affinity }, {}, "retain"); +} + +export function configuredGpuProducts(values: Values, specifications: GPUSpec[], fallback: string[]): string[] { + const terms = at(values, TERMS); + if (!Array.isArray(terms)) return []; + const selected = new Set(); + for (const term of terms) { + const expressions = term && typeof term === "object" ? term.matchExpressions : undefined; + if (!Array.isArray(expressions)) continue; + for (const expression of expressions) { + const specification = specifications.find((entry) => entry.labelKey === expression?.key); + if (!specification || expression.operator !== "In" || !Array.isArray(expression.values)) continue; + const labels: unknown[] = expression.values; + const products = labels.filter((value): value is string => typeof value === "string").map((value) => specification.products?.find((product) => product.product === value || product.values?.includes(value))?.product ?? value); + for (const product of products) selected.add(product); + } + } + return selected.size ? [...selected] : fallback; +} diff --git a/web/src/modules/inferences/deployment-preview.test.ts b/web/src/modules/inferences/deployment-preview.test.ts new file mode 100644 index 0000000..4be6b14 --- /dev/null +++ b/web/src/modules/inferences/deployment-preview.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import { api, type Plan, type PlanRequest } from "@swiss/lib/api"; +import { composeDeploymentDraft, deploymentValuesRequest, deploymentBaselineRequest, DeploymentChangedError, readDeploymentRevision } from "./deployment-preview"; +import { EMPTY } from "./deployment-settings"; +import { recoverySettings } from "./recovery"; +import { mergeValues, parseValues } from "./override-yaml"; + +const original: Plan = { + apiVersion: "plan.swiss/v1", release: { name: "model", namespace: "models" }, + source: { model: "weights", variant: "gpu", version: "1", catalogName: "private" }, + chart: { name: "sglang", version: "0.9.32" }, engine: "sglang", profile: "site", hash: "old", + layers: { catalog: { image: { tag: "catalog" } }, form: { replicaCount: 2, obsolete: true, sloRequirement: { enabled: true }, pd: { enabled: true, colocate: false } }, edit: {}, autoscale: { replicaCount: 4 } }, +}; + +// Match the existing upgrade contract: form merges, while a submitted edit layer replaces. +function legacyCompose(request: PlanRequest, saved = original): Plan { + const hasForm = Object.keys(request.overrides ?? {}).length > 0; + const form = mergeValues(request.fromRelease ? saved.layers.form : {}, request.overrides ?? {}); + const edit = request.fromRelease && !hasForm ? saved.layers.edit : parseValues(request.editsYAML ?? ""); + const { autoscale: _autoscale, ...layers } = saved.layers; + const pd = mergeValues(form, edit).pd as { enabled?: boolean; colocate?: boolean } | undefined; + const on = (form.sloRequirement as { enabled?: boolean })?.enabled && pd?.enabled && !pd.colocate; + return { ...saved, layers: { ...layers, form, edit, ...(request.fromRelease && on && saved.layers.autoscale ? { autoscale: saved.layers.autoscale } : {}) } }; +} + +describe("shared compose boundary", () => { + it("keeps the existing form payload and fromRelease when saving partial YAML", async () => { + const compose = vi.fn<(request: PlanRequest) => Promise>().mockImplementation(async (request) => legacyCompose(request)); + const request = { fromRelease: "model", namespace: "models", overrides: { replicaCount: 2 }, editsYAML: "old: true" }; + const result = await composeDeploymentDraft(request, { custom: true }, compose); + expect(compose).toHaveBeenCalledExactlyOnceWith({ ...request, catalog: undefined, editsYAML: "custom: true\n" }); + expect(result.layers.form).toEqual(original.layers.form); + expect(result.layers.edit).toEqual({ custom: true }); + expect(result.layers.autoscale).toEqual(original.layers.autoscale); + expect(mergeValues(result.layers.form, result.layers.edit)).toMatchObject({ replicaCount: 2, custom: true }); + }); + it("allows YAML to override a form value without deleting unrelated saved settings", async () => { + const result = await composeDeploymentDraft({ fromRelease: "model", overrides: { replicaCount: 3 } }, { replicaCount: 3 }, async (request) => legacyCompose(request)); + expect(mergeValues(result.layers.form, result.layers.edit)).toEqual({ ...original.layers.form, replicaCount: 3 }); + }); + it("clears YAML-only overrides while retaining the saved form", async () => { + const result = await composeDeploymentDraft({ fromRelease: "model", overrides: { replicaCount: 2 } }, {}, async (request) => legacyCompose(request)); + expect(result.layers.edit).toEqual({}); + expect(result.layers.form).toEqual(original.layers.form); + }); + it("uses pinned target and the same form/edit layers for the effective preview", async () => { + const result = await composeDeploymentDraft({ fromRelease: "model", overrides: { replicaCount: 3 } }, { custom: true }, async (request) => legacyCompose(request)); + const request = deploymentValuesRequest({ fromRelease: "model" }, result); + expect(request).toMatchObject({ fromRelease: "model", overrides: result.layers.form, version: "1", variant: "gpu", chartVersion: "0.9.32" }); + expect(parseValues(request.editsYAML!)).toEqual(result.layers.edit); + expect(legacyCompose(request).layers).toEqual(result.layers); + }); + it("keeps the site Monitor policy in the effective document", async () => { + const compose = vi.fn<(request: PlanRequest) => Promise>().mockImplementation(async (request) => legacyCompose(request)); + await composeDeploymentDraft({ overrides: { modelRoute: { monitor: { enabled: false } } } }, { modelRoute: { enabled: true, monitor: { enabled: true }, custom: "keep" } }, compose); + expect(parseValues(compose.mock.calls[0][0].editsYAML!)).toEqual({ modelRoute: { enabled: true, monitor: { enabled: false }, custom: "keep" } }); + }); + it("composes the target baseline without carrying the saved user layers", () => { + const request = deploymentBaselineRequest({ fromRelease: "model", model: "weights", version: "1", variant: "gpu", overrides: { replicaCount: 3 }, editsYAML: "custom: true", localPath: "/models/old", gpuProducts: ["H100"], gpuResource: "example.com/gpu" }); + expect(request).toEqual({ model: "weights", version: "1", variant: "gpu", overrides: {} }); + }); +}); + +it("retains the existing explicit timeout/adaptive cleanup and previews its final request", async () => { + const saved: Plan = { ...original, layers: { form: { + image: { repository: "registry/engine", tag: "saved" }, custom: "keep", + continuationGateway: { enabled: true, config: { continuationModel: "weights", stallIdleTimeoutSeconds: 45 } }, + modelRoute: { nginx: { values: { adaptive_cc: "true", adaptive_cc_min: "80" } } }, + }, edit: { previousYamlOnly: true } } }; + const values = { continuationGateway: { enabled: true, config: { continuationModel: "weights" } }, modelRoute: { nginx: { values: { adaptive_cc: "false" } } } }; + const settings = { form: { ...EMPTY, adaptiveCc: false }, recovery: { ...recoverySettings(saved.layers.form), idleTimeout: "" } }; + const requests: PlanRequest[] = []; + const result = await composeDeploymentDraft({ fromRelease: "model", overrides: { modelRoute: { nginx: { values: { adaptive_cc: "false" } } } } }, values, async (input) => { requests.push(input); return legacyCompose(input, saved); }, undefined, settings); + expect(requests).toHaveLength(3); + expect(result.layers.form).toMatchObject({ image: saved.layers.form.image, custom: "keep" }); + expect(result.layers.form.continuationGateway).not.toHaveProperty("config.stallIdleTimeoutSeconds"); + expect(result.layers.form.modelRoute).not.toHaveProperty("nginx.values.adaptive_cc_min"); + expect(result.layers.edit).not.toHaveProperty("previousYamlOnly"); + const preview = deploymentValuesRequest(requests.at(-1)!, result); + expect(preview.fromRelease).toBeUndefined(); + expect(legacyCompose(preview, saved).layers).toEqual(result.layers); +}); + +it("models the existing bare-upgrade behavior separately from a form submission", () => { + const saved = { ...original, layers: { ...original.layers, edit: { savedEdit: true } } }; + expect(legacyCompose({ fromRelease: "model", editsYAML: "new: true" }, saved).layers.edit).toEqual({ savedEdit: true }); + expect(legacyCompose({ fromRelease: "model", overrides: { replicaCount: 2 }, editsYAML: "" }, saved).layers.edit).toEqual({}); +}); + +describe("running revision snapshot", () => { + it("checks the revision again after reading its actual values", async () => { + vi.spyOn(api, "status").mockResolvedValue({ exists: true, revision: 7 } as Awaited>); + vi.spyOn(api, "revisionValues").mockResolvedValue({ namespace: "models", release: "model", revision: 7, all: "replicaCount: 2\n", supplied: "" }); + const snapshot = await readDeploymentRevision("models", "model"); + expect(snapshot.revision).toBe(7); + expect(api.status).toHaveBeenCalledTimes(2); + vi.restoreAllMocks(); + }); + it("rejects an unrelated response and a revision changed during loading", async () => { + const status = vi.spyOn(api, "status").mockResolvedValueOnce({ exists: true, revision: 7 } as Awaited>).mockResolvedValueOnce({ exists: true, revision: 8 } as Awaited>); + vi.spyOn(api, "revisionValues").mockResolvedValue({ namespace: "models", release: "model", revision: 7, all: "{}", supplied: "" }); + await expect(readDeploymentRevision("models", "model")).rejects.toBeInstanceOf(DeploymentChangedError); + status.mockResolvedValue({ exists: true, revision: 7 } as Awaited>); + vi.mocked(api.revisionValues).mockResolvedValue({ namespace: "other", release: "model", revision: 7, all: "{}", supplied: "" }); + await expect(readDeploymentRevision("models", "model")).rejects.toBeInstanceOf(DeploymentChangedError); + vi.restoreAllMocks(); + }); +}); + + +it("rejects YAML that conflicts with the locked deployment identity", async () => { + const compose = vi.fn<(request: PlanRequest) => Promise>(); + for (const serviceId of ["other", null]) await expect(composeDeploymentDraft({ fromRelease: "release", serviceId: "service" }, { serviceId }, compose)).rejects.toThrow("serviceId must match"); + expect(compose).not.toHaveBeenCalled(); +}); diff --git a/web/src/modules/inferences/deployment-preview.ts b/web/src/modules/inferences/deployment-preview.ts new file mode 100644 index 0000000..771fb9f --- /dev/null +++ b/web/src/modules/inferences/deployment-preview.ts @@ -0,0 +1,74 @@ +import type { Form } from "@swiss/components/DeploySettings"; +import { composeAdaptivePlan, AdaptivePlanChangedError } from "./adaptive-concurrency"; +import { composeRecoveryPlan, RecoveryPlanChangedError } from "./recovery-compose"; +import type { RecoverySettings } from "./recovery"; +import type { RouteOptions } from "./routeOptions"; +import { api, type Plan, type PlanRequest, type RevisionValues } from "@swiss/lib/api"; +import { mergeValues, parseValues, valuesYaml, type Values } from "./override-yaml"; + +export class DeploymentChangedError extends Error { + constructor() { super("The deployment changed. Reload its current revision and preview again."); } +} + +export async function assertDeploymentRevision(namespace: string, release: string, revision: number): Promise { + const current = await api.status(namespace, release); + if (!current.exists || current.revision !== revision) throw new DeploymentChangedError(); +} + +export async function readDeploymentRevision(namespace: string, release: string): Promise { + const current = await api.status(namespace, release); + if (!current.exists || !Number.isInteger(current.revision) || current.revision <= 0) throw new DeploymentChangedError(); + const snapshot = await api.revisionValues(namespace, release, current.revision); + if (snapshot.namespace !== namespace || snapshot.release !== release || snapshot.revision !== current.revision) throw new DeploymentChangedError(); + parseValues(snapshot.all); + await assertDeploymentRevision(namespace, release, current.revision); + return snapshot; +} + + +export function deploymentPolicy(request: PlanRequest): Values { + const route = request.overrides?.modelRoute as Values | undefined; + const monitor = route?.monitor as Values | undefined; + return typeof monitor?.enabled === "boolean" ? { modelRoute: { monitor: { enabled: monitor.enabled } } } : {}; +} + +export interface DeploymentSettings { + form: Form; + recovery: RecoverySettings; + routeOptions?: RouteOptions | null; +} + +export async function composeDeploymentDraft(request: PlanRequest, values: Values, compose: (request: PlanRequest) => Promise, catalog?: string, settings?: DeploymentSettings): Promise { + const identity = request.serviceId || request.release || request.fromRelease || request.model; + if (Object.hasOwn(values, "serviceId") && values.serviceId !== identity) throw new Error(`serviceId must match the deployment identity (${identity}).`); + const prepared = { ...request, catalog: request.catalog || catalog, editsYAML: valuesYaml(mergeValues(values, deploymentPolicy(request))) }; + if (!settings) return compose(prepared); + try { + return await composeRecoveryPlan(prepared, settings.recovery, (input) => composeAdaptivePlan(input, settings.form, settings.routeOptions, compose, catalog), catalog); + } catch (error) { + if (error instanceof RecoveryPlanChangedError || error instanceof AdaptivePlanChangedError) throw new DeploymentChangedError(); + throw error; + } +} + +export function deploymentValuesRequest(request: PlanRequest, plan: Plan, catalog?: string): PlanRequest { + return { + ...request, + catalog: plan.source.catalogName || catalog || 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: valuesYaml(plan.layers.edit ?? {}), + }; +} + +export function deploymentBaselineRequest(request: PlanRequest): PlanRequest { + const baseline = { ...request, overrides: deploymentPolicy(request) }; + delete baseline.fromRelease; + delete baseline.overridesYAML; + delete baseline.editsYAML; + delete baseline.localPath; + delete baseline.gpuProducts; + delete baseline.forceGpuProducts; + delete baseline.gpuResource; + return baseline; +} diff --git a/web/src/modules/inferences/deployment-settings.ts b/web/src/modules/inferences/deployment-settings.ts index 205cf34..01b1bfa 100644 --- a/web/src/modules/inferences/deployment-settings.ts +++ b/web/src/modules/inferences/deployment-settings.ts @@ -12,7 +12,7 @@ function monitorSettings(form: Form, on: boolean): Form { export const EMPTY: Form = monitorSettings(SWISS_EMPTY, false); // Keeps the plan's monitor model and GPU type; planRequest decides whether they are sent. -export const formFromPlan = (plan: Plan): Form => swissFormFromPlan(plan); +export const formFromPlan = (plan: Pick): Form => swissFormFromPlan(plan); export const planRequest = (form: Form, options: Parameters[1], profile?: SiteProfile) => swissPlanRequest(profile ? monitorSettings(form, monitorOn(profile)) : form, options); diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 07d323d..35ed8d5 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -613,8 +613,7 @@ "fracRange": "Must be over 0 and at most 1, or openresty ignores it", "serviceMonitor": "ServiceMonitor", "serviceMonitorHint": "Prometheus metrics scraped alongside the release", - "edits": "Override YAML", - "editsHint": "Any values key, applied after every layer and exempt from layer ownership; shown as its own edit layer in the plan", + "edits": "Configuration YAML", "cache": "Compile cache", "cacheHint": "Keeps compiled kernels on the node, so a restarted pod skips the 20–40 minute recompile. Empty fields keep the site's, else the chart's. Off writes nothing, so a site that turns it on still applies: set cache.enabled: false in Override YAML to force it off", "cacheNone": "Not supported by the {chart} chart", @@ -647,22 +646,22 @@ "format": "Lowercase letters, digits and -, starting with a letter and ending with a letter or digit (it names Kubernetes Services, so no dots)", "length": "At most 53 characters" }, + "draft": { + "editYaml": "Edit YAML", + "baseline": "Deployment baseline", + "user": "User configuration", + "effective": "Effective configuration", + "unavailable": "Configuration unavailable", + "description": "Edit user configuration alongside its baseline and effective values.", + "refresh": "Refresh preview", + "current": "Current configuration", + "refreshRevision": "Reload current revision", + "changed": "The deployment or configuration changed. Reload the current revision and preview again.", + "invalidNumber": "Enter a valid number for {field}.", + "managed": "{field}: configured {requested}, effective {actual} ({source})" + }, "overrides": { "invalid": "Not valid YAML", - "description": "Values applied over everything this deploy sets. Load the chart's values to edit them merged instead.", - "load": "Load chart values", - "loading": "Loading…", - "layersDescription": "Nothing is stored until you deploy. Finish keeps only what you changed in the final values.", - "chart": "Chart defaults", - "chartHint": "The chart's values.yaml", - "swiss": "Overrides", - "swissHint": "Everything this deploy sets, its layers merged; null deletes a chart default", - "edit": "edit.yaml", - "editHint": "The overrides as stored: applied over everything this deploy sets. Delete a line to drop that override; null deletes a chart default.", - "final": "Final values", - "finalHint": "Edit here; a key you delete becomes null", - "changes": "{n, plural, =0 {No changes} one {# change} other {# changes}}", - "notLoaded": "Load chart values to see them.", "search": "Search keys", "noKey": "No key matches" }, diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index 9da1ae8..65c79c6 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -602,8 +602,7 @@ "fracRange": "必须大于 0 且不超过 1,否则 openresty 会忽略", "serviceMonitor": "ServiceMonitor", "serviceMonitorHint": "随 release 一起采集 Prometheus 指标", - "edits": "覆盖 YAML", - "editsHint": "任意 values key,在所有层之后生效,不受层归属约束;在计划里显示为单独的 edit 层", + "edits": "配置 YAML", "cache": "编译缓存", "cacheHint": "把编译好的内核留在节点上,Pod 重启时不必再花 20–40 分钟重新编译。留空的字段沿用站点配置,否则用 chart 默认值。关闭时不写入任何值,站点开启的缓存仍会生效:如需强制关闭,在覆盖 YAML 中设置 cache.enabled: false", "cacheNone": "{chart} chart 不支持", @@ -636,22 +635,22 @@ "format": "只能用小写字母、数字和 -,以字母开头、字母或数字结尾(会用来命名 Kubernetes Service,不能有点)", "length": "最多 53 个字符" }, + "draft": { + "editYaml": "编辑 YAML", + "baseline": "部署基线", + "user": "用户配置", + "effective": "最终生效配置", + "unavailable": "配置暂不可用", + "description": "编辑用户配置,同时查看部署基线和最终生效值。", + "refresh": "刷新预览", + "current": "当前运行配置", + "refreshRevision": "更新运行版本", + "changed": "部署或配置已变化,请更新运行版本后重新预览。", + "invalidNumber": "请为{field}填写有效数字。", + "managed": "{field}:配置为 {requested},最终为 {actual}({source})" + }, "overrides": { "invalid": "YAML 格式有误", - "description": "在本次部署所有设置之上生效的 values。可加载 chart 的 values,在合并后的结果上编辑。", - "load": "加载 chart values", - "loading": "加载中…", - "layersDescription": "部署前不会保存任何内容。完成时只保留你在最终 values 里改动的部分。", - "chart": "Chart 默认值", - "chartHint": "chart 自带的 values.yaml", - "swiss": "覆盖值", - "swissHint": "本次部署设置的全部值,各层已合并;null 表示删除 chart 默认值", - "edit": "edit.yaml", - "editHint": "保存下来的覆盖值,在本次部署的所有设置之上生效。删掉一行即去掉该覆盖;null 表示删除 chart 默认值。", - "final": "最终 values", - "finalHint": "在这里编辑;删除的 key 会记为 null", - "changes": "{n, plural, =0 {没有改动} other {# 处改动}}", - "notLoaded": "加载 chart values 后可查看。", "search": "搜索 key", "noKey": "没有匹配的 key" }, diff --git a/web/src/modules/inferences/override-yaml.test.ts b/web/src/modules/inferences/override-yaml.test.ts index 0bbd1dc..68f64e8 100644 --- a/web/src/modules/inferences/override-yaml.test.ts +++ b/web/src/modules/inferences/override-yaml.test.ts @@ -26,6 +26,11 @@ extra: `; describe("override YAML", () => { + it("rejects scalar null and integers that would silently lose precision", () => { + expect(parseValues("# empty document\n")).toEqual({}); + expect(() => parseValues("null")).toThrow("mapping"); + expect(() => parseValues("replicaCount: 9007199254740993")).toThrow("integer range"); + }); it("writes the overrides into the chart's values.yaml, its comments kept", () => { const computed = computedYaml(defaults, overrides); expect(computed).toContain("# Engine replicas.\nreplicaCount: 2\n"); diff --git a/web/src/modules/inferences/override-yaml.ts b/web/src/modules/inferences/override-yaml.ts index 781ee63..da14bce 100644 --- a/web/src/modules/inferences/override-yaml.ts +++ b/web/src/modules/inferences/override-yaml.ts @@ -14,8 +14,12 @@ function documentValues(document: Document): Values { 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 }); - if (values === null || values === undefined) return {}; + if (values === null || values === undefined) { + if (document.contents !== null) throw new Error("YAML must contain a mapping of values."); + return {}; + } JSON.stringify(values, (_key, value: unknown) => { + if (typeof value === "number" && Number.isInteger(value) && !Number.isSafeInteger(value)) throw new Error("YAML integers must be within the exact JavaScript integer range."); 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; diff --git a/web/src/modules/inferences/plan-composition.ts b/web/src/modules/inferences/plan-composition.ts index 8c3b774..fca29bf 100644 --- a/web/src/modules/inferences/plan-composition.ts +++ b/web/src/modules/inferences/plan-composition.ts @@ -16,6 +16,10 @@ function canonical(value: unknown): string { return JSON.stringify(value) ?? "undefined"; } +export function sameValues(left: unknown, right: unknown): boolean { + return canonical(left) === canonical(right); +} + export function samePlanBaseline(before: Plan, after: Plan, normalize: (layers: Plan["layers"]) => void): boolean { const snapshot = (plan: Plan) => { const layers = structuredClone(plan.layers ?? {}); diff --git a/web/src/modules/inferences/use-deployment-draft.test.ts b/web/src/modules/inferences/use-deployment-draft.test.ts new file mode 100644 index 0000000..8a183c8 --- /dev/null +++ b/web/src/modules/inferences/use-deployment-draft.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "vitest"; +import { initialDraft, changeForm, projection, changeRecovery } from "./use-deployment-draft"; +import { EMPTY } from "./deployment-settings"; +import { parseValues } from "./override-yaml"; +import { recoverySettings } from "./recovery"; + +it("commits all pending fields after correcting an invalid number", () => { + let draft = initialDraft({ ...EMPTY, scaler: false, image: "registry/old:v1", replicaCount: "2" }); + draft = changeForm(draft, { replicaCount: "bad" }, {}); + draft = changeForm(draft, { image: "registry/new:v2" }, {}); + draft = changeForm(draft, { replicaCount: "3" }, {}); + expect(parseValues(draft.text)).toMatchObject({ replicaCount: 3, image: { repository: "registry/new", tag: "v2" } }); + expect(projection(draft, {}).image).toBe("registry/new:v2"); +}); + +it("preserves unknown recovery config when disabling the feature", () => { + const values = { continuationGateway: { enabled: true, config: { continuationModel: "weights", custom: true } } }; + let draft = initialDraft(EMPTY, values); + draft = changeRecovery(draft, { enabled: false }, {}); + expect(parseValues(draft.text)).toMatchObject({ continuationGateway: { enabled: false, config: { continuationModel: "weights", custom: true } } }); + expect(recoverySettings(parseValues(draft.text)).enabled).toBe(false); + draft = changeRecovery(draft, { enabled: true }, {}); + expect(recoverySettings(parseValues(draft.text)).model).toBe("weights"); +}); + +it("explicitly disables a cache inherited from the target baseline", () => { + const baseline = { cache: { enabled: true, hostPath: "/models/cache" } }; + let draft = initialDraft({ ...EMPTY, cache: true }, {}); + draft = changeForm(draft, { cache: false }, baseline); + expect(parseValues(draft.text)).toMatchObject({ cache: { enabled: false } }); + expect(projection(draft, baseline).cache).toBe(false); +}); + +it("keeps cache settings across an off/on toggle", () => { + const values = { cache: { enabled: true, hostPath: "/models/custom", historyLimit: 8, custom: "keep" } }; + let draft = initialDraft(EMPTY, values); + draft = changeForm(draft, { cache: false }, {}); + expect(parseValues(draft.text)).toEqual({ cache: { ...values.cache, enabled: false } }); + draft = changeForm(draft, { cache: true }, {}); + expect(parseValues(draft.text)).toEqual(values); +}); + +it("keeps hidden scaling, routing and monitoring values across switch changes", () => { + const values = { + replicaCount: 2, + scaler: { enabled: true, minReplicas: 1, maxReplicas: 4 }, + cart: { enabled: true }, + modelRoute: { cart: { maxLoad: 8 }, monitor: { enabled: true, model: "weights" } }, + }; + let draft = initialDraft(EMPTY, values); + draft = changeForm(draft, { scaler: false, cart: false, monitor: false }, {}); + expect(parseValues(draft.text)).toMatchObject({ + replicaCount: 2, + scaler: { enabled: false, minReplicas: 1, maxReplicas: 4 }, + cart: { enabled: false }, + modelRoute: { cart: { maxLoad: 8 }, monitor: { enabled: false, model: "weights" } }, + }); + draft = changeForm(draft, { scaler: true, cart: true, monitor: true }, {}); + const { replicaCount: _fixed, ...scaling } = values; + expect(parseValues(draft.text)).toEqual(scaling); +}); + +it("keeps a model path bound to the YAML value on upgrades", () => { + let draft = initialDraft({ ...EMPTY, localPath: "/models/old" }, { model: { localPath: "/models/old", custom: true } }); + draft = changeForm(draft, { localPath: "/models/new" }, {}); + expect(parseValues(draft.text)).toEqual({ model: { localPath: "/models/new", custom: true } }); + expect(projection(draft, {}).localPath).toBe("/models/new"); +}); + + +it("retains saved form settings when a partial YAML document omits them", () => { + const stored = { replicaCount: 2, image: { repository: "registry/engine", tag: "saved" }, continuationGateway: { enabled: false, config: { continuationModel: "weights" } }, custom: true }; + let draft = initialDraft(EMPTY, stored); + draft = changeForm(draft, { edits: "image:\n tag: new\n" }, stored); + expect(projection(draft, stored)).toMatchObject({ replicaCount: "2", image: "registry/engine:new" }); + draft = changeForm(draft, { replicaCount: "3" }, stored); + expect(parseValues(draft.text)).toEqual({ image: { tag: "new" }, replicaCount: 3 }); + expect(projection(draft, stored).replicaCount).toBe("3"); +}); diff --git a/web/src/modules/inferences/use-deployment-draft.ts b/web/src/modules/inferences/use-deployment-draft.ts new file mode 100644 index 0000000..54c41de --- /dev/null +++ b/web/src/modules/inferences/use-deployment-draft.ts @@ -0,0 +1,82 @@ +import { useEffect, useMemo, useState, type SetStateAction } from "react"; +import type { Form } from "@swiss/components/DeploySettings"; +import type { GPUSpec } from "@swiss/lib/api"; +import { applyResolvedGpu, configuredGpuProducts } from "./deployment-gpu"; +import { draftControls, draftForm, editDraftForm, editDraftValues, seedDraft } from "./deployment-draft"; +import { mergeValues, overrideEdit, readYaml, type Values } from "./override-yaml"; +import { recoveryInputError, recoverySettings, withRecovery, type RecoverySettings } from "./recovery"; + +export interface Draft { + controls: Form; + text: string; + fields: Partial; + recoveryFields: Partial; + initialText: string; + initialControls: Form; +} + +export function initialDraft(controls: Form, explicit?: Values): Draft { + const text = seedDraft(controls, explicit); + return { controls, text, initialText: text, initialControls: controls, fields: {}, recoveryFields: {} }; +} + +export function projection(draft: Draft, baseline: Values, specifications: GPUSpec[] = []): Form { + const form = draftForm(draft.controls, draft.text, baseline); + return { ...form, gpuProducts: configuredGpuProducts(mergeValues(baseline, parseValuesForGpu(draft.text)), specifications, form.gpuProducts), ...draft.fields, edits: draft.text }; +} + +function parseValuesForGpu(text: string): Values { return readYaml(text).values ?? {}; } + +export function changeForm(draft: Draft, update: SetStateAction | Partial, baseline: Values): Draft { + const valid = readYaml(draft.text).values; + const current = valid ? projection(draft, baseline) : { ...draft.controls, edits: draft.text }; + const patch = typeof update === "function" ? update(current) : update; + if (patch.edits !== undefined && patch.edits !== draft.text) return { ...draft, text: patch.edits, fields: {}, recoveryFields: {} }; + if (!valid) return draft; + const changed = Object.fromEntries(Object.entries(patch).filter(([key, value]) => value !== current[key as keyof Form])); + const fields = { ...draft.fields, ...changed }; + delete fields.edits; + return { ...draft, controls: draftControls(draft.controls, patch), text: invalidNumber(fields) ? draft.text : editDraftForm(draft.text, draftForm(draft.controls, draft.text, baseline), fields), fields }; +} + +const NUMBERS = ["replicaCount", "scalerMin", "scalerMax", "backendMaxConcurrency", "cartMaxLoad", "ttftLimitMs", "tpsLimitTps", "adaptiveCcMinFrac", "cacheHistoryLimit", "cacheMaxSlots"] as const; + +function invalidNumber(fields: Partial) { + return NUMBERS.find((key) => fields[key]?.trim() && !Number.isFinite(Number(fields[key]))); +} + +export function changeRecovery(draft: Draft, patch: Partial, baseline: Values): Draft { + const values = readYaml(draft.text).values; + if (!values) return draft; + if (patch.enabled === false) return { + ...draft, + text: editDraftValues(draft.text, { continuationGateway: { enabled: false } }), + recoveryFields: { ...draft.recoveryFields, ...patch }, + }; + const before = recoverySettings(mergeValues(baseline, values)); + const next = { ...before, ...draft.recoveryFields, ...patch }; + if (next.idleTimeout.trim() && !Number.isFinite(Number(next.idleTimeout))) return { ...draft, recoveryFields: { ...draft.recoveryFields, ...patch } }; + const changes = overrideEdit(withRecovery({}, before).overrides ?? {}, {}, withRecovery({}, next).overrides ?? {}).edits; + return { ...draft, text: editDraftValues(draft.text, changes, withRecovery({}, before).overrides ?? {}), recoveryFields: { ...draft.recoveryFields, ...patch } }; +} + +export function useDeploymentDraft(initial: Form, explicit?: Values, baseline: Values = {}, specifications: GPUSpec[] = []) { + const [draft, setDraft] = useState(() => initialDraft(initial, explicit)); + useEffect(() => setDraft(initialDraft(initial, explicit)), [initial, explicit]); + const read = useMemo(() => readYaml(draft.text), [draft.text]); + const form = read.values ? projection(draft, baseline, specifications) : { ...draft.controls, edits: draft.text }; + const recovery = { ...recoverySettings(mergeValues(baseline, read.values ?? {})), ...draft.recoveryFields }; + return { + form, recovery, error: read.error, invalidField: invalidNumber(draft.fields), + values: read.values, + dirty: draft.text !== draft.initialText || JSON.stringify(draft.controls) !== JSON.stringify(draft.initialControls) || !!invalidNumber(draft.fields) || !!recoveryInputError(recovery), + reseedForm: (update: SetStateAction) => setDraft((previous) => { + const pristine = previous.text === previous.initialText && Object.keys(previous.fields).length === 0; + const next = changeForm(previous, update, baseline); + return pristine ? { ...next, initialText: next.text, fields: {} } : next; + }), + setForm: (update: SetStateAction | Partial) => setDraft((previous) => changeForm(previous, update, baseline)), + setResolvedGpu: (changes: Values) => setDraft((previous) => ({ ...previous, text: applyResolvedGpu(previous.text, changes) })), + setRecovery: (patch: Partial) => setDraft((previous) => changeRecovery(previous, patch, baseline)), + }; +} diff --git a/web/src/modules/inferences/use-deployment-values.ts b/web/src/modules/inferences/use-deployment-values.ts new file mode 100644 index 0000000..434402b --- /dev/null +++ b/web/src/modules/inferences/use-deployment-values.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { deployApi, type Plan, type PlanRequest } from "@swiss/lib/api"; +import { composeDeploymentDraft, deploymentValuesRequest, deploymentBaselineRequest, deploymentPolicy, DeploymentChangedError, type DeploymentSettings } from "./deployment-preview"; +import { computedYaml, parseValues, type Values } from "./override-yaml"; +import { samePlanBaseline } from "./plan-composition"; + +export interface ConfigurationPreview { + baseline: string; + effective: string; + plan: Plan; + policy?: Values; +} + +export interface HelmValuesSource { + key: string; + data?: ConfigurationPreview; + loading: boolean; + error?: Error | null; + load: () => Promise; + onOpenChange: (open: boolean) => void; +} + +async function baselineFor(request: PlanRequest, catalog?: string) { + const plan = await composeDeploymentDraft(deploymentBaselineRequest(request), {}, deployApi.plan, catalog); + const values = await deployApi.helmValues(deploymentValuesRequest(deploymentBaselineRequest(request), plan, catalog)); + return { plan, text: computedYaml(values.defaults, values.overrides) }; +} + +function sameInputs(left: Plan, right: Plan): boolean { + return samePlanBaseline(left, right, (layers) => { + for (const key of ["form", "edit", "derived", "autoscale", "canary-system"]) delete layers[key]; + }); +} + +export function useDeploymentValues(request: PlanRequest | undefined, text: string, catalog: string | undefined, context: string, settings: DeploymentSettings) { + const client = useQueryClient(); + const [open, setOpen] = useState(false); + const [settled, setSettled] = useState(text); + useEffect(() => { const timer = setTimeout(() => setSettled(text), 250); return () => clearTimeout(timer); }, [text]); + const metadata = request && { ...request, editsYAML: undefined }; + const key = JSON.stringify([metadata, text, catalog, context]); + const query = useQuery({ + queryKey: ["configuration-preview", metadata, settled, catalog, context, settings], + queryFn: async (): Promise => { + const values = parseValues(settled); + let submitted: PlanRequest = metadata!; + const plan = await composeDeploymentDraft(metadata!, values, (input) => { submitted = input; return deployApi.plan(input); }, catalog, settings); + const baselineRequest = deploymentBaselineRequest(deploymentValuesRequest(submitted, plan, catalog)); + const baseline = await client.fetchQuery({ queryKey: ["configuration-baseline", baselineRequest, catalog, context], queryFn: () => baselineFor(baselineRequest, catalog), staleTime: 0 }); + if (!sameInputs(baseline.plan, plan)) throw new DeploymentChangedError(); + const actual = await deployApi.helmValues(deploymentValuesRequest(submitted, plan, catalog)); + return { baseline: baseline.text, effective: computedYaml(actual.defaults, actual.overrides), plan, policy: deploymentPolicy(metadata!) }; + }, + enabled: open && !!metadata && text === settled, + retry: false, + gcTime: 0, + }); + const current = text === settled; + return { + key, + data: current && !query.isError && !query.isFetching ? query.data : undefined, + loading: open && (!current || query.isFetching), + error: current ? query.error : undefined, + load: () => query.refetch(), + onOpenChange: setOpen, + } satisfies HelmValuesSource; +} diff --git a/web/src/modules/swiss/components/DeploySettings.tsx b/web/src/modules/swiss/components/DeploySettings.tsx index 3a6fcaf..9a7c6b4 100644 --- a/web/src/modules/swiss/components/DeploySettings.tsx +++ b/web/src/modules/swiss/components/DeploySettings.tsx @@ -1741,7 +1741,7 @@ export function applyFlags(f: Form): ApplyFlags | undefined { // The plan stores the composed overrides tree, not the form fields, so this is // the inverse of planRequest -- and the pair is why an upgrade can show the // settings rather than only promise it carried them forward. -export function formFromPlan(plan: Plan): Form { +export function formFromPlan(plan: Pick): Form { // The form's own document, not the composed result: an upgrade seeds from // what the operator set, so recomposing against a newer catalog keeps it. const o = (plan.layers?.form ?? {}) as Record; @@ -1817,7 +1817,7 @@ export function formFromPlan(plan: Plan): Form { }; } -function cacheOn(plan: Plan): boolean { +function cacheOn(plan: Pick): boolean { let on = false; for (const layer of layersOf(plan)) { const c = plan.layers[layer]?.cache; diff --git a/web/src/modules/swiss/components/Provenance.tsx b/web/src/modules/swiss/components/Provenance.tsx index 00d9924..f3a1a31 100644 --- a/web/src/modules/swiss/components/Provenance.tsx +++ b/web/src/modules/swiss/components/Provenance.tsx @@ -31,7 +31,7 @@ const WHAT = { // layersOf is every layer the plan carries: the known ones in merge order, then // any this console does not know, so none is hidden. -export function layersOf(plan: Plan): string[] { +export function layersOf(plan: Pick): string[] { const present = Object.keys(plan.layers ?? {}); const ordered: string[] = LAYERS.filter((l) => present.includes(l)); return [...ordered, ...present.filter((l) => !ordered.includes(l)).sort()]; diff --git a/web/test/README.md b/web/test/README.md new file mode 100644 index 0000000..ed00b89 --- /dev/null +++ b/web/test/README.md @@ -0,0 +1,26 @@ +# Deployment configuration browser fixture + +The fixture renders the production deploy/upgrade components and hooks against in-memory API responses. + +```sh +pnpm --dir web dev --host 127.0.0.1 --port 5174 +``` + +Open `http://127.0.0.1:5174/test/deployment-configuration.html?scenario=upgrade` to start directly in the three-pane YAML editor. Close it to inspect the synchronized form, then use **编辑 YAML** in the top toolbar to reopen it. Add `&view=form` to start with the complete form. All scenarios use the existing form/edit API without an advertised configuration capability. + +| Scenario | Coverage | +|---|---| +| `upgrade` | Stored user values, bidirectional controls/YAML, current revision comparison | +| `create` | Fresh deployment defaults and configuration preview | +| `autoscale` | User replica count and higher-priority effective value | +| `canary` | A running revision with experiment settings compared with the target plan returned by the existing service | +| `gpu` | Product aliases, multiple products, unverified product acceptance, retained affinity | +| `legacy` | Upgrade against the existing API with no capability field | + +Console helpers in `window.deploymentFixture`: + +- `advanceRevision()` simulates another deployment revision. +- `failPreview(true)` makes chart values unavailable; reset with `false` and refresh. +- `requests()` returns the recorded compose inputs. + +The fixture blocks unexpected network requests and uses mock install/apply functions. Its compose stub follows the existing contract: upgrades merge form values and replace submitted edit YAML. It does not validate live cluster behavior. diff --git a/web/test/deployment-configuration.html b/web/test/deployment-configuration.html new file mode 100644 index 0000000..75657bd --- /dev/null +++ b/web/test/deployment-configuration.html @@ -0,0 +1 @@ +Deployment configuration — local fixture
diff --git a/web/test/deployment-configuration.tsx b/web/test/deployment-configuration.tsx new file mode 100644 index 0000000..536b0a0 --- /dev/null +++ b/web/test/deployment-configuration.tsx @@ -0,0 +1,98 @@ +import { StrictMode, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ModuleProvider } from "@/shell"; +import { setLocale } from "@/shell/i18n"; +import { DeploySheet } from "@/modules/inferences/components/DeploySheet"; +import { api, deployApi, type Plan, type PlanRequest, type ModelResponse, type GPUSpec } from "@swiss/lib/api"; +import { mergeValues, parseValues, valuesYaml } from "@/modules/inferences/override-yaml"; +import "@/index.css"; + +const scenario = new URLSearchParams(location.search).get("scenario") ?? "upgrade"; +const initialView = new URLSearchParams(location.search).get("view") === "form" ? "form" : "yaml"; +const gpuSpecifications: GPUSpec[] = [{ vendor: "nvidia", resource: "example.com/gpu", labelKey: "example.com/model", products: [{ product: "H100", values: ["h100"] }, { product: "H800", values: ["h800"] }, { product: "L40", values: ["l40"] }] }]; +const gpuAffinity = (product: string | string[]) => ({ nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [{ key: "example.com/model", operator: "In", values: Array.isArray(product) ? product : [product] }] }] }, preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 10, preference: { matchExpressions: [{ key: "region", operator: "In", values: ["local"] }] } }] } }); +const defaults = { replicaCount: 1, image: { repository: "registry/engine", tag: "target" }, scaler: { enabled: false }, cart: { enabled: true }, modelRoute: { enabled: true }, cache: { enabled: false } }; +let revision = 7; +let failPreview = false; +let planCount = 0; +const requests: PlanRequest[] = []; +const saved: Plan = { + apiVersion: "plan.swiss/v1", release: { name: "example", namespace: "models" }, + source: { catalogName: "local", model: "weights", variant: "default", version: "1", hf: "fixture/weights", schema: "v2" }, + chart: { name: "sglang", version: "0.9.32" }, engine: "sglang", profile: "local", hash: "saved", + layers: { + catalog: { image: { repository: "registry/engine", tag: "target" } }, + site: { customSite: "keep" }, + form: { serviceId: "example", ...(scenario === "gpu" ? { affinity: gpuAffinity("h100") } : {}), ...(scenario === "autoscale" ? { pd: { enabled: true, colocate: false }, sloRequirement: { enabled: true } } : {}), replicaCount: 2, scaler: { enabled: false }, cart: { enabled: true }, modelRoute: { enabled: true }, custom: { keep: "unmapped" } }, + edit: { image: { tag: "saved" } }, + ...(scenario === "gpu" ? { derived: { model: { gpuResource: "example.com/gpu" } } } : {}), + ...(scenario === "canary" ? { "canary-system": { scaler: { enabled: false } } } : {}), + ...(scenario === "autoscale" ? { autoscale: { replicaCount: 4 } } : {}), + }, +}; +const model: ModelResponse = { + ref: "local", schema: "v2", + model: { name: "weights", displayName: "Example", source: { hf: "fixture/weights" }, defaultVariant: "default", variants: [{ id: "default", version: "1", digest: "fixture", engine: "sglang", chart: saved.chart, requires: { gpus: scenario === "gpu" ? 1 : 0, gpuProduct: scenario === "gpu" ? ["H100", "H800"] : undefined } }] }, + variant: { id: "default", version: "1", digest: "fixture", engine: "sglang", servedName: "weights", chart: saved.chart, requires: { gpus: scenario === "gpu" ? 1 : 0, gpuProduct: scenario === "gpu" ? ["H100", "H800"] : undefined }, values: defaults }, +}; + +function compose(request: PlanRequest): Plan { + requests.push(structuredClone(request)); + if (request.gpuProducts?.includes("L40") && !request.forceGpuProducts) throw new Error("Fixture: unverified GPU selection was not accepted explicitly"); + const hasForm = Object.keys(request.overrides ?? {}).length > 0; + const form = mergeValues(request.fromRelease ? saved.layers.form : {}, request.overrides ?? {}); + if (request.serviceId) form.serviceId = request.serviceId; + if (request.localPath) form.model = mergeValues((form.model as Record) ?? {}, { localPath: request.localPath }); + if (request.gpuProducts?.length) form.affinity = mergeValues((form.affinity as Record) ?? {}, gpuAffinity(request.gpuProducts.map((product) => product.toLowerCase()))); + if ((form.scaler as { enabled?: boolean })?.enabled) delete form.replicaCount; + const layers: Plan["layers"] = { + catalog: saved.layers.catalog, site: saved.layers.site, form, + edit: request.fromRelease && !hasForm ? saved.layers.edit : parseValues(request.editsYAML ?? ""), + ...(scenario === "gpu" ? { derived: { model: { gpuResource: request.gpuResource || "example.com/gpu" }, ...(!request.gpuProducts?.length ? { affinity: gpuAffinity(["h100", "h800"]) } : {}) } } : {}), + }; + const pd = (mergeValues(form, layers.edit).pd as { enabled?: boolean; colocate?: boolean }); + if (request.fromRelease && scenario === "autoscale" && pd?.enabled && !pd.colocate && (form.sloRequirement as { enabled?: boolean })?.enabled) layers.autoscale = saved.layers.autoscale; + + return { ...saved, layers, hash: `fixture-${++planCount}`, release: { name: request.release || "example", namespace: request.namespace || "models" } }; +} + +api.cluster = async () => ({ name: "local", profile: "local", catalog: "local", catalogs: [{ name: "local", source: "local", url: "local" }], namespace: "models", version: "fixture", allowDeploy: true }); +api.nodes = async () => ({ cluster: "local", summary: { nodes: 2, gpus: 16, gpusUsed: 0 }, nodes: scenario === "gpu" ? ["H100", "H800", "L40"].map((product) => ({ Name: product, GPUProduct: product, GPUResource: "example.com/gpu", GPUs: 8, Schedulable: true, Ready: true, gpusFree: 8, gpusUsed: 0 })) : [] }); +api.profile = async () => ({ profile: { defaults: { features: { scaler: false, cart: true } }, nodes: { gpus: gpuSpecifications } } }) as Awaited>; +api.releasePlan = async () => structuredClone(saved); +api.catalog = async () => ({ name: "local", ref: "local", source: "local", schema: "v2", models: [model.model] }); +api.model = async () => structuredClone(model); +api.history = async () => ({ ref: "local", schema: "v2", model: "weights", variant: "default", revisions: [{ version: "1", digest: "fixture" }] }); +api.status = async () => ({ exists: true, revision, backend: "helm" }) as Awaited>; +api.revisionValues = async () => ({ namespace: "models", release: "example", revision, all: "replicaCount: 2\nimage:\n tag: running\n", supplied: "replicaCount: 2\n" }); +deployApi.plan = async (request) => compose(request); +deployApi.helmValues = async (request) => { + if (failPreview) throw new Error("Fixture: chart values unavailable"); + const plan = compose(request); + const merged = Object.values(plan.layers).reduce((values, layer) => mergeValues(values, layer), {}); + return { defaults: valuesYaml(defaults), overrides: valuesYaml(merged) }; +}; +deployApi.diff = async (request) => ({ planHash: "planHash" in request ? request.planHash : "", changed: true, output: "+ replicaCount: edited\n", revision, exists: true }); +deployApi.apply = async () => { throw new Error("Fixture never applies deployments"); }; +deployApi.install = async () => { throw new Error("Fixture never installs deployments"); }; +window.fetch = async (input) => { + if (String(input).includes("/api/llm/route-options")) return new Response(null, { status: 404 }); + throw new Error(`Fixture blocked unexpected network access: ${String(input)}`); +}; +Object.assign(window, { + deploymentFixture: { + advanceRevision: () => ++revision, + failPreview: (fail: boolean) => { failPreview = fail; }, + requests: () => structuredClone(requests), + }, +}); +setLocale("zh-CN"); + +function Fixture() { + const [open, setOpen] = useState(true); + return

Local deployment configuration fixture

setOpen(false)} />
; +} + +createRoot(document.getElementById("root")!).render(); diff --git a/web/tsconfig.json b/web/tsconfig.json index 85898ff..c14c9c3 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -24,5 +24,5 @@ "@swiss/*": ["./src/modules/swiss/*"] } }, - "include": ["src", "vite.config.ts", "tailwind.config.ts"] + "include": ["src", "test", "vite.config.ts", "tailwind.config.ts"] }