Remove the unreachable Workloads group drawer and group pin (#2723)

## Why

`useWorkloadsState` defaulted `groupNodeDrawerMode` to `'inline'`, and
only that mode wired group-header hover preview, click-to-pin group
scope and an inline `NodeDrawer` under the group row. The only page that
used the default, the standalone `/workloads` page, was retired in May.
Both production mounts have passed `'disabled'` since host drawers moved
to each page's own hosts table: Proxmox in 84f627e22a (2026-05-18, "Move
Proxmox host drawers to the host table") and vSphere in d96d5612fd
(2026-05-23). The disabling was deliberate on both pages, so this
removes the dead path instead of re-enabling it. The demand ledger has
no entry asking for workload group hover or pin.

The only way left to reach the group pin was a hand-typed
`?summaryGroup=` URL. No link builder passes it, and row expansion
stopped writing it in 86ac84f68e.

## What changes

- `WorkloadPanel`: drops the inline `NodeDrawer` mount, the group hover
and pin handlers, `aria-expanded`, `data-summary-group-series-count`,
`data-summary-row-active` on group rows, and the group-member state
passed to `GuestRow`. Group rows stay identity-only dividers and keep
`data-summary-group-id`, so clicking one still leaves an open guest
drawer alone (`summaryTableFocus` ignores that selector).
- `useWorkloadSelectionState`: drops the hovered and focused group
scope, `summaryGroup` hydration and the related effects. It no longer
passes group options to the shared bridge; Storage still uses them.
- `useWorkloadsState`: drops the option, `getNodeTemperatureThresholds`
(it only fed the removed drawer), and the `summaryGroupScopes` memo,
which rebuilt a scope for every group on every filtered-guest change.
- Removes `buildWorkloadSummaryGroupScope(Map)`,
`workloadsHasVisibleWorkloadGroupScope`, the `GuestRow` group-member
attribute and the workloads `summaryGroup` route param.
- Proxmox and vSphere stop passing the option. Their filter bars'
pinned-selection state now reads only the selected guest.
- `NodeDrawer` stays live in `ProxmoxNodesTable`. The temperature
guardrail now pins that live mount instead of the dead one.
- Contracts: rule 8 and the Workloads, Proxmox and vSphere clauses now
describe static dividers. Stale sentences about grouped node rows
opening the node drawer, group-row emphasis, and Workloads selection
writing back to the route are corrected.

## Verification

- Before/after browser pass on pulse-dev: embedded builds on mock demo
stacks, Proxmox and vSphere overviews, at 1440x900 and 390x844.
  - Hover, click and tap on group dividers do nothing on both builds.
- Guest drawer, Escape, guest-row hover emphasis, `?resource=` links and
the Proxmox nodes-table `NodeDrawer` behave the same on both builds.
- Clicking a divider with a guest drawer open keeps the drawer: 10/10 on
each build.
- The hand-typed `?summaryGroup=` URL pinned 18 Proxmox rows on main and
pins none now.
- Receipt: `frontend-modern/browser-verification/db4ee39cb5db006c.json`.
- Frontend typecheck passes. The targeted suites (Workloads, proxmox,
vmware, platformPage, shared, routing, utils, hooks, Storage) pass on
pulse-dev except one Proxmox backups windowing test that failed once at
load ~40 and then passed 2/2 alone.
- A full-suite run on the earlier base passed except files that also
fail on unmodified main or only under load. `useReportingPanelState`
fails on main there too.
- Commit hook battery and governance audits pass.
- Two rounds of second-opinion review. Round 1 caught a stale test pin
and stale perf-contract prose; round 2 caught one inaccurate
scroll-restore sentence. All are fixed here.

Follow-ups raised separately: the built-in `WorkloadsSurface` filter
toolbar is also unreachable, since both mounts pass
`suppressFilterToolbar`. The quarantined E2E spec 48 still targets the
retired `/workloads` page.
This commit is contained in:
rcourtman
2026-10-08 01:58:31 +01:00
committed by GitHub
30 changed files with 188 additions and 819 deletions
@@ -3034,7 +3034,7 @@ Agent`), with the plain-language source phrase available through accessible
than receiving an unfiltered copy of the estate. Opaque unified-resource ids
are not part of that visible search vocabulary and must not retain a node
when the normalized guest table has no corresponding match.
8. Keep summary interaction identity on one shared helper. Pages that expose row-hover, group-hover, or route-focus-driven summary emphasis, today the Workloads and Storage tables through `useSummaryPageInteractionState` in `frontend-modern/src/components/shared/summaryTableFocus.ts`, must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` rather than letting each surface read `hovered || focused` from its own page-local ID source. Hovering or pinning a storage pool-group header, or a workload group header on a surface that enables inline group drawers (the Proxmox and VMware surfaces pass `groupNodeDrawerMode="disabled"` today), must scope the matching rows through that same shared contract instead of forking a page-local summary filter path. No summary card or chart strip renders today, and the bridge carries no chart-hover input; one that returns must consume that same resolved scope, and the chart under the pointer must enter its hovered series into `resolveSummaryScopeState` as a preview input that outranks row hover instead of keeping a chart-local hover island. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision.
8. Keep summary interaction identity on one shared helper. Pages that expose row-hover, group-hover, or route-focus-driven summary emphasis, today the Workloads and Storage tables through `useSummaryPageInteractionState` in `frontend-modern/src/components/shared/summaryTableFocus.ts`, must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` rather than letting each surface read `hovered || focused` from its own page-local ID source. Hovering or pinning a storage pool-group header must scope the matching rows through that same shared contract instead of forking a page-local summary filter path. Workloads grouped host rows are static identity dividers and pass no group scope to the bridge; a grouped table that wants group hover or pin again needs a demand-ledger entry and must use this shared scope rather than reviving a Workloads-local path. No summary card or chart strip renders today, and the bridge carries no chart-hover input; one that returns must consume that same resolved scope, and the chart under the pointer must enter its hovered series into `resolveSummaryScopeState` as a preview input that outranks row hover instead of keeping a chart-local hover island. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision.
9. Keep any page summary page-scoped when table rows enter contextual focus. No infrastructure, workloads, or storage summary cards render today. If one returns, route-backed row selection may add a focused label and shared series emphasis, but the summary must continue to render the page-level series set instead of collapsing down to the selected row or replacing the global trend view with row-local empty states.
10. Keep contextual row focus on the shared primitives. Same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for local scroll preservation, off-screen reveal, inline-detail lookup, and deliberate inline-detail reveal, and must resolve the active series through `frontend-modern/src/components/shared/summaryCardInteraction.ts` behind `frontend-modern/src/components/shared/summaryTableFocus.ts`, instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface. `frontend-modern/src/components/shared/summaryTableFocus.ts` measures and scrolls rows only for deliberate focus, a focused row's inline detail or a pinned group header, so it must not keep window-level scroll or resize listeners or re-measure the hovered row on hover changes and scroll frames; an off-screen affordance for the active row needs a rendered consumer before that cost returns.
11. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer. Group-scoped preview and pin must use that same shared presentation boundary: child rows that belong to a hovered or pinned summary group should expose `data-summary-group-member-active="preview|pinned"` so the block-level emphasis stays subtle, consistent, and reversible instead of each table inventing its own outline, badge, or full-strength fill treatment. Static grouped row headers on workloads, infrastructure, storage, recovery, and future grouped tables must use `frontend-modern/src/components/shared/groupedTableRowPresentation.ts` plus the `.grouped-table-row` CSS contract in `frontend-modern/src/index.css`, rather than rebuilding local `bg-surface-alt` variants with subtly different light/dark behavior or page-local left-accent markers. That shared grouped-table primitive owns the subgroup cell padding, typography, small metadata, and badge treatment as well as the row background token, so a future adjustment to the subgroup visual language changes every grouped product table from one owner. Inline table detail rows on platform, workload, and infrastructure tables must compose `frontend-modern/src/components/shared/InlineDetailTableRow.tsx` for the full-width row, surface-alt cell, detail padding, and row-click containment instead of rebuilding page-local `TableRow` / `TableCell` / `div` shells around each drawer. Storage-backed reusable row presenters under `frontend-modern/src/features/storageBackups/` must also keep row height and alert accents on class/data-attribute presentation instead of runtime inline style maps, so the shared table contract stays CSP-safe on both steady-state and alert-highlighted routes.
@@ -1092,11 +1092,13 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
mounted, report errors out of band, and must not rely on Solid
`createResource` in a way that can bubble into the route-level Suspense
fallback or the setup/welcome shell.
Workload row drawer expansion, local row focus, and summary group pinning
are local interaction state. Inbound Workloads deep links may hydrate
`resource` and `summaryGroup` into the focused row or group, but opening,
closing, or clearing an inline drawer must not write those params back to
route state, schedule router navigation, or trip the app-shell fallback.
Workload row drawer expansion and local row focus are local interaction
state. Inbound Workloads deep links may hydrate `resource` into the focused
row, but opening, closing, or clearing an inline drawer must not write that
param back to route state, schedule router navigation, or trip the
app-shell fallback. Workloads has no group focus, so it reads no
`summaryGroup` param: row expansion stopped writing it and no link builder
passes one, so its hydration was removed with the group pin.
URL synchronization for Workloads stays limited to filter-owned scope in
`frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts`.
The Workloads page may show a bounded partial-inventory banner when a
@@ -1473,13 +1475,16 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
`TableCardHeader` inside both the populated and filtered-empty table card.
A platform page must not place a parallel floating title above its filters;
the title/count band belongs to the same framed table surface as the rows.
`WorkloadPanel` owns the mutually exclusive host/guest drawer handoff:
clicking a grouped host row while a guest drawer is open must clear the
selected guest and keep/focus the host summary group so the node drawer can
replace the guest drawer. Platform pages that render a dedicated host
table above an embedded Workloads table may disable the grouped host drawer
through the explicit Workloads surface option, but standalone Workloads
must keep the inline grouped-row drawer as the default.
`WorkloadPanel` renders only the guest drawer. Grouped host rows are
static dividers: no click handler, hover preview, group pin, or inline
node drawer. They keep `data-summary-group-id` so a click on a divider
does not clear an open guest drawer. Host details open from the platform
page's own hosts table (`ProxmoxNodesTable` mounts `NodeDrawer`;
`VsphereHostsTable` owns its detail row). The former
`groupNodeDrawerMode` option defaulted to an inline grouped-row drawer
that only the retired standalone Workloads page used; both production
mounts passed `disabled`, so the option, the inline `NodeDrawer` mount
and the Workloads group hover/pin scope were removed.
Compact icon headers inside `WorkloadTableHeader.tsx` may stay visually
dense for responsive workload tables, but the icon must be decorative and
the column label must remain present through an `sr-only` label so the
@@ -2003,7 +2008,10 @@ because both remaining production mounts (Proxmox and vSphere overview)
set it unconditionally, so the non-compact path no longer rendered
anywhere. A new embedded consumer that needs host
stats beside its workloads must render its own hosts table rather than
re-growing metric cells in the shared group row.
re-growing metric cells in the shared group row. The same applies to host
interaction: the group row has no drawer, hover preview, or pin, and the
Workloads state no longer builds per-group summary scopes for every
filtered-guest change.
WorkloadsSurface stays monitoring-first. It must not render a persistent
aggregate banner just because running VMs or system containers lack an
@@ -2066,7 +2074,7 @@ the hovered timestamp, so density does not remove point-in-time metric detail.
Proxmox node thermals follow that same drawer-only rule: temperature remains a
node-context metric, not a universal Workloads table column, and the node detail
drawer may add one compact `Thermals` history group beside utilization and I/O
only after a grouped node row is selected. Host-agent CPU temperature must be
only after a node's drawer is opened from the Proxmox nodes table. Host-agent CPU temperature must be
persisted into the same metrics-store history stream as other agent metrics,
with current node temperature displayed only as a drawer-local current reading
while history is still accumulating; it must not become a synthetic history
@@ -2106,7 +2114,7 @@ still reads nothing extra.
The Proxmox node drawer overview should follow the existing guest drawer
compact detail-section pattern and expose node-specific context such as platform,
kernel, hardware, raw capacity, telemetry, and thermal facts rather than
repeating the metric cells already visible in the grouped table row.
repeating the metric cells already visible in the Proxmox nodes table row.
Object drawers across workload, node, Docker, and unified-resource surfaces
must keep their default render bounded to active attention plus a small
additive context projection. Full provider-specific support workflows remain
@@ -2415,16 +2423,19 @@ nothing renders: the retired `Jump to row` visibility check did exactly that,
costing 128 `getComputedStyle` calls across eight row hovers and 320 across
ten scroll steps on the 12-node mock storage table, and none once removed.
That same hot-path contract also owns the row-emphasis paint. Workload guest
and group rows and storage pool, group, and disk rows must expose
rows and storage pool, group, and disk rows must expose
summary-linked activity through the shared `data-summary-row-active` marker and let the shared frontend
primitive render the emphasis, instead of layering lane-local row-fill classes
that diverge across pages or wash out inline metric bars.
`frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts` must
write workload selection back into the workloads route through the shared
same-path route-state scheduler, but the actual shell-position handoff for
query-only row focus must go through `frontend-modern/src/utils/appShellScrollRestoration.ts`
plus the root `frontend-modern/src/App.tsx` shell so opening a focused
workload does not look like a full page reload. The shared same-path scheduler must also own cleanup
`frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts` keeps
workload selection local and never writes it back into the route; an inbound
`resource` link only hydrates it. Local row focus keeps the scroll position
through `preserveScrollableAncestorVerticalOffset` in
`frontend-modern/src/components/shared/contextualFocus.ts` and reveals the
opened detail through `summaryTableFocus.ts`, so opening a focused workload
does not look like a full page reload. The root `frontend-modern/src/App.tsx`
shell applies a pending `frontend-modern/src/utils/appShellScrollRestoration.ts`
restore only when the route itself changes. The shared same-path scheduler must also own cleanup
for every deferred scroll-restore timeout and animation frame it creates, so
route-state cleanup cannot leave hot-path replay work running after the owning
surface unmounts.
@@ -2739,11 +2750,12 @@ can still lose the aggregate to a live agent while its own filesystem rows
remain; such a guest stays on the Proxmox rule. The agent's OS, addresses and
physical disk health carry no stale cue yet.
Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency
boundary for collapse: Workloads owns which inline row is selected and the
close handler, while
boundary for collapse: the owning table (Workloads for guests, the Proxmox
nodes table for nodes, the Docker hosts table for Docker hosts) owns which
inline row is selected and the close handler, while
`frontend-modern/src/components/shared/ObjectDrawerHeader.tsx` owns the
full-width semantic header button, phone target height, focus treatment, and
collapse chevron. `WorkloadPanel` must pass the grouped node close handler into
collapse chevron. `ProxmoxNodesTable` must pass its node close handler into
`NodeDrawer`, the Docker hosts table must pass its close handler into
`DockerHostDrawer`, and those shells must not fall back to a small icon-only
collapse target or wrap interactive header actions inside a local button.
@@ -980,10 +980,11 @@ states. This heading alignment changes neither workload backup scope nor the
storage/recovery evidence carried by the adjacent Backup column and Backups
tab.
The guest table's grouped node rows are identity-only dividers. Node stats and
the node drawer stay with the Proxmox nodes table above it, so `ProxmoxPageSurface`
passes `groupNodeDrawerMode: 'disabled'` and no host-metric option to the
embedded Workloads state. Those dividers carry no backup or recovery evidence;
the per-guest Backup column remains the overview's protection signal.
the node drawer stay with the Proxmox nodes table above it: the embedded
Workloads table has no grouped-row drawer, hover preview, or group pin, and
`ProxmoxPageSurface` passes no host-metric option to the embedded Workloads
state. Those dividers carry no backup or recovery evidence; the per-guest
Backup column remains the overview's protection signal.
The overview's node-row action selects the shared workload node scope and
reveals the guest section. The title names the selected node and reports the
filtered guest count, including zero. Activating the selected node again or
@@ -3060,10 +3060,11 @@ canonical WebSocket snapshot, including the public `vmware-vsphere` alias for
the raw `vmware` source, rather than degrading a source-scoped page to periodic
REST refreshes. VMware Overview passes that same source-scoped snapshot into
the embedded Workloads state, so hosts and VMs share one inventory generation
and one explicit refresh path. Beyond disabling the grouped host drawer, it
passes no option that adds host metrics to grouped rows: the shared Workloads
group row carries host identity only, and per-host stats stay in the page's
own hosts table.
and one explicit refresh path. It passes no option that adds host metrics to
grouped rows: the shared Workloads group row carries host identity only, with
no drawer, hover preview, or group pin, and per-host stats and details stay in
the page's own hosts table. The Workloads route model no longer parses a
`summaryGroup` param, because no group focus remains to hydrate.
Its page-owned workload toolbar consumes the complete shared
`getWorkloadsMetricFilterProps` binding, so vSphere VMs expose the same Bars,
Trends, Details, History, range, and first-use discovery contract as every
@@ -0,0 +1,71 @@
{
"version": 1,
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
"verified_at": "2026-10-07T22:40:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/workloadSelectionModel.ts",
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx",
"frontend-modern/src/routing/resourceLinks.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestRow.tsx": "b345738d55505aeffb16b20f104f8010008e9fcc73fafe084c7e75dbc8138e1a",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "a8b2e107f2143d01f3cb038d29526a9d38d35dcc6df5cdac076c02aa143b3e5f",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "e48d1be3998e7e3ece47d9eb01673872e74f0f46e0cc242142898af3f2eea80d",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "2742be27628c947aac825ca70af4f19b7518931d3629e6bf3b9068cc98eae19e",
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "e7bd98fc4a091ee66595b59d2407b7ea9cca9e52f1df2b7527ff41cfdecc99b9",
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "2c99c812cb19b6c31b376ec5db56c3ec16403d8556fbe5f9028fd9018e310915",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "5ac8410704ad9fbcf8237291432a30d16a31527b38fd34f0179eb57b90b92ca4",
"frontend-modern/src/components/Workloads/workloadSelectionModel.ts": "82b8f740e51630aba916d7ae617cd57213f4c910d17e843ea378384b63f9ffe9",
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "3b39341dc6e068db4bc84a5745f6a8508454b0019a84c7abfd8c6ee826d8ed93",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "f2ec48c3f9932d4e17410cda69c2cc4452c8d11fe3d6d120940cbe8ced40c6bd",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "561645e9b339738484a53a0c044721d69831ce6812d721d826233443efb0f0e1",
"frontend-modern/src/routing/resourceLinks.ts": "7b3a287fb80c7aa66aea6a40a3433662f99460888e734950ea3b455a04f3d53a"
},
"routes": [
"/proxmox/overview",
"/proxmox/overview?resource=Analytics%3Apve31%3A675",
"/proxmox/overview?summaryGroup=Analytics-pve31",
"/vmware/overview",
"/vmware/overview?summaryGroup=etl-batch-01-esxi-01.lab.local"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Embedded build of this exact tree (fingerprinted runtime files checked by sha256 on the test host) on a mock DEMO_MODE stack on pulse-dev; baseline is an embedded build of origin/main 36d3f8c557 run with the same probe script (group interaction code is unchanged on main since)",
"Proxmox overview grouped by node: 8 group dividers, static (no aria-expanded, cursor auto, no inline node drawer in the guests table), same as baseline",
"vSphere overview grouped by host: 18 group dividers in the current mock estate, static, same as baseline",
"Group-row attributes this change removes are absent: data-summary-group-series-count (baseline 18) and data-summary-row-active (baseline \"false\"); data-summary-group-id kept",
"Hand-typed ?summaryGroup=Analytics-pve31 on Proxmox: baseline pinned 18 member rows and highlighted the group row; now 0 rows pinned and no group highlight. vSphere ?summaryGroup= pinned 0 rows on both",
"Proxmox nodes table NodeDrawer still opens from its own row toggle (aria-expanded true, one inline node detail row)",
"?resource= deep link opens the matching guest drawer",
"Phone 390x844 Proxmox: tapping a group divider pins nothing and opens no node drawer; tapping a guest opens its drawer",
"No page errors at either width"
],
"interactions": [
"Hover the first group divider (right half, away from the node link): 0 preview member rows",
"Click the first group divider: 0 pinned rows, 0 inline node drawers",
"Open the first guest drawer, then click a group divider: drawer stays open 5/5 on Proxmox and 5/5 on vSphere (baseline the same); Escape closes it",
"Hover a guest row: exactly one guest row carries data-summary-row-active=true",
"Toggle the first Proxmox nodes table row disclosure: NodeDrawer opens, then closes",
"Load the ?summaryGroup= and ?resource= URLs above and count pinned rows and open drawers",
"Phone width: tap a group divider, then tap a guest row"
]
}
@@ -338,11 +338,6 @@ export function GuestRow(props: GuestRowProps) {
}
data-workload-alert-accent={alertAccentTone()}
data-summary-series-id={guestId()}
data-summary-group-member-active={
props.summaryGroupMemberState && props.summaryGroupMemberState !== 'default'
? props.summaryGroupMemberState
: undefined
}
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined}
on:mousedown={rowActionProps.onClick ? selectionGuard.onMouseDown : undefined}
@@ -5,26 +5,18 @@ import {
GROUPED_TABLE_ROW_BADGE_CLASS,
getGroupedTableRowCellClass,
getGroupedTableRowClass,
getInteractiveGroupedTableRowClass,
} from '@/components/shared/groupedTableRowPresentation';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { createSummaryInteractiveRowPreviewHandlers } from '@/components/shared/summaryInteractionA11y';
import { buildSummaryDisclosureControlsId } from '@/components/shared/summaryInteractionA11y';
import { TableBody, TableCell, TableRow } from '@/components/shared/Table';
import { getAlertsForResource, getAlertStyles } from '@/utils/alerts';
import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity';
import { isNodeOnline } from '@/utils/status';
import { getCanonicalWorkloadId, getWorkloadMetadataIdCandidates } from '@/utils/workloads';
import {
resolveSummaryGroupMemberInteractionState,
type SummarySeriesGroupScope,
} from '@/components/shared/summaryCardInteraction';
import { GuestDrawer } from './GuestDrawer';
import { GuestRow } from './GuestRow';
import { NodeDrawer } from './NodeDrawer';
import { buildWorkloadSummaryGroupScope } from './workloadSelectors';
import { getWorkloadGuestMetadataRecord } from './workloadGuestMetadataRecord';
import type { WorkloadsState } from './useWorkloadsState';
@@ -34,27 +26,19 @@ type WorkloadPanelProps = Pick<
| 'alertsEnabled'
| 'bottomSpacerHeight'
| 'getGroupLabel'
| 'getNodeTemperatureThresholds'
| 'groupedGuests'
| 'groupedWindowing'
| 'groupLabelBadges'
| 'guestMetadata'
| 'guestParentNodeMap'
| 'groupNodeDrawerMode'
| 'groupingMode'
| 'handleCustomUrlUpdate'
| 'handleTagClick'
| 'activeSummaryWorkloadGroupScope'
| 'activeSummaryWorkloadId'
| 'focusedSummaryWorkloadGroupScope'
| 'focusedSummaryWorkloadGroupId'
| 'hoveredSummaryWorkloadGroupScope'
| 'nestedWorkloadContextByGuestId'
| 'nodeByInstance'
| 'search'
| 'selectedGuestId'
| 'setFocusedWorkloadGroupScope'
| 'setHoveredWorkloadGroupScope'
| 'setHoveredWorkloadId'
| 'setSelectedGuestId'
| 'setTableBodyRef'
@@ -100,52 +84,12 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
);
const groupGuestIds = createMemo(() => groupGuests().map(getCanonicalWorkloadId));
const node = () => props.nodeByInstance()[groupKey];
const groupSummaryScope = createMemo<SummarySeriesGroupScope | null>(() => {
if (props.groupingMode() !== 'grouped') {
return null;
}
return buildWorkloadSummaryGroupScope(
groupKey,
fullGroupGuests(),
props.getGroupLabel(groupKey, fullGroupGuests()),
);
});
const isSummaryGroupHighlighted = createMemo(
() => props.activeSummaryWorkloadGroupScope()?.id === groupKey,
);
const shouldShowNodeDrawer = createMemo(
() =>
props.groupNodeDrawerMode() === 'inline' &&
Boolean(node()) &&
props.focusedSummaryWorkloadGroupId() === groupKey &&
props.selectedGuestId() === null,
);
const canOpenNodeDrawer = () => props.groupNodeDrawerMode() === 'inline';
const handleGroupHoverChange = (next: SummarySeriesGroupScope | null) => {
props.setHoveredWorkloadGroupScope(next);
};
const handleGroupFocusToggle = () => {
if (!canOpenNodeDrawer()) return;
const scope = groupSummaryScope();
const selectedGuestId = props.selectedGuestId();
const nextFocusedScope =
scope &&
props.focusedSummaryWorkloadGroupId() === scope.id &&
selectedGuestId === null
? null
: scope;
if (nextFocusedScope && selectedGuestId !== null) {
props.setSelectedGuestId(null);
}
props.setFocusedWorkloadGroupScope(nextFocusedScope);
};
const groupRowInteraction = createSummaryInteractiveRowPreviewHandlers({
onPreview: () => handleGroupHoverChange(groupSummaryScope()),
onPreviewClear: () => handleGroupHoverChange(null),
});
return (
<>
{/* Group rows are identity-only dividers: host details open from the
platform page's own hosts table. data-summary-group-id keeps a click
on a divider from clearing an open guest drawer. */}
<Show
when={
props.groupingMode() === 'grouped' && groupGuests()[0] === fullGroupGuests()[0]
@@ -154,20 +98,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
<Show
when={node()}
fallback={
<TableRow
class={
canOpenNodeDrawer()
? getInteractiveGroupedTableRowClass()
: getGroupedTableRowClass()
}
data-summary-group-id={groupKey}
data-summary-group-series-count={String(
groupSummaryScope()?.seriesIds.length ?? 0,
)}
data-summary-row-active={isSummaryGroupHighlighted() ? 'true' : 'false'}
onClick={canOpenNodeDrawer() ? handleGroupFocusToggle : undefined}
{...(canOpenNodeDrawer() ? groupRowInteraction : {})}
>
<TableRow class={getGroupedTableRowClass()} data-summary-group-id={groupKey}>
<TableCell
colspan={props.totalColumns()}
class={getGroupedTableRowCellClass()}
@@ -197,45 +128,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
node={node()!}
renderAs="tr"
colspan={props.totalColumns()}
trClass={
canOpenNodeDrawer()
? 'cursor-pointer select-none duration-150 [&>td>div]:flex-nowrap'
: 'select-none duration-150 [&>td>div]:flex-nowrap'
}
trProps={{
'aria-expanded': canOpenNodeDrawer()
? shouldShowNodeDrawer()
? 'true'
: 'false'
: undefined,
'data-summary-group-id': groupKey,
'data-summary-group-series-count': String(
groupSummaryScope()?.seriesIds.length ?? 0,
),
'data-summary-row-active': isSummaryGroupHighlighted() ? 'true' : 'false',
onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined,
...(canOpenNodeDrawer() ? groupRowInteraction : {}),
}}
trClass="select-none duration-150 [&>td>div]:flex-nowrap"
trProps={{ 'data-summary-group-id': groupKey }}
/>
</Show>
<Show when={shouldShowNodeDrawer()}>
<InlineDetailTableRow
colspan={props.totalColumns()}
data-inline-node-detail-for={groupKey}
>
<NodeDrawer
node={node()!}
onClose={() => props.setFocusedWorkloadGroupScope(null)}
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
alerts={getAlertsForResource(
[node()!.id],
props.activeAlerts,
props.alertsEnabled(),
node()!.name,
)}
/>
</InlineDetailTableRow>
</Show>
</Show>
<For each={groupGuestIds()} fallback={<></>}>
{(keyedGuestId) => {
@@ -284,11 +180,6 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
}
isExpanded={props.selectedGuestId() === guestId()}
isSummaryHighlighted={props.activeSummaryWorkloadId() === guestId()}
summaryGroupMemberState={resolveSummaryGroupMemberInteractionState({
seriesId: guestId(),
hoveredGroupScope: props.hoveredSummaryWorkloadGroupScope(),
focusedGroupScope: props.focusedSummaryWorkloadGroupScope(),
})}
ioEmphasis={props.workloadIOEmphasis()}
metricDisplayMode={props.workloadMetricDisplayMode()}
metricHoverMode={props.workloadMetricHoverMode()}
@@ -173,9 +173,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
props.memoryDisplayBasis ? state.workloadMemoryDisplayBasis : undefined
}
forcedPlatform={props.forcedPlatform}
pinnedSelectionActive={() =>
Boolean(state.selectedGuestId() || state.focusedSummaryWorkloadGroupId())
}
pinnedSelectionActive={() => Boolean(state.selectedGuestId())}
onClearPinnedSelection={state.clearPinnedSummaryScope}
/>
</div>
@@ -193,29 +191,21 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
alertsEnabled={state.alertsEnabled}
bottomSpacerHeight={state.bottomSpacerHeight}
getGroupLabel={state.getGroupLabel}
getNodeTemperatureThresholds={state.getNodeTemperatureThresholds}
groupedGuests={state.groupedGuests}
groupedWindowing={state.groupedWindowing}
groupLabelBadges={state.groupLabelBadges}
guestMetadata={state.guestMetadata}
guestParentNodeMap={state.guestParentNodeMap}
groupNodeDrawerMode={state.groupNodeDrawerMode}
groupingMode={state.groupingMode}
handleCustomUrlUpdate={state.handleCustomUrlUpdate}
handleSort={state.handleSort}
handleTagClick={state.handleTagClick}
activeSummaryWorkloadGroupScope={state.activeSummaryWorkloadGroupScope}
activeSummaryWorkloadId={state.activeSummaryWorkloadId}
focusedSummaryWorkloadGroupScope={state.focusedSummaryWorkloadGroupScope}
focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope}
isMobile={state.isMobile}
nestedWorkloadContextByGuestId={state.nestedWorkloadContextByGuestId}
nodeByInstance={state.nodeByInstance}
search={state.search}
selectedGuestId={state.selectedGuestId}
setFocusedWorkloadGroupScope={state.setFocusedWorkloadGroupScope}
setHoveredWorkloadGroupScope={state.setHoveredWorkloadGroupScope}
setHoveredWorkloadId={state.setHoveredWorkloadId}
setSelectedGuestId={state.setSelectedGuestId}
setTableRootRef={state.setTableRootRef}
@@ -18,29 +18,21 @@ type WorkloadsTableProps = Pick<
| 'alertsEnabled'
| 'bottomSpacerHeight'
| 'getGroupLabel'
| 'getNodeTemperatureThresholds'
| 'groupedGuests'
| 'groupedWindowing'
| 'groupLabelBadges'
| 'guestMetadata'
| 'guestParentNodeMap'
| 'groupNodeDrawerMode'
| 'groupingMode'
| 'handleCustomUrlUpdate'
| 'handleSort'
| 'handleTagClick'
| 'activeSummaryWorkloadGroupScope'
| 'activeSummaryWorkloadId'
| 'focusedSummaryWorkloadGroupScope'
| 'focusedSummaryWorkloadGroupId'
| 'hoveredSummaryWorkloadGroupScope'
| 'isMobile'
| 'nestedWorkloadContextByGuestId'
| 'nodeByInstance'
| 'search'
| 'selectedGuestId'
| 'setFocusedWorkloadGroupScope'
| 'setHoveredWorkloadGroupScope'
| 'setHoveredWorkloadId'
| 'setSelectedGuestId'
| 'setTableRootRef'
@@ -133,27 +125,19 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
alertsEnabled={props.alertsEnabled}
bottomSpacerHeight={props.bottomSpacerHeight}
getGroupLabel={props.getGroupLabel}
getNodeTemperatureThresholds={props.getNodeTemperatureThresholds}
groupedGuests={props.groupedGuests}
groupedWindowing={props.groupedWindowing}
groupLabelBadges={props.groupLabelBadges}
guestMetadata={props.guestMetadata}
guestParentNodeMap={props.guestParentNodeMap}
groupNodeDrawerMode={props.groupNodeDrawerMode}
groupingMode={props.groupingMode}
handleCustomUrlUpdate={props.handleCustomUrlUpdate}
handleTagClick={props.handleTagClick}
activeSummaryWorkloadGroupScope={props.activeSummaryWorkloadGroupScope}
activeSummaryWorkloadId={props.activeSummaryWorkloadId}
focusedSummaryWorkloadGroupScope={props.focusedSummaryWorkloadGroupScope}
focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope}
nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId}
nodeByInstance={props.nodeByInstance}
search={props.search}
selectedGuestId={props.selectedGuestId}
setFocusedWorkloadGroupScope={props.setFocusedWorkloadGroupScope}
setHoveredWorkloadGroupScope={props.setHoveredWorkloadGroupScope}
setHoveredWorkloadId={props.setHoveredWorkloadId}
setSelectedGuestId={props.setSelectedGuestId}
setTableBodyRef={props.setTableBodyRef}
@@ -438,28 +438,23 @@ describe('Workloads platform-page embed contract', () => {
expect(nodesTableSource).toContain("series: 'nodes',");
});
it('lets platform pages move grouped host drawers to a dedicated host table owner', async () => {
it('keeps grouped host rows as dividers and host drawers on the platform hosts table', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain("groupNodeDrawerMode?: 'inline' | 'disabled';");
expect(stateSource).toContain(
"groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',",
);
expect(stateSource).not.toContain('groupNodeDrawerMode');
expect(stateSource).not.toContain('summaryGroupScopes');
const tableSource = (await import('../WorkloadsTable.tsx?raw')).default;
expect(tableSource).toContain(`| 'groupNodeDrawerMode'`);
expect(tableSource).toContain('groupNodeDrawerMode={props.groupNodeDrawerMode}');
const selectionSource = (await import('../useWorkloadSelectionState.ts?raw')).default;
expect(selectionSource).not.toContain('hoveredGroupScope');
expect(selectionSource).not.toContain('focusedGroupScope');
const panelSource = (await import('../WorkloadPanel.tsx?raw')).default;
expect(panelSource).toContain("props.groupNodeDrawerMode() === 'inline'");
expect(panelSource).toContain(
'onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined',
);
expect(panelSource).not.toContain('NodeDrawer');
expect(panelSource).not.toContain('handleGroupFocusToggle');
expect(panelSource).not.toContain('createSummaryInteractiveRowPreviewHandlers');
expect(panelSource).toContain("trProps={{ 'data-summary-group-id': groupKey }}");
const proxmoxPageSource = (await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw'))
.default;
const proxmoxNodesSource = (await import('../../../features/proxmox/ProxmoxNodesTable.tsx?raw'))
.default;
expect(proxmoxPageSource).toContain('groupNodeDrawerMode="disabled"');
expect(proxmoxNodesSource).toContain('NodeDrawer');
expect(proxmoxNodesSource).toContain('data-inline-node-detail-for={node.id}');
});
@@ -1301,20 +1296,11 @@ describe('Workloads performance contract', () => {
expect(workloadsSource).not.toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
expect(workloadPanelSource).toContain('groupGuestById().get(keyedGuestId)');
expect(workloadPanelSource).toContain('buildWorkloadSummaryGroupScope');
expect(workloadPanelSource).toContain('data-summary-group-id');
expect(workloadPanelSource).toContain('setHoveredWorkloadGroupScope');
expect(workloadPanelSource).toContain('getGroupedTableRowClass');
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
expect(workloadPanelSource).not.toContain('setHoveredWorkloadGroupScope');
expect(workloadPanelSource).toContain('getGroupedTableRowCellClass');
expect(workloadPanelSource).toContain('? getInteractiveGroupedTableRowClass()');
expect(workloadPanelSource).toContain(': getGroupedTableRowClass()');
expect(workloadPanelSource).toContain(
'onClick={canOpenNodeDrawer() ? handleGroupFocusToggle : undefined}',
);
expect(workloadPanelSource).toContain(
'{...(canOpenNodeDrawer() ? groupRowInteraction : {})}',
);
expect(workloadPanelSource).toContain('class={getGroupedTableRowClass()}');
expect(workloadPanelSource).not.toContain('getInteractiveGroupedTableRowClass');
expect(workloadPanelSource).not.toContain('cursor-pointer bg-surface-alt');
expect(workloadsWorkloadTableSource).not.toContain(
'createMemo(() => getCanonicalWorkloadId(guest()))',
@@ -1593,11 +1579,10 @@ describe('Workloads performance contract', () => {
expect(nodeDrawerModelSource).toContain('HOST_METRICS_HISTORY_GROUPS');
expect(nodeDrawerModelSource).toContain("group.id !== 'disk-io'");
expect(nodeDrawerModelSource).not.toContain("id: 'thermals'");
expect(workloadPanelSource).toContain('NodeDrawer');
expect(workloadPanelSource).toContain('data-inline-node-detail-for');
expect(workloadPanelSource).toContain('const selectedGuestId = props.selectedGuestId()');
expect(workloadPanelSource).toContain('props.setSelectedGuestId(null)');
expect(workloadPanelSource).toContain('selectedGuestId === null');
expect(workloadPanelSource).not.toContain('NodeDrawer');
expect(workloadPanelSource).not.toContain('data-inline-node-detail-for');
expect(workloadPanelSource).toContain('data-inline-detail-for={guestId()}');
expect(workloadPanelSource).toContain('onClose={() => props.setSelectedGuestId(null)}');
expect(guestDrawerSource).not.toContain('const guestId = () =>');
expect(guestDrawerSource).not.toContain('const infrastructureHref = () =>');
expect(guestDrawerSource).not.toContain('Filesystems');
@@ -1720,19 +1705,15 @@ describe('Workloads performance contract', () => {
expect(workloadPanelSource).toContain('NodeGroupHeader');
expect(workloadPanelSource).toContain('GuestDrawer');
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers');
expect(workloadPanelSource).toContain('resolveSummaryGroupMemberInteractionState');
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
expect(workloadPanelSource).not.toContain('createSummaryInteractiveRowPreviewHandlers');
expect(workloadPanelSource).not.toContain('resolveSummaryGroupMemberInteractionState');
expect(workloadPanelSource).toContain('getGroupedTableRowCellClass');
expect(workloadPanelSource).not.toContain('style={{');
expect(workloadPanelSource).not.toContain('style={');
expect(workloadPanelSource).not.toContain('kind="scope"');
expect(workloadPanelSource).not.toContain('leadingAction={');
expect(workloadsSelectionStateSource).toContain('activeSummaryWorkloadGroupScope');
expect(workloadsSelectionStateSource).toContain('focusedSummaryWorkloadGroupScope');
expect(workloadsSelectionStateSource).toContain('setHoveredWorkloadGroupScope');
expect(workloadsWorkloadTableSource).toContain('focusedSummaryWorkloadGroupScope');
expect(workloadsWorkloadTableSource).toContain('hoveredSummaryWorkloadGroupScope');
expect(workloadsSelectionStateSource).not.toContain('WorkloadGroupScope');
expect(workloadsWorkloadTableSource).not.toContain('SummaryWorkloadGroupScope');
expect(workloadPanelSource).not.toContain('TableHead');
});
@@ -2,7 +2,6 @@ import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import type { WorkloadGuest } from '@/types/workloads';
import { resolveWorkloadResourceSelection } from '../workloadSelectionModel';
@@ -10,7 +9,6 @@ import { useWorkloadSelectionState } from '../useWorkloadSelectionState';
let locationSearch = '?resource=cluster-a:node-1:101';
const navigateSpy = vi.fn();
const emptySummaryGroupScopes = () => new Map<string, SummarySeriesGroupScope>();
vi.mock('@solidjs/router', () => ({
useLocation: () => ({
@@ -46,15 +44,11 @@ describe('useWorkloadSelectionState', () => {
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
expect(result.selectedGuestId()).toBe('cluster-a:node-1:101');
expect(resolveWorkloadResourceSelection(locationSearch)).toEqual({
resourceId: 'cluster-a:node-1:101',
summaryGroupId: null,
});
expect(resolveWorkloadResourceSelection(locationSearch)).toBe('cluster-a:node-1:101');
});
it('clears stale hovered workload ids when filtered guests change', () => {
@@ -71,7 +65,6 @@ describe('useWorkloadSelectionState', () => {
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
@@ -89,15 +82,13 @@ describe('useWorkloadSelectionState', () => {
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
expect(result.selectedGuestId()).toBe('app-container:truenas-main:nextcloud');
expect(resolveWorkloadResourceSelection(locationSearch)).toEqual({
resourceId: 'app-container:truenas-main:nextcloud',
summaryGroupId: null,
});
expect(resolveWorkloadResourceSelection(locationSearch)).toBe(
'app-container:truenas-main:nextcloud',
);
});
it('opens workload row selection locally without route navigation', () => {
@@ -107,7 +98,6 @@ describe('useWorkloadSelectionState', () => {
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
@@ -118,40 +108,28 @@ describe('useWorkloadSelectionState', () => {
expect(navigateSpy).not.toHaveBeenCalled();
});
it('clears pinned workload scope locally without mutating route filters', () => {
it('clears the pinned workload row locally without mutating route filters', () => {
locationSearch =
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud&summaryGroup=docker-host%3Atruenas-main';
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud';
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
const summaryScopes = () =>
new Map<string, SummarySeriesGroupScope>([
[
'docker-host:truenas-main',
{
id: 'docker-host:truenas-main',
label: 'TrueNAS Main (4 workloads)',
seriesIds: ['app-container:truenas-main:nextcloud'],
},
],
]);
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: summaryScopes,
}),
);
expect(result.selectedGuestId()).toBe('app-container:truenas-main:nextcloud');
result.clearPinnedSummaryScope();
vi.runAllTimers();
expect(result.selectedGuestId()).toBeNull();
expect(result.focusedSummaryWorkloadGroupId()).toBeNull();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('routes Escape through workload scope clearing and additional page reset work', () => {
locationSearch =
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud&summaryGroup=docker-host%3Atruenas-main';
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud';
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
const clearAdditionalPageStateOnEscape = vi.fn();
@@ -159,7 +137,6 @@ describe('useWorkloadSelectionState', () => {
useWorkloadSelectionState({
clearAdditionalPageStateOnEscape,
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
@@ -177,7 +154,6 @@ describe('useWorkloadSelectionState', () => {
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
@@ -231,7 +207,6 @@ describe('useWorkloadSelectionState', () => {
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
@@ -284,7 +259,8 @@ describe('useWorkloadSelectionState', () => {
).toBe(true);
});
it('tracks hovered workload groups without letting them override entity selection outside scope', () => {
it('ignores a summaryGroup param because grouped rows no longer pin a group scope', () => {
locationSearch = '?summaryGroup=cluster-b&resource=cluster-a:node-1:101';
const [filteredGuests] = createSignal<WorkloadGuest[]>([
{
id: 'cluster-a:node-1:101',
@@ -294,87 +270,18 @@ describe('useWorkloadSelectionState', () => {
node: 'node-1',
vmid: 101,
} as unknown as WorkloadGuest,
{
id: 'cluster-b:node-2:202',
name: 'guest-2',
status: 'running',
instance: 'cluster-b',
node: 'node-2',
vmid: 202,
} as unknown as WorkloadGuest,
]);
const groupScope: SummarySeriesGroupScope = {
id: 'cluster-b',
label: 'Cluster B (1 workload)',
seriesIds: ['cluster-b:node-2:202'],
};
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
vi.runAllTimers();
expect(result.selectedGuestId()).toBe('cluster-a:node-1:101');
result.setHoveredWorkloadGroupScope(groupScope);
expect(result.activeSummaryWorkloadGroupScope()?.id).toBe('cluster-b');
expect(result.activeSummaryWorkloadId()).toBeNull();
});
it('shows the pinned-scope fallback only after a pinned workload group scrolls out of view', () => {
locationSearch = '?summaryGroup=cluster-a';
const [filteredGuests] = createSignal<WorkloadGuest[]>([
{
id: 'cluster-a:node-1:101',
name: 'guest-1',
status: 'running',
instance: 'cluster-a',
node: 'node-1',
vmid: 101,
} as unknown as WorkloadGuest,
]);
const summaryScopes = () =>
new Map<string, SummarySeriesGroupScope>([
[
'cluster-a',
{
id: 'cluster-a',
label: 'Cluster A (1 workload)',
seriesIds: ['cluster-a:node-1:101'],
},
],
]);
let top = 120;
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: summaryScopes,
}),
);
const tableWrapper = document.createElement('div');
const groupRow = document.createElement('div');
groupRow.dataset.summaryGroupId = 'cluster-a';
groupRow.getBoundingClientRect = vi.fn(() => ({
top,
bottom: top + 40,
left: 0,
right: 320,
width: 320,
height: 40,
x: 0,
y: top,
toJSON: () => ({}),
})) as unknown as typeof groupRow.getBoundingClientRect;
tableWrapper.appendChild(groupRow);
document.body.appendChild(tableWrapper);
result.setTableWrapperRef(tableWrapper as HTMLDivElement);
expect(result.focusedSummaryWorkloadGroupId()).toBe('cluster-a');
expect(result.activeSummaryWorkloadId()).toBe('cluster-a:node-1:101');
expect(Object.keys(result).filter((key) => /group/i.test(key))).toEqual([]);
});
it('clears row focus locally without leaving behind an inferred agent filter', () => {
@@ -384,7 +291,6 @@ describe('useWorkloadSelectionState', () => {
const { result } = renderHook(() =>
useWorkloadSelectionState({
filteredGuests,
summaryGroupScopes: emptySummaryGroupScopes,
}),
);
@@ -9,28 +9,18 @@ import {
describe('workloadSelectionModel', () => {
it('resolves workloads resource deep links into focused guest ids without inventing filters', () => {
expect(resolveWorkloadResourceSelection('?resource=cluster-a:node-1:101')).toEqual({
resourceId: 'cluster-a:node-1:101',
summaryGroupId: null,
});
expect(resolveWorkloadResourceSelection('?resource=cluster-a:node-1:101')).toBe(
'cluster-a:node-1:101',
);
expect(
resolveWorkloadResourceSelection(
'?type=app-container&resource=app-container:truenas-main:nextcloud',
),
).toEqual({
resourceId: 'app-container:truenas-main:nextcloud',
summaryGroupId: null,
});
).toBe('app-container:truenas-main:nextcloud');
expect(
resolveWorkloadResourceSelection('?resource=app-container:docker-main:container-123'),
).toEqual({
resourceId: 'app-container:docker-main:container-123',
summaryGroupId: null,
});
expect(resolveWorkloadResourceSelection('?resource=guest-1')).toEqual({
resourceId: 'guest-1',
summaryGroupId: null,
});
).toBe('app-container:docker-main:container-123');
expect(resolveWorkloadResourceSelection('?resource=guest-1')).toBe('guest-1');
expect(resolveWorkloadResourceSelection('')).toBeNull();
});
@@ -50,10 +40,7 @@ describe('workloadSelectionModel', () => {
expect(workloadsHasHoveredWorkload(guests, 'cluster-a:node-1:102')).toBe(false);
});
it('resolves summary group deep links separately from local row expansion', () => {
expect(resolveWorkloadResourceSelection('?summaryGroup=docker-host%3Atruenas-main')).toEqual({
resourceId: null,
summaryGroupId: 'docker-host:truenas-main',
});
it('ignores summary group params, which no longer pin a workload group', () => {
expect(resolveWorkloadResourceSelection('?summaryGroup=docker-host%3Atruenas-main')).toBeNull();
});
});
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { Node } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
import {
buildWorkloadSummaryGroupScope,
buildWorkloadSummaryGroupScopeMap,
createWorkloadSortComparator,
filterWorkloads,
getDiskUsagePercent,
@@ -38,26 +35,6 @@ const makeGuest = (i: number, overrides?: Partial<WorkloadGuest>): WorkloadGuest
...overrides,
});
const makeNode = (id: string, instance: string, name: string): Node => ({
id,
name,
displayName: name,
instance,
host: `${name}.local`,
status: 'online',
type: 'pve',
cpu: 0,
memory: { total: 1, used: 0, free: 1, usage: 0 },
disk: { total: 1, used: 0, free: 1, usage: 0 },
uptime: 1,
loadAverage: [0, 0, 0],
kernelVersion: 'test',
pveVersion: 'test',
cpuInfo: { model: 'test', cores: 1, sockets: 1, mhz: '1' },
lastSeen: new Date().toISOString(),
connectionHealth: 'online',
});
describe('workloadSelectors (branch coverage)', () => {
describe('createWorkloadSortComparator', () => {
it('sorts by memory usage in both directions and falls back to 0 for null memory', () => {
@@ -703,139 +680,6 @@ describe('workloadSelectors (branch coverage)', () => {
});
});
describe('buildWorkloadSummaryGroupScope', () => {
it('returns null when all guests produce empty canonical IDs', () => {
const guest = makeGuest(1, { id: '', type: 'app-container', workloadType: 'app-container' });
expect(buildWorkloadSummaryGroupScope('grp', [guest], { type: 'T', name: 'N' })).toBeNull();
});
it('builds singular label for a single guest', () => {
const guest = makeGuest(1, {
id: 'cid-1',
type: 'app-container',
workloadType: 'app-container',
});
const scope = buildWorkloadSummaryGroupScope('grp', [guest], {
type: 'Container',
name: 'frigate',
});
expect(scope).not.toBeNull();
expect(scope!.label).toBe('frigate · Container (1 workload)');
expect(scope!.seriesIds).toEqual(['cid-1']);
});
it('builds plural label and deduplicates canonical IDs for multiple guests', () => {
const guests = [
makeGuest(1, { id: 'dup-id', type: 'app-container', workloadType: 'app-container' }),
makeGuest(2, { id: 'dup-id', type: 'app-container', workloadType: 'app-container' }),
];
const scope = buildWorkloadSummaryGroupScope('grp', guests, { type: 'C', name: 'host' });
expect(scope).not.toBeNull();
expect(scope!.label).toBe('host · C (2 workloads)');
expect(scope!.seriesIds).toEqual(['dup-id']); // deduped
});
it('uses workload count alone when label name and type are both empty', () => {
const guest = makeGuest(1, {
id: 'x-1',
type: 'app-container',
workloadType: 'app-container',
});
const scope = buildWorkloadSummaryGroupScope('grp', [guest], { type: '', name: '' });
expect(scope).not.toBeNull();
expect(scope!.label).toBe('1 workload');
});
it('trims the groupId for the scope id', () => {
const guest = makeGuest(1, {
id: 'x-2',
type: 'app-container',
workloadType: 'app-container',
});
const scope = buildWorkloadSummaryGroupScope(' padded-grp ', [guest], {
type: 'T',
name: 'N',
});
expect(scope).not.toBeNull();
expect(scope!.id).toBe('padded-grp');
});
});
describe('buildWorkloadSummaryGroupScopeMap', () => {
it('returns an empty map when groupingMode is flat', () => {
const result = buildWorkloadSummaryGroupScopeMap({
guests: [makeGuest(1)],
nodes: [],
groupingMode: 'flat',
sortComparator: null,
});
expect(result.size).toBe(0);
});
it('resolves node display name via buildNodeByInstance for the group label', () => {
const guests = [
makeGuest(1, { type: 'vm', workloadType: 'vm', instance: 'cluster-a', node: 'node-a' }),
];
const nodes = [makeNode('n1', 'cluster-a', 'node-a')];
const result = buildWorkloadSummaryGroupScopeMap({
guests,
nodes,
groupingMode: 'grouped',
sortComparator: null,
});
// groupKey = 'cluster-a-node-a' matches node via instance-name key
// getNodeDisplayName -> 'node-a' -> label includes it
const scope = result.get('cluster-a-node-a');
expect(scope).toBeDefined();
expect(scope!.label).toBe('node-a (1 workload)');
expect(scope!.seriesIds).toEqual(['cluster-a:node-a:101']);
});
it('falls back to lowercase badge key when exact-case lookup misses', () => {
const guests = [
makeGuest(1, {
id: 'c-1',
name: 'frigate',
type: 'app-container',
workloadType: 'app-container',
contextLabel: 'Frigate', // capital F -> groupKey 'app-container:Frigate'
}),
];
const result = buildWorkloadSummaryGroupScopeMap({
guests,
nodes: [],
groupingMode: 'grouped',
sortComparator: null,
groupLabelBadges: {
'app-container:frigate': { label: 'Docker' }, // lowercase key
},
});
// badge found via lowercase fallback -> label includes 'Docker'
const scope = result.get('app-container:Frigate');
expect(scope).toBeDefined();
expect(scope!.label).toBe('Frigate · Docker (1 workload)');
});
it('omits groups whose scope is null (empty canonical IDs)', () => {
const guests = [
makeGuest(1, { id: '', type: 'app-container', workloadType: 'app-container' }),
];
const result = buildWorkloadSummaryGroupScopeMap({
guests,
nodes: [],
groupingMode: 'grouped',
sortComparator: null,
});
expect(result.size).toBe(0);
});
});
describe('groupWorkloads', () => {
it('sorts the single flat group when a comparator is provided', () => {
const guests = [
@@ -8,7 +8,6 @@ import {
getDiskUsagePercent,
getWorkloadGroupKey,
getWorkloadGroupLabel,
buildWorkloadSummaryGroupScopeMap,
groupWorkloads,
selectVisibleWorkloadInventory,
} from '@/components/Workloads/workloadSelectors';
@@ -946,32 +945,6 @@ describe('workloadSelectors', () => {
type: 'LXC',
});
});
it('passes group identity badges into summary group scope labels', () => {
const guests = [
makeGuest(1, {
id: 'container-1',
name: 'frigate',
type: 'app-container',
workloadType: 'app-container',
contextLabel: 'frigate-host',
}),
];
const scopes = buildWorkloadSummaryGroupScopeMap({
guests,
nodes: [],
groupingMode: 'grouped',
sortComparator: null,
groupLabelBadges: {
'app-container:frigate-host': { label: 'LXC' },
},
});
expect(scopes.get('app-container:frigate-host')?.label).toBe(
'frigate-host · LXC (1 workload)',
);
});
});
describe('workloadNodeScopeId', () => {
@@ -22,7 +22,6 @@ describe('workloadUrlSyncModel', () => {
cluster: '',
agent: 'node-a',
resource: 'guest-1',
summaryGroup: '',
});
});
@@ -1,7 +1,6 @@
import type { JSX } from 'solid-js';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import type { SummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction';
import {
PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT,
getPlatformTableWeightedColumnWidthStyle,
@@ -118,7 +117,6 @@ export interface GuestRowProps {
onClick?: () => void;
isExpanded?: boolean;
isSummaryHighlighted?: boolean;
summaryGroupMemberState?: SummaryGroupMemberInteractionState;
ioEmphasis?: WorkloadIOEmphasis;
metricDisplayMode?: 'bars' | 'sparklines';
metricHoverMode?: WorkloadsMetricHoverMode;
@@ -1,24 +1,18 @@
import { useLocation } from '@solidjs/router';
import { createEffect, createMemo, createSignal, type Accessor } from 'solid-js';
import { createEffect, createSignal, type Accessor } from 'solid-js';
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
import {
isSummarySeriesInGroupScope,
type SummarySeriesGroupScope,
} from '@/components/shared/summaryCardInteraction';
import type { WorkloadGuest } from '@/types/workloads';
import { capturePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
import {
workloadsHasHoveredWorkload,
workloadsHasVisibleWorkloadGroupScope,
resolveWorkloadResourceSelection,
} from './workloadSelectionModel';
interface UseWorkloadsSelectionStateOptions {
clearAdditionalPageStateOnEscape?: () => void;
filteredGuests: Accessor<WorkloadGuest[]>;
summaryGroupScopes: Accessor<Map<string, SummarySeriesGroupScope>>;
routeStateEnabled?: Accessor<boolean>;
}
@@ -26,42 +20,25 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
const location = useLocation();
const [selectedGuestId, setSelectedGuestIdRaw] = createSignal<string | null>(null);
const [selectedWorkloadGroupId, setSelectedWorkloadGroupIdRaw] = createSignal<string | null>(
null,
);
const [hoveredWorkloadId, setHoveredWorkloadId] = createSignal<string | null>(null);
const [hoveredWorkloadGroupScope, setHoveredWorkloadGroupScope] =
createSignal<SummarySeriesGroupScope | null>(null);
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
const [handledWorkloadGroupId, setHandledWorkloadGroupId] = createSignal<string | null>(null);
const [revealedGuestId, setRevealedGuestId] = createSignal<string | null>(null);
const [tableWrapperRef, setTableWrapperRefSignal] = createSignal<HTMLDivElement | undefined>(
undefined,
);
const [tableBodyRef, setTableBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
const focusedWorkloadGroupScope = createMemo<SummarySeriesGroupScope | null>(() => {
const selectedGroupId = selectedWorkloadGroupId();
if (!selectedGroupId) {
return null;
}
return options.summaryGroupScopes().get(selectedGroupId) ?? null;
});
const clearPinnedSummaryScope = () => {
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
setSelectedGuestIdRaw(null);
setSelectedWorkloadGroupIdRaw(null);
});
};
const summaryInteraction = useSummaryPageInteractionState({
clearPinnedScope: clearPinnedSummaryScope,
hoveredSeriesId: hoveredWorkloadId,
hoveredGroupScope: hoveredWorkloadGroupScope,
focusedSeriesId: selectedGuestId,
focusedGroupId: selectedWorkloadGroupId,
focusedGroupScope: focusedWorkloadGroupScope,
onEscapeClear: () => {
clearPinnedSummaryScope();
options.clearAdditionalPageStateOnEscape?.();
@@ -89,50 +66,20 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
const setSelectedGuestId = (id: string | null) => {
capturePendingAppShellRestoreTop();
const activeFocusedGroupScope = focusedWorkloadGroupScope();
const nextGroupScope =
activeFocusedGroupScope && !isSummarySeriesInGroupScope(activeFocusedGroupScope, id)
? null
: activeFocusedGroupScope;
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
setSelectedGuestIdRaw(id);
setSelectedWorkloadGroupIdRaw(nextGroupScope?.id ?? null);
});
};
const setFocusedWorkloadGroupScopeState = (scope: SummarySeriesGroupScope | null) => {
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
setSelectedWorkloadGroupIdRaw(scope?.id ?? null);
if (scope && !isSummarySeriesInGroupScope(scope, selectedGuestId())) {
setSelectedGuestIdRaw(null);
}
});
};
const setFocusedWorkloadGroupScope = (scope: SummarySeriesGroupScope | null) => {
setFocusedWorkloadGroupScopeState(scope);
setSelectedGuestIdState(id);
};
createEffect(() => {
if (options.routeStateEnabled && !options.routeStateEnabled()) return;
const selection = resolveWorkloadResourceSelection(location.search);
if (!selection) {
const resourceId = resolveWorkloadResourceSelection(location.search);
if (!resourceId) {
if (handledResourceId() !== null) {
setSelectedGuestIdState(null);
setHandledResourceId(null);
}
if (handledWorkloadGroupId() !== null) {
setSelectedWorkloadGroupIdRaw(null);
setHandledWorkloadGroupId(null);
}
return;
}
const { resourceId, summaryGroupId } = selection;
if (summaryGroupId !== handledWorkloadGroupId()) {
setSelectedWorkloadGroupIdRaw(summaryGroupId);
setHandledWorkloadGroupId(summaryGroupId);
}
if (resourceId !== handledResourceId()) {
setSelectedGuestIdState(resourceId);
setHandledResourceId(resourceId);
@@ -155,50 +102,13 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
}
});
createEffect(() => {
const groupScope = hoveredWorkloadGroupScope();
if (!groupScope) {
return;
}
if (!workloadsHasVisibleWorkloadGroupScope(options.filteredGuests(), groupScope)) {
setHoveredWorkloadGroupScope(null);
}
});
createEffect(() => {
const selectedGroupId = selectedWorkloadGroupId();
if (!selectedGroupId) {
return;
}
if (!focusedWorkloadGroupScope()) {
setFocusedWorkloadGroupScope(null);
}
});
createEffect(() => {
const selectedId = selectedGuestId();
const groupScope = focusedWorkloadGroupScope();
if (!selectedId || !groupScope) {
return;
}
if (!isSummarySeriesInGroupScope(groupScope, selectedId)) {
setSelectedGuestId(null);
}
});
return {
activeSummaryWorkloadGroupScope: summaryInteraction.activeGroupScope,
activeSummaryWorkloadId: summaryInteraction.activeSeriesId,
clearPinnedSummaryScope,
focusedSummaryWorkloadGroupScope: focusedWorkloadGroupScope,
hoveredWorkloadId,
hoveredSummaryWorkloadGroupScope: hoveredWorkloadGroupScope,
focusedSummaryWorkloadGroupId: selectedWorkloadGroupId,
revealedGuestId,
selectedGuestId,
setClearSurfaceRootRef,
setFocusedWorkloadGroupScope,
setHoveredWorkloadGroupScope,
setHoveredWorkloadId,
setSelectedGuestId,
setTableBodyRef,
@@ -5,7 +5,6 @@ import {
type RuntimeInventorySourcesResponse,
} from '@/api/runtimeInventorySources';
import { recordWorkloadHistoryActivity } from '@/api/workloadHistoryActivity';
import { nodeOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
import type { VM, Container, Node } from '@/types/api';
import type { Resource } from '@/types/resource';
import type { ViewMode, WorkloadGuest, WorkloadType } from '@/types/workloads';
@@ -28,7 +27,6 @@ import { getCanonicalWorkloadId } from '@/utils/workloads';
import { nodeFromResource } from '@/utils/resourceStateAdapters';
import { STORAGE_KEYS } from '@/utils/localStorage';
import {
buildWorkloadSummaryGroupScopeMap,
createWorkloadSortComparator,
filterWorkloads,
selectVisibleWorkloadInventory,
@@ -105,10 +103,6 @@ export interface WorkloadsSurfaceProps {
}>;
resourceSnapshotRefetch?: () => Promise<unknown>;
statusModeStorageScope?: string;
// Default Workloads behavior owns grouped host row drawers inline. Platform
// pages with a dedicated host table can disable that drawer so host details
// open from the host-owned table instead of the embedded guest table.
groupNodeDrawerMode?: 'inline' | 'disabled';
// When a platform page owns the metric display mode + sparkline range
// (so the same toggle drives both the page's hosts table and this
// embedded workloads surface), pass the accessors + change handlers.
@@ -423,9 +417,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
reconnect();
};
const getNodeTemperatureThresholds = (node: Node) =>
alertsActivation.getMetricThresholds('node', 'temperature', nodeOverrideIdCandidates(node));
createEffect(() => {
if (!workloadsEnabled() || props.inventorySourcesQuery) return;
const handle = window.setInterval(() => {
@@ -480,29 +471,14 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
const groupLabelBadges = createMemo<Record<string, WorkloadGroupLabelBadge>>(
() => props.groupLabelBadges ?? {},
);
const summaryGroupScopes = createMemo(() =>
buildWorkloadSummaryGroupScopeMap({
guests: filteredGuests(),
nodes: infrastructureNodes(),
groupingMode: groupingMode(),
sortComparator: guestSortComparator(),
groupLabelBadges: groupLabelBadges(),
}),
);
const {
activeSummaryWorkloadGroupScope,
activeSummaryWorkloadId,
clearPinnedSummaryScope,
focusedSummaryWorkloadGroupScope,
focusedSummaryWorkloadGroupId,
hoveredSummaryWorkloadGroupScope,
hoveredWorkloadId,
revealedGuestId,
selectedGuestId,
setClearSurfaceRootRef,
setFocusedWorkloadGroupScope,
setHoveredWorkloadGroupScope,
setHoveredWorkloadId,
setSelectedGuestId,
setTableBodyRef,
@@ -515,7 +491,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
resetWorkloadRouteFilters();
},
filteredGuests,
summaryGroupScopes,
routeStateEnabled: props.routeStateEnabled,
});
@@ -606,7 +581,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
allGuests,
inventoryStats,
isScrollToTopVisible,
activeSummaryWorkloadGroupScope,
activeSummaryWorkloadId,
clearPinnedSummaryScope,
bottomSpacerHeight,
@@ -623,10 +597,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadsInfrastructureEmptyState,
workloadsLoadingState,
filteredGuests,
focusedSummaryWorkloadGroupScope,
focusedSummaryWorkloadGroupId,
getGroupLabel,
getNodeTemperatureThresholds,
groupedGuests,
groupedWindowing,
guestMetadata,
@@ -638,7 +609,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
handleTagClick,
hasInfrastructureSources,
hostFilterConfig,
hoveredSummaryWorkloadGroupScope,
infrastructureSourceStateReady,
infrastructureNodes,
initialDataReceived,
@@ -667,9 +637,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
selectedNode,
setContainerRuntime,
setClearSurfaceRootRef,
setFocusedWorkloadGroupScope,
setGroupingMode,
setHoveredWorkloadGroupScope,
setHoveredWorkloadId,
setSearch,
setSelectedCluster,
@@ -725,7 +693,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadsNoInventoryState,
ws,
groupingMode,
groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',
groupLabelBadges,
} as const;
}
@@ -1,36 +1,12 @@
import { parseWorkloadsLinkSearch } from '@/routing/resourceLinks';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import type { WorkloadGuest } from '@/types/workloads';
import { getCanonicalWorkloadId } from '@/utils/workloads';
export interface WorkloadsResourceSelection {
resourceId: string | null;
summaryGroupId: string | null;
}
export const resolveWorkloadResourceSelection = (
search: string,
): WorkloadsResourceSelection | null => {
const { resource: resourceId, summaryGroup: summaryGroupId } = parseWorkloadsLinkSearch(search);
if (!resourceId && !summaryGroupId) return null;
return {
resourceId: resourceId || null,
summaryGroupId: summaryGroupId || null,
};
};
/** Resolves the inbound `resource` deep link to the guest whose drawer opens. */
export const resolveWorkloadResourceSelection = (search: string): string | null =>
parseWorkloadsLinkSearch(search).resource || null;
export const workloadsHasHoveredWorkload = (
filteredGuests: WorkloadGuest[],
hoveredId: string,
): boolean => filteredGuests.some((guest) => getCanonicalWorkloadId(guest) === hoveredId);
export const workloadsHasVisibleWorkloadGroupScope = (
filteredGuests: WorkloadGuest[],
groupScope: SummarySeriesGroupScope,
): boolean => {
const filteredGuestIds = new Set(
filteredGuests.map((guest) => getCanonicalWorkloadId(guest)).filter(Boolean),
);
return groupScope.seriesIds.some((seriesId) => filteredGuestIds.has(seriesId));
};
@@ -1,19 +1,15 @@
import type { Node } from '@/types/api';
import type { WorkloadGuest, ViewMode, WorkloadType } from '@/types/workloads';
import {
getWorkloadInfoValue,
type IODistributionStats,
type WorkloadIOEmphasis,
} from './guestRowModel';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery';
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
import { OFFLINE_HEALTH_STATUSES } from '@/utils/status';
import { hasFailedAttachedAvailabilityCheck } from '@/utils/availabilityProbePresentation';
import { getNodeDisplayName } from '@/utils/nodes';
import {
isContainerWorkloadViewMode,
getCanonicalWorkloadId,
getWorkloadCPUPercent,
resolveWorkloadType,
workloadMatchesPlatformScope,
@@ -21,7 +17,6 @@ import {
} from '@/utils/workloads';
import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation';
import {
buildNodeByInstance,
getKubernetesContextKey,
getWorkloadHostHintCandidates,
workloadHostScopeId,
@@ -417,29 +412,6 @@ export const getWorkloadGroupLabel = (
return { type: '', name: context };
};
export const buildWorkloadSummaryGroupScope = (
groupId: string,
guests: WorkloadGuest[],
label: { type: string; name: string },
): SummarySeriesGroupScope | null => {
const seriesIds = Array.from(
new Set(guests.map((guest) => getCanonicalWorkloadId(guest)).filter(Boolean)),
);
if (seriesIds.length === 0) {
return null;
}
const summaryLabelParts = [label.name.trim(), label.type.trim()].filter(Boolean);
const scopeLabel = summaryLabelParts.join(' · ');
const workloadCountLabel = `${guests.length} workload${guests.length === 1 ? '' : 's'}`;
return {
id: groupId.trim(),
label: scopeLabel ? `${scopeLabel} (${workloadCountLabel})` : workloadCountLabel,
seriesIds,
};
};
export const groupWorkloads = (
guests: WorkloadGuest[],
mode: 'grouped' | 'flat',
@@ -471,46 +443,6 @@ export const groupWorkloads = (
return groups;
};
export const buildWorkloadSummaryGroupScopeMap = ({
guests,
nodes,
groupingMode,
sortComparator,
groupLabelBadges,
}: {
guests: WorkloadGuest[];
nodes: Node[];
groupingMode: 'grouped' | 'flat';
sortComparator: ((a: WorkloadGuest, b: WorkloadGuest) => number) | null;
groupLabelBadges?: Record<string, { label: string }>;
}): Map<string, SummarySeriesGroupScope> => {
if (groupingMode !== 'grouped') {
return new Map<string, SummarySeriesGroupScope>();
}
const grouped = groupWorkloads(guests, groupingMode, sortComparator);
const nodeByInstance = buildNodeByInstance(nodes);
const scopes = new Map<string, SummarySeriesGroupScope>();
for (const [groupKey, groupGuests] of Object.entries(grouped)) {
const node = nodeByInstance[groupKey];
const badge = groupLabelBadges?.[groupKey] ?? groupLabelBadges?.[groupKey.toLowerCase()];
const scope = buildWorkloadSummaryGroupScope(
groupKey,
groupGuests,
getWorkloadGroupLabel(
groupKey,
groupGuests,
node ? getNodeDisplayName(node) : null,
badge?.label,
),
);
if (scope) {
scopes.set(scope.id, scope);
}
}
return scopes;
};
export const computeWorkloadStats = (guests: WorkloadGuest[]): WorkloadStats => {
let running = 0;
let degraded = 0;
@@ -1356,8 +1356,6 @@ describe('shared primitive guardrails', () => {
expect(summaryCardInteractionSource).toContain('resolveSummaryScopeState');
expect(workloadSelectionStateSource).toContain('preserveScrollableAncestorVerticalOffset');
expect(workloadSelectionStateSource).toContain('hoveredWorkloadGroupScope');
expect(workloadSelectionStateSource).toContain('activeSummaryWorkloadGroupScope');
expect(workloadSelectionStateSource).not.toContain('const scrollTop = scroller?.scrollTop');
expect(summaryTableFocusSource).toContain('export function useSummaryTableFocusBridge');
@@ -1404,7 +1402,6 @@ describe('shared primitive guardrails', () => {
expect(groupedTableRowPresentationSource).not.toContain('GROUPED_TABLE_ROW_DIVIDER_CLASS');
expect(guestRowSource).toContain('data-summary-row-active');
expect(guestRowSource).toContain('data-summary-group-member-active');
expect(guestRowStateSource).not.toContain('bg-sky-50/70');
expect(guestRowStateSource).not.toContain('ring-sky-400/25');
@@ -1422,7 +1419,7 @@ describe('shared primitive guardrails', () => {
expect(storageGroupPresentationSource).toContain('getGroupedTableRowCellClass');
expect(nodeGroupHeaderSource).toContain('getGroupedTableRowClass');
expect(nodeGroupHeaderSource).toContain('getGroupedTableRowCellClass');
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
expect(workloadPanelSource).toContain('class={getGroupedTableRowClass()}');
expect(workloadPanelSource).toContain('getGroupedTableRowCellClass');
expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowClass');
expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowCellClass');
@@ -1728,7 +1725,6 @@ describe('shared primitive guardrails', () => {
expect(storageGroupRowSource).not.toContain('hideWhenRowTappableOnMobile={false}');
expect(storageGroupRowSource).toContain('props.onToggle();');
expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers');
expect(workloadPanelSource).not.toContain('kind="scope"');
expect(storageGroupRowSource).not.toContain('kind="scope"');
@@ -494,7 +494,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
// surface, so label it with the platform vocabulary instead of 'Info'.
columnLabelOverrides: { info: 'ID' },
statusModeStorageScope: PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE,
groupNodeDrawerMode: 'disabled',
metricDisplayMode: props.metricDisplayMode,
onMetricDisplayModeChange: props.setMetricDisplayMode,
metricHistoryRange: props.metricHistoryRange,
@@ -617,12 +616,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
memoryDisplayBasis={workloadsState.workloadMemoryDisplayBasis}
setMemoryDisplayBasis={props.setMemoryDisplayBasis}
forcedPlatform={PROXMOX_PLATFORM_FILTER}
pinnedSelectionActive={() =>
Boolean(
workloadsState.selectedGuestId() ||
workloadsState.focusedSummaryWorkloadGroupId(),
)
}
pinnedSelectionActive={() => Boolean(workloadsState.selectedGuestId())}
onClearPinnedSelection={workloadsState.clearPinnedSummaryScope}
/>
</div>
@@ -636,7 +630,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
forcedPlatform={PROXMOX_PLATFORM_FILTER}
excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}
showNestedExcludedWorkloads
groupNodeDrawerMode="disabled"
suppressFilterToolbar
emptyStateTitle="No Proxmox workloads"
emptyStateDescription="Proxmox VMs and LXCs appear here when inventory is available."
@@ -214,16 +214,6 @@ describe('ProxmoxPageSurface contract', () => {
);
});
it('leaves host details to the nodes table instead of the embedded guest table', () => {
setResources([makeResource({ id: 'vm-1', type: 'vm' })]);
renderSurface();
expect(mockWorkloadsOptions).toHaveBeenCalledWith(
expect.objectContaining({ groupNodeDrawerMode: 'disabled' }),
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -2,23 +2,14 @@ import { describe, expect, it } from 'vitest';
import workloadsStateSource from '@/components/Workloads/useWorkloadsState.ts?raw';
import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw';
import proxmoxNodesTableSource from '../ProxmoxNodesTable.tsx?raw';
import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw';
describe('Proxmox drawer placement contract', () => {
it('keeps host details owned by the Proxmox host table instead of the embedded guest table', () => {
expect(workloadsStateSource).toContain("groupNodeDrawerMode?: 'inline' | 'disabled';");
expect(workloadsStateSource).toContain(
"groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',",
);
expect(workloadsTableSource).toContain(`| 'groupNodeDrawerMode'`);
expect(workloadsTableSource).toContain('groupNodeDrawerMode={props.groupNodeDrawerMode}');
expect(workloadPanelSource).toContain("props.groupNodeDrawerMode() === 'inline'");
expect(workloadPanelSource).toContain(
'onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined',
);
expect(proxmoxPageSurfaceSource).toContain('groupNodeDrawerMode="disabled"');
expect(workloadsStateSource).not.toContain('groupNodeDrawerMode');
expect(workloadPanelSource).not.toContain('NodeDrawer');
expect(proxmoxPageSurfaceSource).not.toContain('groupNodeDrawerMode');
expect(proxmoxNodesTableSource).toContain('NodeDrawer');
expect(proxmoxNodesTableSource).toContain('data-inline-node-detail-for={node.id}');
});
@@ -475,7 +475,6 @@ function VmwareOverview(props: VmwareOverviewProps) {
columnVisibilityStorageScope: VMWARE_WORKLOAD_COLUMN_VISIBILITY_SCOPE,
additionalDefaultHiddenColumnIds: [...VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS],
inventorySourcesQuery: props.inventorySourcesQuery,
groupNodeDrawerMode: 'disabled',
metricDisplayMode: props.metricDisplayMode,
onMetricDisplayModeChange: props.setMetricDisplayMode,
metricHistoryRange: props.metricHistoryRange,
@@ -527,11 +526,7 @@ function VmwareOverview(props: VmwareOverviewProps) {
platformFilter={undefined}
{...getWorkloadsMetricFilterProps(workloadsState)}
forcedPlatform={VMWARE_PLATFORM_FILTER}
pinnedSelectionActive={() =>
Boolean(
workloadsState.selectedGuestId() || workloadsState.focusedSummaryWorkloadGroupId(),
)
}
pinnedSelectionActive={() => Boolean(workloadsState.selectedGuestId())}
onClearPinnedSelection={workloadsState.clearPinnedSummaryScope}
/>
</div>
@@ -544,7 +539,6 @@ function VmwareOverview(props: VmwareOverviewProps) {
useWorkloads
forcedPlatform={VMWARE_PLATFORM_FILTER}
forcedViewMode="vm"
groupNodeDrawerMode="disabled"
suppressFilterToolbar
emptyStateTitle="No vSphere VMs"
emptyStateDescription="Virtual machines appear here once the vCenter connection enumerates them."
@@ -28,22 +28,20 @@ describe('buildWorkloadsRouteSearch branch coverage', () => {
expect(buildWorkloadsRouteSearch({})).toBe('');
});
it('emits the runtime, namespace, cluster, and summaryGroup params the sibling suite omits', () => {
it('emits the runtime, namespace, and cluster params the sibling suite omits', () => {
expect(
buildWorkloadsRouteSearch({
runtime: 'containerd',
namespace: 'kube-system',
cluster: 'prod-us-east',
summaryGroup: 'compute',
}),
).toBe('?runtime=containerd&namespace=kube-system&cluster=prod-us-east&summaryGroup=compute');
).toBe('?runtime=containerd&namespace=kube-system&cluster=prod-us-east');
});
it('verifies the omitted query-param keys map to the canonical names', () => {
expect(WORKLOADS_QUERY_PARAMS.runtime).toBe('runtime');
expect(WORKLOADS_QUERY_PARAMS.namespace).toBe('namespace');
expect(WORKLOADS_QUERY_PARAMS.cluster).toBe('cluster');
expect(WORKLOADS_QUERY_PARAMS.summaryGroup).toBe('summaryGroup');
});
it('canonicalizes the "host" type alias to "agent" via canonicalizeWorkloadFilterType', () => {
@@ -69,10 +67,9 @@ describe('buildWorkloadsRouteSearch branch coverage', () => {
cluster: ' cl-1 ',
agent: ' ag-1 ',
resource: ' res-1 ',
summaryGroup: ' sg-1 ',
}),
).toBe(
'?type=pod&platform=kubernetes&runtime=cri-o&context=ctx-1&namespace=ns-1&cluster=cl-1&agent=ag-1&resource=res-1&summaryGroup=sg-1',
'?type=pod&platform=kubernetes&runtime=cri-o&context=ctx-1&namespace=ns-1&cluster=cl-1&agent=ag-1&resource=res-1',
);
});
@@ -87,7 +84,6 @@ describe('buildWorkloadsRouteSearch branch coverage', () => {
cluster: '',
agent: null,
resource: undefined,
summaryGroup: ' ',
}),
).toBe('');
});
@@ -248,7 +248,6 @@ describe('resource link routing contract', () => {
cluster: '',
agent: 'worker-1',
resource: 'cluster-a:worker-1:101',
summaryGroup: '',
});
expect(WORKLOADS_QUERY_PARAMS.type).toBe('type');
@@ -259,7 +258,7 @@ describe('resource link routing contract', () => {
expect(WORKLOADS_QUERY_PARAMS.cluster).toBe('cluster');
expect(WORKLOADS_QUERY_PARAMS.agent).toBe('agent');
expect(WORKLOADS_QUERY_PARAMS.resource).toBe('resource');
expect(WORKLOADS_QUERY_PARAMS.summaryGroup).toBe('summaryGroup');
expect(WORKLOADS_QUERY_PARAMS).not.toHaveProperty('summaryGroup');
});
it('canonicalizes legacy workloads type aliases when building route search', () => {
@@ -13,7 +13,6 @@ export const WORKLOADS_QUERY_PARAMS = {
// Canonical v6 agent filter query param.
agent: 'agent',
resource: 'resource',
summaryGroup: 'summaryGroup',
} as const;
export const STANDALONE_PATH = '/standalone';
@@ -156,7 +155,6 @@ type WorkloadsLinkOptions = {
cluster?: string | null;
agent?: string | null;
resource?: string | null;
summaryGroup?: string | null;
};
type DockerLinkOptions = {
@@ -210,7 +208,6 @@ export const parseWorkloadsLinkSearch = (search: string) => {
cluster: normalizeQueryValue(params.get(WORKLOADS_QUERY_PARAMS.cluster)),
agent: normalizeQueryValue(params.get(WORKLOADS_QUERY_PARAMS.agent)),
resource: normalizeQueryValue(params.get(WORKLOADS_QUERY_PARAMS.resource)),
summaryGroup: normalizeQueryValue(params.get(WORKLOADS_QUERY_PARAMS.summaryGroup)),
};
};
@@ -298,7 +295,6 @@ export const buildWorkloadsRouteSearch = (options: WorkloadsLinkOptions = {}): s
const cluster = normalizeQueryValue(options.cluster);
const agent = normalizeQueryValue(options.agent);
const resource = normalizeQueryValue(options.resource);
const summaryGroup = normalizeQueryValue(options.summaryGroup);
if (type) params.set(WORKLOADS_QUERY_PARAMS.type, type);
if (platform) params.set(WORKLOADS_QUERY_PARAMS.platform, platform);
if (runtime) params.set(WORKLOADS_QUERY_PARAMS.runtime, runtime);
@@ -307,7 +303,6 @@ export const buildWorkloadsRouteSearch = (options: WorkloadsLinkOptions = {}): s
if (cluster) params.set(WORKLOADS_QUERY_PARAMS.cluster, cluster);
if (agent) params.set(WORKLOADS_QUERY_PARAMS.agent, agent);
if (resource) params.set(WORKLOADS_QUERY_PARAMS.resource, resource);
if (summaryGroup) params.set(WORKLOADS_QUERY_PARAMS.summaryGroup, summaryGroup);
return serializedRouteSearch(params);
};
@@ -1414,7 +1414,7 @@ describe('frontend resource type boundaries', () => {
expect(storageGroupRowSource).toContain('buildStorageGroupRowPresentation');
expect(storageGroupRowSource).toContain('STORAGE_GROUP_ROW_CLASS');
expect(storageGroupRowSource).not.toContain('cursor-pointer select-none bg-surface-alt');
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
expect(workloadPanelSource).toContain('getGroupedTableRowClass()');
expect(storageGroupPresentationSource).toContain(
'export const getStorageGroupHealthCountPresentation',
);
@@ -1,16 +1,14 @@
import { describe, expect, it } from 'vitest';
import nodeDrawerOverviewSource from '@/components/Workloads/NodeDrawerOverview.tsx?raw';
import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
import temperatureGaugeSource from '@/components/shared/TemperatureGauge.tsx?raw';
import dockerHostDrawerOverviewSource from '@/features/docker/DockerHostDrawerOverview.tsx?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw';
describe('temperature display guardrails', () => {
it('keeps colored temperature displays threshold-aware', () => {
expect(workloadPanelSource).toContain(
'temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}',
);
expect(proxmoxNodesTableSource).toContain('temperatureThresholds={temperatureThresholds()}');
expect(nodeDrawerOverviewSource).toContain('getTemperatureTextClass(value, thresholds)');
expect(dockerHostDrawerOverviewSource).toContain('temperatureThresholds()');
expect(agentsMachinesTableSource).toContain('getAgentMachineTemperatureMetric');
@@ -19,7 +17,6 @@ describe('temperature display guardrails', () => {
});
it('does not reintroduce the previous local temperature severity calls', () => {
expect(workloadPanelSource).not.toContain('getTemperatureTextClass(temperature)}');
expect(nodeDrawerOverviewSource).not.toContain('getTemperatureTextClass(value)');
expect(dockerHostDrawerOverviewSource).not.toContain('getTemperatureTextClass(temperature)');
expect(agentsMachinesTableSource).not.toContain('<TemperatureGauge value={value()} />');