Skip to content

feat(web): an Images section for every image slot in the chart - #137

Merged
aceforeverd merged 8 commits into
modelsphere:mainfrom
huskcass:feat/image-slots
Nov 13, 2026
Merged

aceforeverd merged 8 commits into
modelsphere:mainfrom
huskcass:feat/image-slots

Conversation

@huskcass

@huskcass huskcass commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Part of modelsphere/modelsphere#57 (task 3, generic image override). Chart side: modelsphere/helm-charts#110. That PR makes the CART and continuation gateway image keys real defaults, so they show up here.

Before this change, the deploy form had a single engine image field. The swiss Deploy and Upgrade pages and the console deploy sheet now show an Images section with one row per image slot in the chart.

  • Discovery (swiss/lib/imageSlots.ts, discoverImageSlots) walks the merged values from the compose dry run (/api/v2/compose?dryRun=true&dumpHelmValues=true, which is chart defaults plus plan layers). A slot is either a map with a string repository (with its tag) or a string leaf named image. Slots under any section with enabled: false (pd, cart, hangWatcher, …) are hidden.
  • Rows show the values path, with the effective image as the placeholder.
  • Edits go to the form layer at the slot's path. A map slot gets <path>.repository and <path>.tag, and a string slot gets <path>. An empty row adds no override. Clearing a row on Upgrade drops that form-layer override, the same way clearing tolerations works.
  • Engine row keeps the site mirror / catalog original choice, now as a dropdown, on both Deploy and Upgrade.
  • Digests: splitImage splits off @sha256:… before looking for the tag, so quay.io/ascend/vllm-ascend:pinned@sha256:… keeps its digest on the tag, and host:5000/repo:tag keeps the port on the repository.

No swiss or model-catalog changes.

Checks: npm run typecheck, npm test (638 tests), npm run build, npm run build:swiss.

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers.

Edits land on the form layer at the slot path (map → repository/tag, string → the string), upgrades seed them from the release's form layer, and clearing a row now really drops the override (before, clearing the engine field on upgrade kept the old one). The @sha256 split fix is right.

Medium

  • Subchart images: dumpHelmValues is helm show values, which is the parent values.yaml only, so a dependency's own image (the vendored cart chart, or any future subchart) only shows up if the parent pins it. That is why helm-charts#110 has to copy the cart defaults. Coalescing dependency defaults in swiss ChartValues would make discovery complete and let #110 drop the copied pins. Fine as a follow-up.
  • Overdesign, the "set by " label: provenanceLayers rebuilds the catalog/site layers on the client (variant image, mirror), and LAYER_ORDER/layerOrder/layerFor, imageOverrideLayer and edit-YAML parsing exist only to name a layer. That repeats swiss's composition and the plan's existing Provenance view. Cut it: the effective value as the placeholder is enough. This removes about 100 lines plus their tests, and the useMemo in all three pages.
  • The engine is stored twice (form.image and form.images.image), and "" vs. absent encodes "cleared" vs. "untouched". formFromPlan already seeds every form-layer slot, so "cleared" can simply mean "in the release's form layer but empty in the form". Keep one map, and the imagePatch special case and the images.image check in dropClearedImages go away.

Low

  • useImageSlots keys on the whole request, so every keystroke anywhere (including in an image row) reruns compose plus helm show values. Key it on the request without image edits and edits. In DeploySheet, share the existing Override YAML helmValues query instead of adding a second one that also skips the recovery/adaptive wrapping.
  • The same wiring (slotsRef, requestOf, layers memo, hook) is repeated in Deploy, Upgrade and DeploySheet. So are ImageRows/ImageSource/imagePatch in DeployForm.tsx and DeploySettings.tsx. At least share imagePatch. Reading slotsRef.current at call time to pass kinds into planRequest is fragile, and storing the shaped value when a row is edited would remove imageSlots from planRequest.
  • metricsMock is a hard-coded chart key in the UI. Drop the special case.
  • Over-defensive: the try/catch around discovery of server YAML, sorting of unknown layer names, ancestor pruning in dropImage. dropImage and CHART_LAYER are exported only for tests.

Fine to merge once the label and the double engine store are trimmed. The subchart defaults can follow in swiss.

— Review Golden

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers.

All of the previous points are addressed. The layer label and provenanceLayers are gone. The engine now lives only in images["image"], and a clear means "in the release's form layer but empty in the form", which matches what formFromPlan seeds. imageSlots no longer goes through planRequest. Discovery keys on the request without image edits and edits, and in DeploySheet it uses the same recovery and adaptive wrap. metricsMock, the try/catch and the ancestor pruning are removed, and ImageSource/imagePatch are shared.

I checked for regressions and found none. Clearing still drops only repository/tag and keeps pullPolicy. Upgrades seed and send back the existing overrides, and a form-layer image the chart doesn't list still shows as a row. The mirror/catalog dropdown round-trips through imageText.

Low

  • ImageEdit.draft exists only so a half-typed map ref keeps its trailing colon, because rows store the split shape. Storing { kind, ref } text and splitting in applyImageEdits would remove draft, imageValue and the round-trip check. This is optional.
  • Clearing an extra row (a path the chart doesn't list) makes the row disappear as soon as it's empty, so select-all-and-retype loses the field. This is minor.

Fine to merge.

— Review Golden

huskcass and others added 2 commits November 13, 2026 11:11
The deploy form had one engine image field, so the hang-watcher, PD router,
CART, continuation gateway and mooncake master images could only be changed
through the YAML editor.

The swiss Deploy and Upgrade pages, and the console deploy sheet, now ask
swiss for the merged helm values (compose dry run with dumpHelmValues) and
list every image slot in them: a map with a string repository (plus its
tag), or a string leaf named image. Slots under a section with
enabled: false are hidden, and metricsMock is skipped. Each row shows the
values path, the effective image and the layer that set it; an edit is
written on the form layer at that path, and clearing a row on upgrade drops
that override.

The engine row keeps the site mirror / catalog original choice, now as a
dropdown. splitImage keeps an @sha256: digest with the tag, so
repo:pinned@sha256:... and host:port/repo refs split correctly.

Refs modelsphere/modelsphere#57

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>
- Drop the "set by <layer>" label and the client-side layer rebuild behind
  it; a row shows its values path, with the effective image as the
  placeholder.
- Keep the engine in the same images map as every other slot. On upgrade, a
  slot in the release's form layer that is empty in the form is cleared.
- Store the shaped value when a row is edited, so planRequest no longer needs
  slot kinds.
- Key the dry run on the request without image edits and edits YAML, still
  debounced, and move the shared Deploy/Upgrade/DeploySheet wiring into
  useImageSlots.
- Drop the metricsMock special case, the try/catch around discovery and the
  empty-parent pruning in dropImage.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>
The Images section recomposed on every form change, and the Override YAML
preview ran its own compose under another key, so a deploy made several
/api/v2/compose calls.

Both now share one query, keyed on the form without image edits or YAML
edits, and it runs only while the Images section (now collapsed by default)
or the YAML editor is open. Image edits and YAML overrides are applied on
top of the returned values. A deploy that opens neither makes no compose
call; opening both makes one per form state.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers.

Medium

  • The 400 ms debounce on the dry-run key is gone. While Images or the YAML editor is open, every keystroke in a form text field changes the key and fires its own /api/v2/compose, which works against what this commit is for. Debouncing the key in useImageSlots again fixes it.

Low (cuts)

  • reload rebuilds imageSlotsQueryOptions just to read enabled. Checking !!request is enough, and once it is, the throw new Error("nothing to compose") in queryFn can't be reached.
  • The hook returns the same failure twice, as error (string) and queryError (Error). Keep queryError and let ImageRows read .message.
  • yamlFromForm and yamlFromButton can be one yamlOpen state, since only one of the two dialogs can be open at a time.

Verdict: on-demand compose with one shared query is the right simplification. Restore the debounce and this is good to merge.

— Review Golden

Typing in the form while Images or the YAML editor was open composed on
every keystroke. The shared query key now waits 400ms for the request to
hold still; opening a section still fetches the current request at once.

Also: reload checks the request directly, the unreachable "nothing to
compose" branch is gone, the hook returns one error, and the deploy sheet
keeps a single open flag for its two YAML dialogs.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers.

Medium (cuts)

  • The debounce is now three render-time refs (wasEnabled, opening, settled) that change during render and hold a stale form/requestFor/prepare. Plain useDebounced(requestKey) covers it: build the query from the debounced form. Opening right after an edit then waits at most 400 ms, which is fine and doesn't need its own path.
  • useImageSlots.test.ts adds about 200 lines of hand-written fake DOM just to mount the hook. Once the refs are gone, the debounce is useDebounced, which is already in use elsewhere. Testing imageSlotsQueryOptions with the QueryObserver cases the file already has is enough, so the fake DOM and the two mount tests can go.

Low

  • posted as PlanRequest with the comment is fine. The three earlier Lows are addressed.

Verdict: the behaviour is right. Simplify the debounce and drop the fake-DOM test, then it's good to merge.

— Review Golden

Build the shared compose query from the debounced form and drop the refs
that fetched at once on open; opening a section now waits out the same
400ms. Trim useImageSlots.test.ts to the QueryObserver tests of
imageSlotsQueryOptions.

Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com>

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers.

The debounce is now plain useDebounced(form, 400), and the refs and fake-DOM test are gone. Nothing left to cut.

Verdict: good to merge.

— Review Golden

- site mirror applies on all detected images
- correct the /api/v2/compose call, never call it on every keystroke,
  manual trigger is much clean

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers.

Manual-trigger discovery (target-keyed key + refresh button) and mirror choices on every slot read fine. KISS cuts:

  • Medium: useImageSlots.ts latestRequestRef (assigned during render) plus the latestRequest param are unnecessary: useQuery takes the latest options each render, so queryFn: () => deployApi.helmValues(requestFor(previewForm(form))) with the target-only key already posts the current form on open/refresh. Drop the ref and the extra param (tests shrink accordingly).
  • Medium: DeploySheet.tsx valuesQuery is a useQuery with enabled: false, staleTime: Infinity, gcTime: 0 and a key that changes on every edit, used only via refetch(). It is just an async function; make valuesSource.load call compose + helmValues directly (or a useMutation) and drop the query plumbing.
  • Low: OverrideDialog draftRef + cancelled flag guard cases that don't matter (effect only reruns on source.data; setState after unmount is a no-op). Revert to the plain effect.
  • Low: ImageSource now has two inputs (choices or legacy image) and an optional labels.default fallback; every caller passes choices. Drop the image prop, the imageChoices fallback and imageChoices itself (slotImageChoices already covers the engine row).
  • Low: mirrorImage re-implements swissd Profile.MirrorImage on the client; fine for now, but keep the two in sync (or have the server return mirrored refs later).

Verdict: good to merge; the cuts above are optional simplifications.

— Review Golden

@huskcass huskcass left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers.

Previous cuts are addressed: latestRequestRef/latestRequest gone, valuesQuery replaced by a mutation, draftRef/cancelled reverted, ImageSource takes only choices and imageChoices is deleted, mirrorImage points at Profile.MirrorImage.

  • Low: valuesKey (JSON of the request, rebuilt every render) passed as an otherwise-unused mutation variable just to detect staleness is a little clever; fine as is, a short comment on why would help.

Verdict: good to merge.

— Review Golden

@aceforeverd
aceforeverd merged commit ab9cd4d into modelsphere:main Nov 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants