mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge pull request #2065 from rcourtman/fix/proxmox-node-guest-navigation
Show a node's guests from the Proxmox overview row
This commit is contained in:
@@ -360,8 +360,18 @@ whole-row pointer activation, Enter/Space keyboard activation, focus treatment,
|
||||
`aria-expanded` / `aria-controls`, and exclusion of embedded links and controls.
|
||||
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
|
||||
is visually removed on phone layouts where the complete row is the touch target;
|
||||
provider tables must not add a second mobile chevron. Operator overrides remain
|
||||
in the shared `Manage` tab and use the explicit compact density of `FormSelect`
|
||||
provider tables must not add a second mobile chevron. When row activation
|
||||
performs a different primary action, such as selecting a node's guests on
|
||||
Proxmox, the shared toggle remains visible on phones through its
|
||||
`hideWhenRowTappableOnMobile={false}` option. The node name supplies native
|
||||
keyboard activation for guest selection, and embedded controls retain their
|
||||
separate actions. Explicit guest selection focuses the guest heading with
|
||||
scroll prevention. The shared `revealElementInViewport` helper leaves a
|
||||
visible heading anchored and reveals an off-screen heading only far enough
|
||||
to show the start of its results. It uses the actual scroll container and
|
||||
marks deliberate movement so route-state restoration cannot compete with it. Activating the selected node again clears its node scope without
|
||||
moving scroll or focus, while hover leaves the guest inventory unchanged.
|
||||
Operator overrides remain in the shared `Manage` tab and use the explicit compact density of `FormSelect`
|
||||
and `FormTextarea`, keeping form labels, help relationships, touch targets, and
|
||||
control chrome canonical without expanding the low-frequency management surface.
|
||||
When the summary row already owns a canonical unified `Resource`, its expanded
|
||||
|
||||
@@ -1850,6 +1850,13 @@ The Workloads selector path and the Workloads runtime that consumes it
|
||||
are now part of the protected performance surface rather than proof-only
|
||||
context. Future hot-path filter/group/sort/windowing changes must route through
|
||||
the explicit Workloads performance proof policy in the subsystem registry.
|
||||
Node filter options combine the existing canonical Proxmox node snapshot
|
||||
with workload-derived hosts. Empty nodes retain instance-scoped identities
|
||||
and labels without an extra inventory request. Only matching platform scopes
|
||||
include these Proxmox nodes. A filtered-empty guest result cannot present an
|
||||
unrelated source outage as its cause when the unfiltered guest inventory is
|
||||
populated. Verify node option disambiguation and filtered-empty handling in
|
||||
`workloadRouteModel.test.ts` and `WorkloadsSurface.k8s.test.tsx`.
|
||||
Route-backed workload `resource` focus on that hot path is contextual state
|
||||
only, not inferred filter state: opening or closing an inline drawer must not
|
||||
invent, retain, or clear `agent` or node-scope filters unless those filters
|
||||
|
||||
@@ -646,6 +646,14 @@ the page supplies it through the Workloads `tableTitle` slot and the shared
|
||||
states. This heading alignment changes neither workload backup scope nor the
|
||||
storage/recovery evidence carried by the adjacent Backup column and Backups
|
||||
tab.
|
||||
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
|
||||
using its Show all nodes control clears only
|
||||
the node scope, preserving search, status and type filters. These navigation
|
||||
actions do not change backup ownership or recovery evidence. Verify selection,
|
||||
clear and independent node disclosure in the Proxmox table tests and the
|
||||
current `frontend-modern/browser-verification.json` interaction matrix.
|
||||
Those counts and layout controls must not imply
|
||||
protection, verification, or restore readiness beyond the evidence held by the
|
||||
workflow-owned Storage, Backups, Ceph, and Mail surfaces.
|
||||
|
||||
@@ -788,7 +788,12 @@ tables own which row opens, which resource label is exposed, and which
|
||||
source-specific detail payload or drawer is rendered, but the disclosure
|
||||
affordance itself must compose
|
||||
`PlatformResourceDetailToggleButton` from the frontend-primitives-owned
|
||||
`PlatformResourceDetailTableRow.tsx` contract. Future platform tables must not
|
||||
`PlatformResourceDetailTableRow.tsx` contract. When whole-row activation selects workload scope instead of opening details,
|
||||
the shared toggle stays visible on phones and continues to own disclosure
|
||||
semantics and event containment. Proxmox guest selection derives its node
|
||||
scope through `nodeFromResource` and `workloadNodeScopeId`, including empty
|
||||
nodes, rather than inferring identity from the visible guest collection.
|
||||
Future platform tables must not
|
||||
add page-local chevron buttons, bespoke `aria-expanded` handling, or local
|
||||
event-propagation variants for row detail expansion.
|
||||
Inline detail section content follows the same ownership split.
|
||||
|
||||
@@ -1,37 +1,83 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "72809bc1cf71e2493d1f0c492b926110a969bdb4",
|
||||
"verified_at": "2026-09-11T10:26:20.316799Z",
|
||||
"base_sha": "5a4163ff83890005142ed81b025b8dea2a755b56",
|
||||
"verified_at": "2026-09-12T16:22:19.408045Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/cards/DisksCard.tsx"
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadRouteModel.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadTopology.ts",
|
||||
"frontend-modern/src/components/shared/contextualFocus.ts",
|
||||
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/shared/cards/DisksCard.tsx": "cdda554f93b7aecb925d803b0192260ef1a445065c501468a0412b824dc08028"
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "8f0befdd56d332c9fb9e74319b8a7cb3db6fcb5518cd6dbc560f0710d83bd471",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts": "d4e44b1410578876832ae8ba205b5e212154b8b940baa7f42bf72c4b5611ccde",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts": "b669aed8d88287230a72d98d6916ed77c51455ea0c68fbda3d0623a66859944f",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "1d515d140099a05219f97773b988d065bc5784d95910626f0e797ff3acd1e283",
|
||||
"frontend-modern/src/components/Workloads/workloadRouteModel.ts": "fb8d8d45ee9acce9359efefb2778a7a246946c84da25f502cbddb398d0ff02a6",
|
||||
"frontend-modern/src/components/Workloads/workloadTopology.ts": "0917dfb1d9e7b1d65e3ff3afebc9a725b68d40188177fe7181547aebc9b1c3c7",
|
||||
"frontend-modern/src/components/shared/contextualFocus.ts": "2c83a1ab5ca16c45d0c8a90bbdc0a7d75aedb1c1ec46b5513e31fbc26dfcab2e",
|
||||
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "76a2ccdbfa460492f4eb3b6cae91bda72d15915c6b172056c987e18a11116c94",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "33c6804b64cbacc0e770aabdd162a3e5121e0db5947d91fde95297c621772459",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "d4c7072f66a8a8ed50d920a18982ffef2ffca1fd972daa6471d3c0fe5857ef24"
|
||||
},
|
||||
"routes": [
|
||||
"/qualification/disks/?theme=light&count=2",
|
||||
"/qualification/disks/?theme=dark&count=24"
|
||||
"/proxmox/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 600,
|
||||
"height": 500
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 500
|
||||
"width": 1440,
|
||||
"height": 700
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"2 and 24 mounts in light and dark themes",
|
||||
"No nested clipping before scrolling; all mounts remain in outer flow"
|
||||
"Default inventory and inert node hover",
|
||||
"Selected and cleared node with matching guest count",
|
||||
"Empty node and type-filtered empty result",
|
||||
"Node and guest details expanded independently",
|
||||
"Restored node selection after reload",
|
||||
"Mobile filters expanded and collapsed",
|
||||
"Node name default and hover without link decoration",
|
||||
"Keyboard focus ring without underline",
|
||||
"Selected and unselected guest scope",
|
||||
"Separate desktop external website link"
|
||||
],
|
||||
"interactions": [
|
||||
"Focus Before disks, keyboard End then Tab",
|
||||
"Verify final mount in viewport and After disks focused",
|
||||
"Inspect full-page Firefox dark narrow and Chromium light desktop screenshots"
|
||||
"Click node name, count and plain row cell to filter guests",
|
||||
"Activate the node name with Enter and Space, and select with touch",
|
||||
"Reveal and focus the guest heading when it starts below the viewport",
|
||||
"Expand and collapse node details with the chevron without changing guest scope",
|
||||
"Keep external links independent of row selection",
|
||||
"Switch nodes and activate the selected node again to clear its scope with pointer, keyboard and touch",
|
||||
"Preserve search, status and type filters while selecting and clearing nodes",
|
||||
"Open guest details and dismiss with Escape while retaining node scope",
|
||||
"Inspect desktop, intermediate and mobile pixels, including touch controls and empty results",
|
||||
"Assert the selected node keeps its screen position and keyboard focus when deselected, including with a long open details panel",
|
||||
"Select and switch minipc, delly and delly2 with an already visible guest heading and assert unchanged node screen position at all four viewports",
|
||||
"Hover node name and inspect computed decoration and actual pixels",
|
||||
"Tab and Shift+Tab to node name and inspect visible keyboard focus ring",
|
||||
"Enter selects guests and Space clears node scope",
|
||||
"Touch selects and clears the node on phone",
|
||||
"Verify external website icon remains an independent link"
|
||||
],
|
||||
"command": "pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs",
|
||||
"notes": "16 synthetic production-card/CSS cases passed in Firefox142.0.1 and Chromium141.0.7390.37. Screenshots retained in lane outcome screenshots directory. No full installed drawer or reporter Firefox140 ESR reproduction claimed. Production runtime unchanged during completion repair."
|
||||
"command": "Playwright scripts check.cjs, adjacent.cjs, reveal.cjs, deselect.cjs and selection-scroll.cjs against the current local Vite server",
|
||||
"notes": "Live inventory. Final source hashes match the browser-verified implementation. Screenshots and interaction scripts retained locally in the proxmox-node-row-review receipt directory. Rebased onto current main with byte-identical verified runtime files. Final presentation follow-up removes the node-name hover underline. The affected hover, focus, selection and touch matrix passed at1440x1000 and390x844, with other runtime content unchanged."
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ function WorkloadInventoryIssueList(props: { issues: readonly WorkloadInventoryS
|
||||
export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
||||
const state = props.state ?? useWorkloadsState(props);
|
||||
const visibleInventoryIssues = createMemo(() => {
|
||||
// A filtered-empty table is not an inventory outage. Once inventory is
|
||||
// present, unrelated source failures cannot explain an empty selection.
|
||||
if (state.allGuests().length > 0) return [];
|
||||
const issues = state.workloadInventoryIssues?.() ?? [];
|
||||
const forcedPlatform = props.forcedPlatform?.trim().toLowerCase();
|
||||
const platformIssueTypes = forcedPlatform ? PLATFORM_ISSUE_TYPES[forcedPlatform] : undefined;
|
||||
|
||||
@@ -301,6 +301,16 @@ describe('Workloads pod workloads integration', () => {
|
||||
surfaceInitialDataReceived: () => true,
|
||||
allGuests: () => [{ id: 'vm-1' }],
|
||||
filteredGuests: () => [],
|
||||
workloadInventoryIssues: () => [
|
||||
{
|
||||
id: 'other-node',
|
||||
type: 'pve',
|
||||
name: 'other-node',
|
||||
stateLabel: 'Source unreachable',
|
||||
coverageLabel: 'VMs and containers',
|
||||
description: 'Unrelated connection failure.',
|
||||
},
|
||||
],
|
||||
search: () => '',
|
||||
viewMode: () => 'vm',
|
||||
statusMode: () => 'stopped',
|
||||
@@ -320,6 +330,7 @@ describe('Workloads pod workloads integration', () => {
|
||||
expect(screen.getByText('No guests found')).toBeInTheDocument();
|
||||
expect(screen.getByText('No guests match your current filters')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No vSphere VMs')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('workload-inventory-source-issues')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps page-owned table-only empty copy when no operator filters are active', () => {
|
||||
|
||||
+2
-2
@@ -1227,8 +1227,8 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadsWorkloadRouteStateSource).toContain('WORKLOADS_WORKLOAD_ROUTE_RESET_STATE');
|
||||
expect(workloadsWorkloadRouteStateSource).toContain('isWorkloadsRoute,');
|
||||
expect(workloadsWorkloadFilterOptionsSource).toContain("from './workloadFilterConfigModel'");
|
||||
expect(workloadsWorkloadFilterOptionsSource).toContain(
|
||||
'buildWorkloadNodeOptions(platformScopedGuests())',
|
||||
expect(workloadsWorkloadFilterOptionsSource).toMatch(
|
||||
/const workloadNodeOptions = createMemo\(\(\) =>\s*buildWorkloadNodeOptions\(\s*platformScopedGuests\(\),/,
|
||||
);
|
||||
expect(workloadsWorkloadFilterOptionsSource).not.toContain(
|
||||
'const onContextChange = (value: string) =>',
|
||||
|
||||
@@ -62,6 +62,22 @@ describe('workloadRouteModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes empty inventory nodes and disambiguates them against occupied nodes', () => {
|
||||
const options = buildWorkloadNodeOptions(
|
||||
[makeGuest({ node: 'node-a', instance: 'cluster-a' })],
|
||||
[
|
||||
{ name: 'node-a', instance: 'cluster-a' },
|
||||
{ name: 'node-a', instance: 'cluster-b' },
|
||||
{ name: 'empty', instance: 'cluster-a' },
|
||||
],
|
||||
);
|
||||
expect(options).toEqual([
|
||||
{ value: 'cluster-a-empty', label: 'empty' },
|
||||
{ value: 'cluster-a-node-a', label: 'node-a (cluster-a)' },
|
||||
{ value: 'cluster-b-node-a', label: 'node-a (cluster-b)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds app-container host options from Docker host ids and host labels', () => {
|
||||
const options = buildWorkloadNodeOptions([
|
||||
makeGuest({
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createMemo, type Accessor } from 'solid-js';
|
||||
|
||||
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
|
||||
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
|
||||
import {
|
||||
normalizeSourcePlatformQueryValue,
|
||||
sourcePlatformScopeMatchesFilter,
|
||||
} from '@/utils/sourcePlatforms';
|
||||
import { workloadMatchesPlatformScope } from '@/utils/workloads';
|
||||
import type { WorkloadsToolbarFilterConfig } from './workloadsFilterModel';
|
||||
import {
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
buildWorkloadsKubernetesNamespaceOptions,
|
||||
buildWorkloadsVmwareClusterOptions,
|
||||
buildWorkloadNodeOptions,
|
||||
type WorkloadInventoryNode,
|
||||
} from './workloadRouteModel';
|
||||
import {
|
||||
buildWorkloadsContainerRuntimeFilterConfig,
|
||||
@@ -22,6 +26,7 @@ import {
|
||||
|
||||
interface WorkloadsWorkloadFilterOptionsOptions {
|
||||
allGuests: Accessor<WorkloadGuest[]>;
|
||||
nodes?: Accessor<readonly WorkloadInventoryNode[]>;
|
||||
isWorkloadsRoute: Accessor<boolean>;
|
||||
allowEmbeddedScopeFilters: Accessor<boolean>;
|
||||
viewMode: Accessor<ViewMode>;
|
||||
@@ -51,7 +56,14 @@ export function useWorkloadFilterOptions(options: WorkloadsWorkloadFilterOptions
|
||||
.filter((guest) => workloadMatchesPlatformScope(guest, normalizedScope));
|
||||
});
|
||||
|
||||
const workloadNodeOptions = createMemo(() => buildWorkloadNodeOptions(platformScopedGuests()));
|
||||
const workloadNodeOptions = createMemo(() =>
|
||||
buildWorkloadNodeOptions(
|
||||
platformScopedGuests(),
|
||||
sourcePlatformScopeMatchesFilter('proxmox-pve', options.platformScope?.())
|
||||
? (options.nodes?.() ?? [])
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
const kubernetesContextOptions = createMemo(() =>
|
||||
buildWorkloadsKubernetesContextOptions(platformScopedGuests()),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSignal, onMount, type Accessor, type Setter } from 'solid-js';
|
||||
import { useLocation, useNavigate } from '@solidjs/router';
|
||||
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
|
||||
import { deserializeWorkloadViewMode } from './workloadRouteModel';
|
||||
import { deserializeWorkloadViewMode, type WorkloadInventoryNode } from './workloadRouteModel';
|
||||
import {
|
||||
WORKLOADS_WORKLOAD_ROUTE_RESET_STATE,
|
||||
deserializeWorkloadsContainerRuntime,
|
||||
@@ -13,6 +13,7 @@ import { WORKLOADS_QUERY_PARAMS } from '@/routing/resourceLinks';
|
||||
|
||||
export interface WorkloadRouteStateOptions {
|
||||
allGuests: Accessor<WorkloadGuest[]>;
|
||||
nodes?: Accessor<readonly WorkloadInventoryNode[]>;
|
||||
forcedPlatform?: string;
|
||||
forcedViewMode?: ViewMode;
|
||||
routeStateEnabled?: Accessor<boolean>;
|
||||
@@ -125,6 +126,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) {
|
||||
|
||||
const filterOptions = useWorkloadFilterOptions({
|
||||
allGuests: options.allGuests,
|
||||
nodes: options.nodes,
|
||||
isWorkloadsRoute,
|
||||
allowEmbeddedScopeFilters: () => true,
|
||||
viewMode: filterViewMode,
|
||||
|
||||
@@ -251,6 +251,24 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
),
|
||||
);
|
||||
|
||||
const infrastructureNodes = createMemo<Node[]>(() => {
|
||||
const merged = new Map<string, Node>();
|
||||
props.nodes.forEach((node) => merged.set(node.id, node));
|
||||
|
||||
if (workloadsEnabled()) {
|
||||
infrastructureResources()
|
||||
.filter(isProxmoxNodeResource)
|
||||
.map(nodeFromResource)
|
||||
.filter((node): node is Node => Boolean(node))
|
||||
.forEach((node) => {
|
||||
const existing = merged.get(node.id);
|
||||
merged.set(node.id, existing ? { ...existing, ...node } : node);
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
});
|
||||
|
||||
const {
|
||||
clusterFilterConfig,
|
||||
clusterOptions,
|
||||
@@ -282,6 +300,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
containerRuntimeOptions,
|
||||
} = useWorkloadRouteState({
|
||||
allGuests,
|
||||
nodes: infrastructureNodes,
|
||||
forcedPlatform: props.forcedPlatform,
|
||||
forcedViewMode: props.forcedViewMode,
|
||||
showFilters,
|
||||
@@ -356,23 +375,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
routeStateEnabled: props.routeStateEnabled,
|
||||
});
|
||||
|
||||
const infrastructureNodes = createMemo<Node[]>(() => {
|
||||
const merged = new Map<string, Node>();
|
||||
props.nodes.forEach((node) => merged.set(node.id, node));
|
||||
|
||||
if (workloadsEnabled()) {
|
||||
infrastructureResources()
|
||||
.filter(isProxmoxNodeResource)
|
||||
.map(nodeFromResource)
|
||||
.filter((node): node is Node => Boolean(node))
|
||||
.forEach((node) => {
|
||||
const existing = merged.get(node.id);
|
||||
merged.set(node.id, existing ? { ...existing, ...node } : node);
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
});
|
||||
const workloadMemoryDisplayBasis: Accessor<WorkloadsMemoryDisplayBasis> =
|
||||
props.memoryDisplayBasis ?? (() => 'guest');
|
||||
const memoryParentNodeByGuestId = createMemo(() =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Node } from '@/types/api';
|
||||
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
|
||||
import {
|
||||
getWorkloadPlatformScopes,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
getKubernetesContextKey,
|
||||
getWorkloadHostLabel,
|
||||
workloadHostScopeId,
|
||||
workloadNodeScopeId,
|
||||
} from './workloadTopology';
|
||||
|
||||
export type WorkloadNodeOption = WorkloadsFilterSelectOption;
|
||||
@@ -20,10 +22,24 @@ export const deserializeWorkloadViewMode = (raw: unknown): ViewMode => {
|
||||
return normalizeWorkloadViewModeParam(raw) ?? 'all';
|
||||
};
|
||||
|
||||
export const buildWorkloadNodeOptions = (guests: WorkloadGuest[]): WorkloadNodeOption[] => {
|
||||
export type WorkloadInventoryNode = Pick<Node, 'name' | 'instance'>;
|
||||
|
||||
export const buildWorkloadNodeOptions = (
|
||||
guests: WorkloadGuest[],
|
||||
nodes: readonly WorkloadInventoryNode[] = [],
|
||||
): WorkloadNodeOption[] => {
|
||||
const labelsByScope = new Map<string, string>();
|
||||
const scopesByLabel = new Map<string, Set<string>>();
|
||||
|
||||
// Inventory owns node identity even when a node currently has no guests.
|
||||
for (const node of nodes) {
|
||||
const name = node.name.trim();
|
||||
if (!name) continue;
|
||||
const scopes = scopesByLabel.get(name) ?? new Set<string>();
|
||||
scopes.add(workloadNodeScopeId({ node: name, instance: node.instance }));
|
||||
scopesByLabel.set(name, scopes);
|
||||
}
|
||||
|
||||
for (const guest of guests) {
|
||||
const type = resolveWorkloadType(guest);
|
||||
if (type === 'pod') continue;
|
||||
@@ -62,6 +78,17 @@ export const buildWorkloadNodeOptions = (guests: WorkloadGuest[]): WorkloadNodeO
|
||||
labelsByScope.set(scope, label);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const name = node.name.trim();
|
||||
if (!name) continue;
|
||||
const instance = (node.instance || '').trim();
|
||||
const scope = workloadNodeScopeId({ node: name, instance });
|
||||
if (labelsByScope.has(scope)) continue;
|
||||
const label =
|
||||
(scopesByLabel.get(name)?.size ?? 0) > 1 && instance ? `${name} (${instance})` : name;
|
||||
labelsByScope.set(scope, label);
|
||||
}
|
||||
|
||||
return Array.from(labelsByScope.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
@@ -30,7 +30,7 @@ const dedupeTrimmed = (values: Array<string | null | undefined>): string[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const workloadNodeScopeId = (guest: WorkloadGuest): string =>
|
||||
export const workloadNodeScopeId = (guest: Pick<WorkloadGuest, 'instance' | 'node'>): string =>
|
||||
`${(guest.instance || '').trim()}-${(guest.node || '').trim()}`;
|
||||
|
||||
export const getKubernetesContextKey = (guest: WorkloadGuest): string => {
|
||||
|
||||
@@ -7700,6 +7700,12 @@ describe('shared primitive guardrails', () => {
|
||||
]) {
|
||||
expect(source).toContain('PlatformResourceDetailToggleButton');
|
||||
}
|
||||
// A row with a different primary action must retain the shared disclosure.
|
||||
expect(proxmoxNodesTableSource).toContain('hideWhenRowTappableOnMobile={false}');
|
||||
expect(proxmoxNodesTableSource).not.toContain('<SummaryRowActionButton');
|
||||
expect(platformResourceDetailTableRowSource).toContain(
|
||||
'hideWhenRowTappableOnMobile={props.hideWhenRowTappableOnMobile ?? true}',
|
||||
);
|
||||
expect(agentsMachinesTableSource).not.toContain('data-agent-machine-expand-icon');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { revealElementInViewport } from '../contextualFocus';
|
||||
|
||||
const target = (top: number, bottom: number) => {
|
||||
const element = document.createElement('h2');
|
||||
element.getBoundingClientRect = () => ({ top, bottom }) as DOMRect;
|
||||
return element;
|
||||
};
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('conditional contextual reveal', () => {
|
||||
it('does not move a visible heading even when extra result space is requested', () => {
|
||||
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
expect(
|
||||
revealElementInViewport({
|
||||
element: target(window.innerHeight - 30, window.innerHeight - 10),
|
||||
bottomPadding: 96,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals an off-screen heading with room for its first results', () => {
|
||||
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
expect(
|
||||
revealElementInViewport({
|
||||
element: target(window.innerHeight + 40, window.innerHeight + 60),
|
||||
bottomPadding: 96,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(scroll).toHaveBeenCalledWith({ top: window.scrollY + 156, behavior: 'instant' });
|
||||
});
|
||||
|
||||
it('uses the clipped scroll-container viewport instead of moving the document', () => {
|
||||
const scroller = document.createElement('div');
|
||||
scroller.style.overflowY = 'auto';
|
||||
Object.defineProperties(scroller, {
|
||||
clientHeight: { value: 300 },
|
||||
scrollHeight: { value: 1000 },
|
||||
});
|
||||
scroller.getBoundingClientRect = () => ({ top: 100, bottom: 400 }) as DOMRect;
|
||||
scroller.scrollTop = 25;
|
||||
scroller.scrollTo = vi.fn();
|
||||
const element = target(450, 470);
|
||||
scroller.append(element);
|
||||
expect(revealElementInViewport({ element })).toBe(true);
|
||||
expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 95, behavior: 'instant' });
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,25 @@ const scrollVerticalScroller = (
|
||||
}
|
||||
};
|
||||
|
||||
// Leave a visible target anchored. Only off-screen navigation needs a reveal.
|
||||
export const revealElementInViewport = (options: {
|
||||
element: HTMLElement;
|
||||
bottomPadding?: number;
|
||||
behavior?: ScrollBehavior;
|
||||
}): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const scroller = resolveVerticalScroller(options.element);
|
||||
const metrics = getScrollerMetrics(scroller);
|
||||
const rect = options.element.getBoundingClientRect();
|
||||
const top = Math.max(0, metrics.top);
|
||||
const bottom = Math.min(window.innerHeight, metrics.bottom);
|
||||
if (rect.top >= top && rect.bottom <= bottom) return false;
|
||||
const offset =
|
||||
rect.top < top ? rect.top - top : rect.bottom - bottom + (options.bottomPadding ?? 0);
|
||||
scrollVerticalScroller(scroller, metrics.scrollTop + offset, options.behavior ?? 'instant');
|
||||
return true;
|
||||
};
|
||||
|
||||
export const findInlineDetailElement = (
|
||||
root: ParentNode | null | undefined,
|
||||
seriesId: string | null | undefined,
|
||||
|
||||
@@ -59,6 +59,7 @@ export function getPlatformResourceDetailRowInteractionProps(
|
||||
export const PlatformResourceDetailToggleButton: Component<{
|
||||
expanded: boolean;
|
||||
resourceLabel: string;
|
||||
hideWhenRowTappableOnMobile?: boolean;
|
||||
controlsId?: string;
|
||||
class?: string;
|
||||
onToggle: () => void;
|
||||
@@ -69,7 +70,7 @@ export const PlatformResourceDetailToggleButton: Component<{
|
||||
subjectLabel={`details for ${props.resourceLabel}`}
|
||||
controlsId={props.controlsId}
|
||||
class={props.class}
|
||||
hideWhenRowTappableOnMobile
|
||||
hideWhenRowTappableOnMobile={props.hideWhenRowTappableOnMobile ?? true}
|
||||
onAction={props.onToggle}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
||||
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { SUMMARY_ROW_ACTION_BUTTON_FOCUS_CLASS } from '@/components/shared/summaryInteractionA11y';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { getNodeExternalUrl } from '@/utils/nodes';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
@@ -46,7 +47,10 @@ import {
|
||||
getPlatformTableCellClassForKind,
|
||||
type PlatformTableSortValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
|
||||
import {
|
||||
getPlatformResourceDetailRowInteractionProps,
|
||||
PlatformResourceDetailToggleButton,
|
||||
} from '@/features/platformPage/PlatformResourceDetailTableRow';
|
||||
import { type ProxmoxEstateTopology } from '@/features/platformPage/platformEstateOverviewModel';
|
||||
import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel';
|
||||
import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel';
|
||||
@@ -204,6 +208,8 @@ export const ProxmoxNodesTable: Component<{
|
||||
emptyDescription: string;
|
||||
topology?: ProxmoxEstateTopology;
|
||||
inventoryCountsVisible?: Accessor<boolean>;
|
||||
onShowGuests?: (node: Resource) => void;
|
||||
guestFilterNodeId?: Accessor<string | null>;
|
||||
}> = (props) => {
|
||||
const breakpoint = useBreakpoint();
|
||||
const inventoryCountsVisible = () => props.inventoryCountsVisible?.() ?? true;
|
||||
@@ -375,6 +381,12 @@ export const ProxmoxNodesTable: Component<{
|
||||
const version = () => asTrimmedString(getResourceVersion(node));
|
||||
const cluster = () => getResourceClusterLabel(node);
|
||||
const counts = () => countGuestsForNode(props.guests, node);
|
||||
const isGuestFilterSelected = () => props.guestFilterNodeId?.() === node.id;
|
||||
const showGuests = () => props.onShowGuests?.(node);
|
||||
const rowInteraction = getPlatformResourceDetailRowInteractionProps({
|
||||
expanded: false,
|
||||
onToggle: showGuests,
|
||||
});
|
||||
const indicator = () => getSimpleStatusIndicator(node.status);
|
||||
const connectionHealth = createMemo(() =>
|
||||
(asTrimmedString(node.proxmox?.connectionHealth) ?? '').toLowerCase(),
|
||||
@@ -484,6 +496,7 @@ export const ProxmoxNodesTable: Component<{
|
||||
expanded={isSelected()}
|
||||
resourceLabel={name()}
|
||||
controlsId={detailRowId()}
|
||||
hideWhenRowTappableOnMobile={false}
|
||||
onToggle={toggleNodeDrawer}
|
||||
/>
|
||||
<StatusDot
|
||||
@@ -504,7 +517,24 @@ export const ProxmoxNodesTable: Component<{
|
||||
: `Open ${name()} web interface`
|
||||
}
|
||||
>
|
||||
{visibleNodeLabel()}
|
||||
<button
|
||||
type="button"
|
||||
class={`block max-w-full truncate rounded-sm text-left ${SUMMARY_ROW_ACTION_BUTTON_FOCUS_CLASS}`}
|
||||
aria-label={`Show guests on ${name()}`}
|
||||
aria-controls="proxmox-guests-section"
|
||||
aria-pressed={isGuestFilterSelected()}
|
||||
title={
|
||||
isGuestFilterSelected()
|
||||
? `Clear guest filter for ${name()}`
|
||||
: `Show guests on ${name()}`
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
showGuests();
|
||||
}}
|
||||
>
|
||||
{visibleNodeLabel()}
|
||||
</button>
|
||||
</ResourceNameWithWebInterfaceLink>
|
||||
</div>
|
||||
<Show when={!isOnline()}>
|
||||
@@ -721,7 +751,8 @@ export const ProxmoxNodesTable: Component<{
|
||||
} ${isOnline() ? '' : 'opacity-60'}`}
|
||||
data-proxmox-host-row={node.id}
|
||||
data-workload-alert-accent={alertAccentTone()}
|
||||
onClick={toggleNodeDrawer}
|
||||
data-summary-row-active={isGuestFilterSelected() ? 'true' : undefined}
|
||||
onClick={rowInteraction.onClick}
|
||||
>
|
||||
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
|
||||
</TableRow>
|
||||
|
||||
@@ -11,6 +11,10 @@ import StorageSurface from '@/components/Storage/Storage';
|
||||
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
|
||||
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
|
||||
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
|
||||
import { workloadNodeScopeId } from '@/components/Workloads/workloadTopology';
|
||||
import { revealElementInViewport } from '@/components/shared/contextualFocus';
|
||||
import { nodeFromResource } from '@/utils/resourceStateAdapters';
|
||||
import { TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS } from '@/components/shared/TableCardHeader';
|
||||
import {
|
||||
DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
|
||||
getWorkloadsMetricFilterProps,
|
||||
@@ -470,6 +474,57 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
workloadsState.allGuests().length > 0,
|
||||
);
|
||||
const estateTopology = createMemo(() => buildProxmoxEstateTopology(currentModel().resources));
|
||||
// Use the same node identity as the workload table, including empty nodes.
|
||||
const nodeScopes = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
currentModel().pveNodes.map((resource) => {
|
||||
const node = nodeFromResource(resource);
|
||||
return [
|
||||
resource.id,
|
||||
workloadNodeScopeId({
|
||||
node: node?.name ?? resource.name,
|
||||
instance: node?.instance ?? '',
|
||||
}),
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
const selectedScope = () => workloadsState.selectedNode() || workloadsState.selectedHostHint();
|
||||
const selectedNode = createMemo(() =>
|
||||
currentModel().pveNodes.find((node) => nodeScopes().get(node.id) === selectedScope()),
|
||||
);
|
||||
const selectedNodeLabel = createMemo(() => {
|
||||
const scope = selectedScope();
|
||||
if (!scope) return null;
|
||||
return (
|
||||
selectedNode()?.name ||
|
||||
workloadsState.hostFilterConfig()?.options.find((option) => option.value === scope)?.label ||
|
||||
scope
|
||||
);
|
||||
});
|
||||
let guestsHeading: HTMLHeadingElement | undefined;
|
||||
const focusGuests = () => {
|
||||
guestsHeading?.focus({ preventScroll: true });
|
||||
if (guestsHeading) {
|
||||
revealElementInViewport({ element: guestsHeading, bottomPadding: 96 });
|
||||
}
|
||||
};
|
||||
const showNodeGuests = (node: Resource) => {
|
||||
const scope = nodeScopes().get(node.id);
|
||||
if (!scope) return;
|
||||
const nextScope = selectedScope() === scope ? null : scope;
|
||||
workloadsState.handleNodeSelect(nextScope, nextScope ? 'pve' : null);
|
||||
if (nextScope) {
|
||||
requestAnimationFrame(() => {
|
||||
if (selectedScope() === nextScope) focusGuests();
|
||||
});
|
||||
}
|
||||
};
|
||||
const clearNodeFilter = () => {
|
||||
workloadsState.handleNodeSelect(null, null);
|
||||
guestsHeading?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={overviewWidth.setElement} class="pulse-wide-data-surface flex flex-col gap-4">
|
||||
@@ -486,6 +541,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
|
||||
topology={estateTopology()}
|
||||
inventoryCountsVisible={props.inventoryCountsVisible}
|
||||
onShowGuests={showNodeGuests}
|
||||
guestFilterNodeId={() => selectedNode()?.id ?? null}
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
@@ -550,14 +607,30 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
emptyStateTitle="No Proxmox workloads"
|
||||
emptyStateDescription="Proxmox VMs and LXCs appear here when inventory is available."
|
||||
tableTitle={
|
||||
<h2 id="proxmox-guests-heading" class="inline-flex items-center gap-1.5">
|
||||
Guests
|
||||
<Show when={showSharedFilterToolbar()}>
|
||||
<span class="font-semibold tabular-nums text-base-content">
|
||||
{workloadsState.allGuests().length}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h2
|
||||
ref={guestsHeading}
|
||||
id="proxmox-guests-heading"
|
||||
tabIndex={-1}
|
||||
class="inline-flex flex-wrap items-center gap-1.5 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
>
|
||||
{selectedNodeLabel() ? `Guests on ${selectedNodeLabel()}` : 'Guests'}
|
||||
<Show when={showSharedFilterToolbar()}>
|
||||
<span class="font-semibold tabular-nums text-base-content">
|
||||
{workloadsState.filteredGuests().length}
|
||||
</span>
|
||||
</Show>
|
||||
</h2>
|
||||
<Show when={selectedNodeLabel()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS} min-h-6`}
|
||||
onClick={clearNodeFilter}
|
||||
>
|
||||
Show all nodes
|
||||
</button>
|
||||
</Show>
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -618,11 +618,13 @@ describe('ProxmoxNodesTable', () => {
|
||||
expect(rowNames()).toEqual(['pve-node-1', 'pve-node-2']);
|
||||
});
|
||||
|
||||
it('opens the host details drawer from the host-owned top table row', async () => {
|
||||
it('selects guests from the row and reserves the chevron for node details', async () => {
|
||||
const onShowGuests = vi.fn();
|
||||
render(() => (
|
||||
<ProxmoxNodesTable
|
||||
nodes={[makeNodeResource()]}
|
||||
guests={[]}
|
||||
onShowGuests={onShowGuests}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No Proxmox VE nodes"
|
||||
emptyDescription="No nodes"
|
||||
@@ -639,6 +641,10 @@ describe('ProxmoxNodesTable', () => {
|
||||
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(row!);
|
||||
expect(onShowGuests).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Expand details for pve-node-1' }));
|
||||
expect(onShowGuests).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(row).not.toHaveAttribute('aria-expanded');
|
||||
expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
|
||||
@@ -650,7 +656,8 @@ describe('ProxmoxNodesTable', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await fireEvent.click(row!);
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Collapse details for pve-node-1' }));
|
||||
expect(onShowGuests).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(row).not.toHaveAttribute('aria-expanded');
|
||||
expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
|
||||
|
||||
@@ -16,6 +16,8 @@ const mockTotalStats = vi.hoisted(() => vi.fn());
|
||||
const mockNodesTableProps = vi.hoisted(() => vi.fn());
|
||||
const mockBackupsTableProps = vi.hoisted(() => vi.fn());
|
||||
const mockWorkloadSearch = vi.hoisted(() => vi.fn(() => ''));
|
||||
const mockSelectedNode = vi.hoisted(() => vi.fn<() => string | null>(() => null));
|
||||
const mockHandleNodeSelect = vi.hoisted(() => vi.fn());
|
||||
|
||||
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
|
||||
({
|
||||
@@ -90,6 +92,9 @@ vi.mock('@/components/Workloads/useWorkloadsState', () => ({
|
||||
surfaceConnected: () => false,
|
||||
surfaceInitialDataReceived: () => false,
|
||||
allGuests: () => [],
|
||||
selectedNode: mockSelectedNode,
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
selectedHostHint: () => null,
|
||||
totalStats: mockTotalStats,
|
||||
search: mockWorkloadSearch,
|
||||
setSearch: vi.fn(),
|
||||
@@ -154,6 +159,8 @@ const renderSurface = () =>
|
||||
|
||||
describe('ProxmoxPageSurface contract', () => {
|
||||
beforeEach(() => {
|
||||
mockSelectedNode.mockReturnValue(null);
|
||||
mockHandleNodeSelect.mockClear();
|
||||
mockPathname.mockReturnValue('/proxmox/overview');
|
||||
mockVersionInfo.mockReturnValue(null);
|
||||
mockTotalStats.mockReturnValue({
|
||||
@@ -224,6 +231,33 @@ describe('ProxmoxPageSurface contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles only the node scope when the selected node is activated again', () => {
|
||||
const node = makeResource({
|
||||
id: 'agent:pve1',
|
||||
name: 'pve1',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'pve1', instance: 'lab' },
|
||||
});
|
||||
setResources([node]);
|
||||
renderSurface();
|
||||
const { onShowGuests } = mockNodesTableProps.mock.lastCall![0];
|
||||
const revealFrame = vi.spyOn(window, 'requestAnimationFrame');
|
||||
|
||||
onShowGuests(node);
|
||||
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('lab-pve1', 'pve');
|
||||
expect(revealFrame).toHaveBeenCalledTimes(1);
|
||||
revealFrame.mockClear();
|
||||
mockSelectedNode.mockReturnValue('lab-pve1');
|
||||
onShowGuests(node);
|
||||
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith(null, null);
|
||||
expect(revealFrame).not.toHaveBeenCalled();
|
||||
mockSelectedNode.mockReturnValue('lab-other');
|
||||
onShowGuests(node);
|
||||
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('lab-pve1', 'pve');
|
||||
expect(revealFrame).toHaveBeenCalledTimes(1);
|
||||
revealFrame.mockRestore();
|
||||
});
|
||||
|
||||
it('passes committed workload search terms to the node table', () => {
|
||||
mockWorkloadSearch.mockReturnValue('reporting-api-01, wireguard-edge-01');
|
||||
setResources([
|
||||
|
||||
@@ -665,9 +665,13 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(workloadsControlsStateSource).toContain('DEFAULT_WORKLOADS_SORT_KEY');
|
||||
expect(workloadsWorkloadRouteStateSource).toContain('useWorkloadFilterOptions');
|
||||
expect(workloadsWorkloadFilterOptionsSource).toContain("from './workloadFilterConfigModel'");
|
||||
expect(workloadsWorkloadFilterOptionsSource).toContain(
|
||||
'buildWorkloadNodeOptions(platformScopedGuests())',
|
||||
expect(workloadsWorkloadFilterOptionsSource).toMatch(
|
||||
/buildWorkloadNodeOptions\(\s*platformScopedGuests\(\),/,
|
||||
);
|
||||
expect(workloadsWorkloadFilterOptionsSource).toContain(
|
||||
"sourcePlatformScopeMatchesFilter('proxmox-pve', options.platformScope?.())",
|
||||
);
|
||||
expect(workloadsWorkloadFilterOptionsSource).toContain('? (options.nodes?.() ?? [])');
|
||||
expect(workloadsWorkloadFilterOptionsSource).not.toContain(
|
||||
'const onContextChange = (value: string) =>',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user