This is the heaviest phase. Three concerns: filter (top bar), assignment (canvas multi-select), rendering (cluster +
bubbles).
3a. Top toolbar — web/src/pages/TopologyView.tsx
Add a thin toolbar above the canvas:
[ Group: All ▾ ] [ + New group ] [ 🔍 search hostname ]
- Group: [All ▾] — dropdown sourced from getGroups(). Selecting a group filters the canvas: nodes outside the group
are hidden (filtered out of the simulation entirely so they don't tug from off-screen). Default = "All". Special
entries: All (no filter), Ungrouped (group_name IS NULL).
- + New group — opens a tiny inline input ("Group name…" + Enter to confirm, Esc to cancel). Creates an empty group
locally — it appears in the dropdown; it persists once a machine is assigned to it (see "implicit creation" in
Phase 2).
- 🔍 search — fuzzy substring match on hostname. Matched nodes pulse / non-matches dim.
This is the primary "too many GPUs" mitigation: scope the canvas, don't try to render 80 nodes at once.
3b. Canvas multi-select — TopologyView.tsx
Extend the existing mouse handlers (onMouseDown ~line 544, onMouseMove ~line 568):
- Shift+click on node → toggle membership in selectedIds: Set<string>. Selected nodes get a thicker ring (extend
drawGpuNode).
- Drag on empty canvas with no modifier currently pans. Change: drag on empty canvas with Shift held = lasso. Render
a translucent rect; on mouse up, intersect with node positions → bulk-add to selectedIds. Plain drag-on-empty still
pans (preserves current muscle memory).
- Esc / click empty (no shift) → clear selection.
- Disambiguation rule: if mouseDown hits a node → drag = move that node (current behaviour). If mouseDown is empty +
shift → lasso. If mouseDown is empty + no shift → pan. Single-node drag is unchanged so existing users notice
nothing.
3c. Floating action bar — new web/src/components/SelectionActionBar.tsx
Renders absolutely positioned at bottom-center of the topology viewport when selectedIds.size > 0:
[ 3 selected ] [ Assign group ▾ ] [ Ungroup ] [ × ]
- "Assign group ▾" → opens <GroupPicker> (the same component used in MachineDetail). On select → fire
updateMachineGroup() for each selected id in parallel (Promise.all); on completion clear selection + refetch.
- "Ungroup" → updateMachineGroup(id, null) for each.
- "×" → clear selection.
- Show error toast if any PATCH fails (preserve selection so user can retry).
3d. Right-click shortcut
Single-node oncontextmenu (the canvas already swallows mouse events at ~line 540) → render a tiny <GroupPicker>
popover at the cursor. Keyboard-friendly alternative for the one-off case. No selection model needed for this path —
it operates on the right-clicked node directly.
3e. Group rendering inside the canvas
Extend GNode interface (~line 24-38) to include group: string | null. Update the sim:
- Same-group attraction: add a soft pull between nodes sharing a group value. Cheap O(n²) pass: for each pair with
a.group === b.group && a.group !== null, apply attractive force k_group * (dist - GROUP_IDEAL) with GROUP_IDEAL = 110
(half the hub distance), k_group = 0.0008. Tune.
- Group bubbles: before drawing nodes, compute centroid + bounding radius for each group → draw a faint filled circle
(alpha ~0.08, same colour as the group's hash → consistent palette). Label the group name above the bubble. Skip
bubble drawing if the dropdown is filtered to a single group (redundant).
- Hash group name → HSL hue (hashCode(name) % 360, 60% sat, 50% light, 8% alpha) for stable per-group colours without
a colour picker.
3f. Refetch wiring
After any PATCH from the action bar / right-click / picker, call the existing getMachines() poller's load function
(extract it into a useCallback so it can be invoked imperatively).
---
Phase 3.5 — Fleet integration
Small additions, share work with Phase 3 components:
- web/src/pages/FleetOverview.tsx: add Group column between Hostname and GPU. Add a group filter chip next to the
existing status pills, sourced from getGroups(). Clicking a group name in the column also filters.
- The filter chip uses the same <GroupPicker> in read/select-only mode (passing onChange to update local filter state
instead of calling the API).
---
Phase 4 — Sidebar reshape + landing redirect
Final UX polish, ship last so each preceding phase can validate independently.
- web/src/App.tsx:108-115 reorder nav: Fleet → Topology → Activity (Fleet first since it's the workhorse).
- web/src/main.tsx:13-17 change index route: <Route index element={<Navigate to="/fleet" replace />} /> (was
/topology).
Phase 3 — Topology grouping UX