mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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 in84f627e22a(2026-05-18, "Move Proxmox host drawers to the host table") and vSphere ind96d5612fd(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 in86ac84f68e. ## 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:
@@ -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}
|
||||
|
||||
+21
-40
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
+13
-107
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
-156
@@ -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();
|
||||
|
||||
+3
-12
@@ -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()} />');
|
||||
|
||||
Reference in New Issue
Block a user