mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Show nested Docker context in LXC drawers
This commit is contained in:
parent
87e19f5101
commit
23e100e4c0
19 changed files with 618 additions and 24 deletions
|
|
@ -828,10 +828,17 @@ not a replacement status card, CTA band, or page-local nested card.
|
|||
canonical resource types.
|
||||
Workload page membership must use the canonical `platformScopes` list when
|
||||
present instead of treating `platformType` as exclusive ownership. A Docker
|
||||
or Podman app-container can therefore be visible on both the container
|
||||
runtime lens and its owning platform page, such as Proxmox when the runtime
|
||||
is detected inside a PVE LXC, while TrueNAS app containers stay scoped to
|
||||
TrueNAS even when their runtime metadata uses the shared Docker facet.
|
||||
or Podman app-container can therefore carry both the container runtime lens
|
||||
and its owning platform page in routing/filter context, such as Proxmox
|
||||
when the runtime is detected inside a PVE LXC, while TrueNAS app containers
|
||||
stay scoped to TrueNAS even when their runtime metadata uses the shared
|
||||
Docker facet. That membership overlap is not permission to duplicate the
|
||||
detailed container table into every platform overview: Proxmox Overview
|
||||
keeps the default Workloads peer table to VMs and LXCs, and Docker-in-LXC
|
||||
evidence belongs as LXC drawer detail while `/docker`
|
||||
remains the canonical detailed Docker / Podman container lens. The overview
|
||||
table should not add peer rows, badges, or child rows for Docker containers;
|
||||
those signals compete with VM/LXC state and belong one click down.
|
||||
Primary navigation uses that same membership model: the Docker route is
|
||||
the container-runtime lens and may be labelled `Containers` in the shell,
|
||||
while shared source badges, filters, and runtime management copy continue
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ regression protection.
|
|||
102. `frontend-modern/src/useAppRuntimeState.ts`
|
||||
103. `frontend-modern/src/utils/storageSummaryCache.ts`
|
||||
104. `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`
|
||||
105. `frontend-modern/src/components/Workloads/nestedWorkloadContext.ts`
|
||||
106. `frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -154,6 +156,17 @@ regression protection.
|
|||
`frontend-modern/src/hooks/useWorkloads.ts` must consume canonical
|
||||
`platformScopes`; page surfaces must not rebuild platform membership with
|
||||
ad hoc source or runtime scans.
|
||||
Page-owned workload-type exclusions are part of that same hot path:
|
||||
`useWorkloadsState` must apply them before dedupe, filter-option
|
||||
derivation, summary statistics, and row rendering so a platform overview
|
||||
that demotes nested workloads does not carry hidden rows through downstream
|
||||
table state.
|
||||
When a platform page still needs a visible signal for those demoted nested
|
||||
workloads, `nestedWorkloadContext.ts` must derive a bounded summary from the
|
||||
same raw workload snapshot before row rendering. That derivation must use
|
||||
canonical workload IDs and ambiguity-safe runtime-host matching; row and
|
||||
drawer components may render the summary they are given, but they must not
|
||||
scan hidden workloads per row or guess ownership from ambiguous VMIDs.
|
||||
The same `useWorkloads.ts` mapping owns canonical-field fallback for
|
||||
per-row scalars the workload table reads. `WorkloadGuest.uptime` must
|
||||
fall back to the canonical `Resource.Uptime` field after the
|
||||
|
|
|
|||
|
|
@ -141,6 +141,11 @@ dense, and backed by the canonical guest `LastBackup` synchronization from PVE
|
|||
storage and PBS evidence; the Proxmox Backups tab is the drilldown for
|
||||
coverage, restore-point, and source-detail evidence, not the first place users
|
||||
must visit to learn whether a guest is backed up.
|
||||
That overview Backup signal belongs to Proxmox VMs and LXCs. If the embedded
|
||||
Workloads table demotes Docker-in-LXC `app-container` rows out of peer
|
||||
membership, the backup surface must still receive the page model's Proxmox
|
||||
guest inventory for VM/LXC coverage and must not reinterpret Docker runtime
|
||||
membership as backup or recovery ownership.
|
||||
The platform page embedding point may pass read-only Proxmox guest inventory
|
||||
into that backup surface solely for workload identity correlation; the backup
|
||||
surface must still source restore evidence from the PVE/PBS backup APIs rather
|
||||
|
|
|
|||
|
|
@ -2282,6 +2282,15 @@ overlap: Docker/Podman resources reported from a Proxmox LXC carry both
|
|||
`proxmox-pve` and `docker`, but TrueNAS app containers that reuse
|
||||
`DockerData` for runtime metadata remain scoped to `truenas` and must not be
|
||||
promoted to Docker-managed action targets.
|
||||
That overlap is not default peer-row membership on every platform overview.
|
||||
The Docker / Podman runtime lens is the canonical detailed table for Docker
|
||||
containers, while the Proxmox Overview workload table stays focused on VMs and
|
||||
LXCs. Proxmox may surface Docker-inside-LXC evidence as LXC drawer detail, but
|
||||
it must not promote those `app-container` rows into the default Proxmox peer
|
||||
workload table or add always-visible child rows that compete with VM/LXC scan
|
||||
flow. That nested context must be read-only and keyed by the same canonical
|
||||
Proxmox guest identity and Docker host source identity used by the resource
|
||||
model; ambiguous runtime-host matches must be omitted rather than guessed.
|
||||
The same facet bundle now also returns grouped recent-change counts by
|
||||
canonical change kind, so the detail drawer can surface the distribution of
|
||||
state transitions, restarts, config updates, and anomalies without
|
||||
|
|
|
|||
|
|
@ -571,6 +571,38 @@ describe('GuestDrawer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Nested workload context card', () => {
|
||||
it('shows nested Docker container count, sample rows, and canonical Docker link', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({ name: 'media-lxc', type: 'lxc', workloadType: 'system-container' })}
|
||||
nestedWorkloadContext={{
|
||||
type: 'app-container',
|
||||
label: 'Docker',
|
||||
title: 'Nested Docker',
|
||||
count: 2,
|
||||
href: '/docker',
|
||||
items: [
|
||||
{ id: 'container-1', name: 'frigate', runtimeLabel: 'Docker', status: 'running' },
|
||||
{ id: 'container-2', name: 'mosquitto', runtimeLabel: 'Docker', status: 'exited' },
|
||||
],
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
const card = screen.getByTestId('nested-workload-context-card');
|
||||
expect(within(card).getByText('Nested Docker')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Containers')).toBeInTheDocument();
|
||||
expect(within(card).getByText('2')).toBeInTheDocument();
|
||||
expect(within(card).getByText('frigate')).toBeInTheDocument();
|
||||
expect(within(card).getByText('mosquitto')).toBeInTheDocument();
|
||||
expect(
|
||||
within(card).getByRole('link', { name: 'Open Docker page for media-lxc' }),
|
||||
).toHaveAttribute('href', '/docker');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent info ──
|
||||
|
||||
describe('Agent info', () => {
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
hasOsInfo={hasOsInfo()}
|
||||
ipAddresses={ipAddresses()}
|
||||
networkInterfaces={networkInterfaces()}
|
||||
nestedWorkloadContext={props.nestedWorkloadContext}
|
||||
normalizedTags={normalizedTags()}
|
||||
onCustomUrlChange={props.onCustomUrlChange}
|
||||
customUrl={props.customUrl}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { For, Show } from 'solid-js';
|
||||
import { TerminalSquare } from 'lucide-solid';
|
||||
import BoxIcon from 'lucide-solid/icons/box';
|
||||
|
||||
import { formatDiscoveryAge } from '@/api/discovery';
|
||||
import { buildInfrastructureOnboardingPath } from '@/components/Settings/infrastructureWorkspaceModel';
|
||||
|
|
@ -13,6 +14,7 @@ import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
|||
|
||||
import { DiskList } from './DiskList';
|
||||
import { getGuestDrawerMemoryRows, isGuestDrawerVM } from './guestDrawerModel';
|
||||
import type { NestedWorkloadContext } from './nestedWorkloadContext';
|
||||
import {
|
||||
IN_GUEST_AGENT_INSTALL_ACTION_LABEL,
|
||||
IN_GUEST_AGENT_INSTALL_TITLE,
|
||||
|
|
@ -35,6 +37,7 @@ interface GuestDrawerOverviewProps {
|
|||
showInGuestAgentInstallCue: boolean;
|
||||
ipAddresses: string[];
|
||||
networkInterfaces: NonNullable<GuestDrawerProps['guest']['networkInterfaces']>;
|
||||
nestedWorkloadContext?: NestedWorkloadContext;
|
||||
normalizedTags: string[];
|
||||
onCustomUrlChange?: GuestDrawerProps['onCustomUrlChange'];
|
||||
customUrl?: GuestDrawerProps['customUrl'];
|
||||
|
|
@ -215,6 +218,54 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.nestedWorkloadContext}>
|
||||
{(context) => {
|
||||
const visibleItems = () => context().items.slice(0, 4);
|
||||
const hiddenCount = () => Math.max(0, context().count - visibleItems().length);
|
||||
return (
|
||||
<div
|
||||
class="rounded border border-border bg-surface p-3 shadow-sm"
|
||||
data-testid="nested-workload-context-card"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
|
||||
{context().title}
|
||||
</h3>
|
||||
<a
|
||||
href={context().href}
|
||||
class="inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold text-blue-600 underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-blue-300"
|
||||
aria-label={`Open ${context().label} page for ${props.guest.name}`}
|
||||
>
|
||||
<BoxIcon class="h-3 w-3" aria-hidden="true" />
|
||||
<span>Open Docker</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="space-y-1.5 text-[11px]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted">Containers</span>
|
||||
<span class="font-medium text-base-content">{context().count}</span>
|
||||
</div>
|
||||
<For each={visibleItems()}>
|
||||
{(item) => (
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||
<span class="truncate font-medium text-base-content" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span class="rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={hiddenCount() > 0}>
|
||||
<div class="text-[10px] font-medium text-muted">+{hiddenCount()} more</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
|
||||
{/* vSphere placement card: vCenter / Datacenter / Cluster live on
|
||||
WorkloadGuest.vmware and aren't surfaced by System (Node already
|
||||
shows the runtime host). Render only when the workload is a
|
||||
|
|
@ -324,7 +375,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
|||
{(row) => (
|
||||
<div class="flex items-center justify-between gap-2 min-w-0">
|
||||
<span class="shrink-0 text-muted">{row.label}</span>
|
||||
<span class="truncate text-right font-medium text-base-content" title={row.value}>
|
||||
<span
|
||||
class="truncate text-right font-medium text-base-content"
|
||||
title={row.value}
|
||||
>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ type WorkloadPanelProps = Pick<
|
|||
| 'focusedSummaryWorkloadGroupId'
|
||||
| 'hoveredSummaryWorkloadGroupScope'
|
||||
| 'isMobile'
|
||||
| 'nestedWorkloadContextByGuestId'
|
||||
| 'nodeByInstance'
|
||||
| 'search'
|
||||
| 'selectedGuestId'
|
||||
|
|
@ -519,6 +520,8 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
|||
const pn = parentNode();
|
||||
return pn ? isNodeOnline(pn) : true;
|
||||
};
|
||||
const nestedWorkloadContext = () =>
|
||||
props.nestedWorkloadContextByGuestId()[guestId()];
|
||||
|
||||
return (
|
||||
<ComponentErrorBoundary name="GuestRow">
|
||||
|
|
@ -564,6 +567,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
|||
guest={guest()}
|
||||
onClose={() => props.setSelectedGuestId(null)}
|
||||
customUrl={metadata()?.customUrl}
|
||||
nestedWorkloadContext={nestedWorkloadContext()}
|
||||
onCustomUrlChange={props.handleCustomUrlUpdate}
|
||||
parentNodeOnline={parentNodeOnline()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import { Show } from 'solid-js';
|
|||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { TableCard } from '@/components/shared/TableCard';
|
||||
import { WorkloadsFilter } from './WorkloadsFilter';
|
||||
import {
|
||||
DEFAULT_WORKLOADS_VIEW_MODE,
|
||||
hasActiveWorkloadsFilters,
|
||||
} from './workloadsFilterModel';
|
||||
import { DEFAULT_WORKLOADS_VIEW_MODE, hasActiveWorkloadsFilters } from './workloadsFilterModel';
|
||||
import { WorkloadsTable } from './WorkloadsTable';
|
||||
import {
|
||||
useWorkloadsState,
|
||||
|
|
@ -124,6 +121,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
|||
focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId}
|
||||
hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope}
|
||||
isMobile={state.isMobile}
|
||||
nestedWorkloadContextByGuestId={state.nestedWorkloadContextByGuestId}
|
||||
nodeByInstance={state.nodeByInstance}
|
||||
search={state.search}
|
||||
selectedGuestId={state.selectedGuestId}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type WorkloadsTableProps = Pick<
|
|||
| 'focusedSummaryWorkloadGroupId'
|
||||
| 'hoveredSummaryWorkloadGroupScope'
|
||||
| 'isMobile'
|
||||
| 'nestedWorkloadContextByGuestId'
|
||||
| 'nodeByInstance'
|
||||
| 'search'
|
||||
| 'selectedGuestId'
|
||||
|
|
@ -116,6 +117,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
|||
focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId}
|
||||
hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope}
|
||||
isMobile={props.isMobile}
|
||||
nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId}
|
||||
nodeByInstance={props.nodeByInstance}
|
||||
search={props.search}
|
||||
selectedGuestId={props.selectedGuestId}
|
||||
|
|
|
|||
|
|
@ -233,6 +233,55 @@ describe('Workloads pod workloads integration', () => {
|
|||
expect(getByTestId('workloads-filter')).toHaveTextContent('host-filter-enabled');
|
||||
});
|
||||
|
||||
it('excludes app-container workloads when a platform page owns them as nested context', async () => {
|
||||
mockLocationSearch = '?type=all';
|
||||
mockWorkloads = [
|
||||
{
|
||||
id: 'pve-lxc-101',
|
||||
vmid: 101,
|
||||
name: 'media-lxc',
|
||||
node: 'pve-a',
|
||||
instance: 'pve-a',
|
||||
status: 'running',
|
||||
type: 'system-container',
|
||||
workloadType: 'system-container',
|
||||
platformType: 'proxmox-pve',
|
||||
platformScopes: ['proxmox-pve'],
|
||||
},
|
||||
{
|
||||
id: 'app-container:pve-a:grafana',
|
||||
vmid: 0,
|
||||
name: 'grafana',
|
||||
node: 'media-lxc',
|
||||
instance: 'pve-a',
|
||||
status: 'running',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformType: 'docker',
|
||||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
containerRuntime: 'docker',
|
||||
},
|
||||
];
|
||||
|
||||
render(() => (
|
||||
<WorkloadsSurface
|
||||
vms={[]}
|
||||
containers={[]}
|
||||
nodes={[]}
|
||||
useWorkloads
|
||||
forcedPlatform="proxmox-pve"
|
||||
forcedViewMode="all"
|
||||
excludedWorkloadTypes={['app-container']}
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('media-lxc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('grafana')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses filtered-empty copy for table-only surfaces when filters remove every workload', () => {
|
||||
render(() => (
|
||||
<WorkloadsSurface
|
||||
|
|
|
|||
|
|
@ -228,7 +228,16 @@ vi.mock('../WorkloadsFilter', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('../GuestDrawer', () => ({
|
||||
GuestDrawer: () => <div data-testid="guest-drawer">drawer</div>,
|
||||
GuestDrawer: (props: { nestedWorkloadContext?: { label: string; count: number } }) => (
|
||||
<div data-testid="guest-drawer">
|
||||
drawer
|
||||
<span data-testid="drawer-nested-context">
|
||||
{props.nestedWorkloadContext
|
||||
? `${props.nestedWorkloadContext.label} ${props.nestedWorkloadContext.count}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../NodeDrawer', () => ({
|
||||
|
|
@ -836,6 +845,55 @@ describe('Workloads performance contract', () => {
|
|||
expect(canonicalId).toBe('shared:node-x:42');
|
||||
});
|
||||
|
||||
it('applies page-owned workload type exclusions before derived row state', async () => {
|
||||
mockLocationSearch = '?type=all';
|
||||
mockWorkloads = [
|
||||
makeGuest(1, {
|
||||
name: 'pve-lxc',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
platformType: 'proxmox-pve',
|
||||
platformScopes: ['proxmox-pve'],
|
||||
}),
|
||||
makeGuest(2, {
|
||||
name: 'nested-docker-container',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformType: 'docker',
|
||||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
containerRuntime: 'docker',
|
||||
dockerHostId: 'proxmox-lxc-docker:cluster-1:node-1:101',
|
||||
}),
|
||||
];
|
||||
|
||||
const { container } = render(() => (
|
||||
<WorkloadsSurface
|
||||
vms={[]}
|
||||
containers={[]}
|
||||
nodes={[]}
|
||||
useWorkloads
|
||||
forcedPlatform="proxmox-pve"
|
||||
forcedViewMode="all"
|
||||
excludedWorkloadTypes={['app-container']}
|
||||
showNestedExcludedWorkloads
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-testid="guest-row-pve-lxc"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(getGuestRowCount(container)).toBe(1);
|
||||
expect(
|
||||
container.querySelector('[data-testid="guest-row-nested-docker-container"]'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByTestId('nested-workload-context-row')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('guest-row-toggle-pve-lxc'));
|
||||
expect(await screen.findByTestId('guest-drawer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-nested-context')).toHaveTextContent('Docker 1');
|
||||
});
|
||||
|
||||
it('routes org scope normalization through the shared helper', () => {
|
||||
expect(workloadsSource).toContain('useWorkloadsState');
|
||||
expect(workloadsStateSource).toContain('useWorkloadGuestMetadataState');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { buildNestedWorkloadContextByGuestId } from '../nestedWorkloadContext';
|
||||
|
||||
const makeGuest = (overrides: Partial<WorkloadGuest>): WorkloadGuest =>
|
||||
({
|
||||
id: 'guest-1',
|
||||
vmid: 101,
|
||||
name: 'guest-1',
|
||||
node: 'node-a',
|
||||
instance: 'pve-a',
|
||||
status: 'running',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
cpu: 0,
|
||||
cpus: 1,
|
||||
memory: { total: 0, used: 0, free: 0, usage: 0 },
|
||||
disk: { total: 0, used: 0, free: 0, usage: 0 },
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 0,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
lastSeen: new Date().toISOString(),
|
||||
...overrides,
|
||||
}) as WorkloadGuest;
|
||||
|
||||
describe('nested workload context', () => {
|
||||
it('maps Proxmox LXC Docker app containers to the owning visible LXC', () => {
|
||||
const lxc = makeGuest({
|
||||
id: 'lxc-141',
|
||||
vmid: 141,
|
||||
name: 'media-lxc',
|
||||
node: 'node-a',
|
||||
instance: 'pve-a',
|
||||
platformScopes: ['proxmox-pve'],
|
||||
});
|
||||
const container = makeGuest({
|
||||
id: 'app-container:frigate',
|
||||
vmid: 0,
|
||||
name: 'frigate',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformType: 'docker',
|
||||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
containerRuntime: 'docker',
|
||||
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
|
||||
image: 'ghcr.io/blakeblackshear/frigate:stable',
|
||||
});
|
||||
|
||||
const result = buildNestedWorkloadContextByGuestId({
|
||||
guests: [lxc, container],
|
||||
visibleGuests: [lxc],
|
||||
excludedWorkloadTypes: ['app-container'],
|
||||
platformScope: 'proxmox-pve',
|
||||
});
|
||||
|
||||
expect(result['pve-a:node-a:141']).toMatchObject({
|
||||
label: 'Docker',
|
||||
count: 1,
|
||||
href: '/docker',
|
||||
items: [{ name: 'frigate', status: 'running' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not guess when Docker host identity only matches ambiguous VMIDs', () => {
|
||||
const firstLxc = makeGuest({
|
||||
id: 'lxc-a-141',
|
||||
vmid: 141,
|
||||
name: 'first-lxc',
|
||||
node: 'node-a',
|
||||
instance: 'pve-a',
|
||||
});
|
||||
const secondLxc = makeGuest({
|
||||
id: 'lxc-b-141',
|
||||
vmid: 141,
|
||||
name: 'second-lxc',
|
||||
node: 'node-b',
|
||||
instance: 'pve-b',
|
||||
});
|
||||
const container = makeGuest({
|
||||
id: 'app-container:ambiguous',
|
||||
vmid: 0,
|
||||
name: 'ambiguous',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
dockerHostId: 'proxmox-lxc-docker:141',
|
||||
});
|
||||
|
||||
const result = buildNestedWorkloadContextByGuestId({
|
||||
guests: [firstLxc, secondLxc, container],
|
||||
visibleGuests: [firstLxc, secondLxc],
|
||||
excludedWorkloadTypes: ['app-container'],
|
||||
platformScope: 'proxmox-pve',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('only builds summaries for workload types the page deliberately excludes', () => {
|
||||
const lxc = makeGuest({ vmid: 141, name: 'media-lxc' });
|
||||
const container = makeGuest({
|
||||
id: 'app-container:frigate',
|
||||
vmid: 0,
|
||||
name: 'frigate',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
|
||||
});
|
||||
|
||||
const result = buildNestedWorkloadContextByGuestId({
|
||||
guests: [lxc, container],
|
||||
visibleGuests: [lxc],
|
||||
excludedWorkloadTypes: [],
|
||||
platformScope: 'proxmox-pve',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
import { formatHistoryChartTooltipValue } from '@/components/shared/historyChartModel';
|
||||
import { formatBytes, formatPercent } from '@/utils/format';
|
||||
import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads';
|
||||
import type { NestedWorkloadContext } from './nestedWorkloadContext';
|
||||
|
||||
type Guest = WorkloadGuest;
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export interface GuestDrawerProps {
|
|||
customUrl?: string;
|
||||
onCustomUrlChange?: (guestId: string, url: string) => void;
|
||||
parentNodeOnline?: boolean;
|
||||
nestedWorkloadContext?: NestedWorkloadContext;
|
||||
}
|
||||
|
||||
export type GuestDrawerTab = 'overview' | 'history' | 'discovery';
|
||||
|
|
@ -301,7 +303,10 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] =
|
|||
const cache = memory.cache ?? 0;
|
||||
|
||||
if (total > 0) {
|
||||
rows.push({ label: 'Usage', value: `${formatPercent((used / total) * 100)} · ${formatBytes(used)}` });
|
||||
rows.push({
|
||||
label: 'Usage',
|
||||
value: `${formatPercent((used / total) * 100)} · ${formatBytes(used)}`,
|
||||
});
|
||||
rows.push({ label: 'Total', value: formatBytes(total) });
|
||||
if (cache > 0) {
|
||||
rows.push({ label: 'Reclaimable cache', value: formatBytes(cache) });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
import type { WorkloadGuest, WorkloadType } from '@/types/workloads';
|
||||
import {
|
||||
getCanonicalWorkloadId,
|
||||
resolveWorkloadType,
|
||||
workloadMatchesPlatformScope,
|
||||
} from '@/utils/workloads';
|
||||
|
||||
const LXC_DOCKER_HOST_PREFIX = 'proxmox-lxc-docker:';
|
||||
const NESTED_WORKLOAD_DETAILS_PATH = '/docker';
|
||||
|
||||
export interface NestedWorkloadContextItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
runtimeLabel: string;
|
||||
}
|
||||
|
||||
export interface NestedWorkloadContext {
|
||||
type: 'app-container';
|
||||
label: string;
|
||||
title: string;
|
||||
count: number;
|
||||
href: string;
|
||||
items: NestedWorkloadContextItem[];
|
||||
}
|
||||
|
||||
export type NestedWorkloadContextByGuestId = Record<string, NestedWorkloadContext>;
|
||||
|
||||
interface BuildNestedWorkloadContextParams {
|
||||
guests: readonly WorkloadGuest[];
|
||||
visibleGuests: readonly WorkloadGuest[];
|
||||
excludedWorkloadTypes?: readonly WorkloadType[];
|
||||
platformScope?: string | null;
|
||||
}
|
||||
|
||||
const cleanText = (value?: string | number | null): string => String(value ?? '').trim();
|
||||
|
||||
const normalizeCandidate = (value?: string | number | null): string =>
|
||||
cleanText(value).toLowerCase();
|
||||
|
||||
const addCandidate = (target: Set<string>, value?: string | number | null): void => {
|
||||
const normalized = normalizeCandidate(value);
|
||||
if (normalized) target.add(normalized);
|
||||
};
|
||||
|
||||
const addHostnameCandidates = (target: Set<string>, value?: string | null): void => {
|
||||
const normalized = normalizeCandidate(value);
|
||||
if (!normalized) return;
|
||||
target.add(normalized);
|
||||
const shortName = normalized.split('.')[0];
|
||||
if (shortName) target.add(shortName);
|
||||
};
|
||||
|
||||
const buildNodeScopedIdentityCandidates = (guest: WorkloadGuest): Set<string> => {
|
||||
const candidates = new Set<string>();
|
||||
const instance = cleanText(guest.instance);
|
||||
const node = cleanText(guest.node);
|
||||
const vmid = Number.isFinite(guest.vmid) ? String(Number(guest.vmid)) : '';
|
||||
|
||||
addCandidate(candidates, getCanonicalWorkloadId(guest));
|
||||
addCandidate(candidates, guest.id);
|
||||
addCandidate(candidates, guest.displayId);
|
||||
addHostnameCandidates(candidates, guest.name);
|
||||
|
||||
if (instance && node && vmid) addCandidate(candidates, `${instance}:${node}:${vmid}`);
|
||||
if (instance && vmid) addCandidate(candidates, `${instance}:${vmid}`);
|
||||
if (node && vmid) addCandidate(candidates, `${node}:${vmid}`);
|
||||
if (vmid) addCandidate(candidates, vmid);
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const addDockerHostIdentitySegments = (target: Set<string>, value?: string | null): void => {
|
||||
const normalized = normalizeCandidate(value);
|
||||
if (!normalized) return;
|
||||
|
||||
addCandidate(target, normalized);
|
||||
const withoutPrefix = normalized.startsWith(LXC_DOCKER_HOST_PREFIX)
|
||||
? normalized.slice(LXC_DOCKER_HOST_PREFIX.length)
|
||||
: normalized;
|
||||
addCandidate(target, withoutPrefix);
|
||||
|
||||
const segments = withoutPrefix.split(':').filter(Boolean);
|
||||
if (segments.length >= 3) addCandidate(target, segments.slice(-3).join(':'));
|
||||
if (segments.length >= 2) addCandidate(target, segments.slice(-2).join(':'));
|
||||
if (segments.length >= 1) addCandidate(target, segments[segments.length - 1]);
|
||||
};
|
||||
|
||||
const buildAppContainerParentCandidates = (guest: WorkloadGuest): Set<string> => {
|
||||
const candidates = new Set<string>();
|
||||
addDockerHostIdentitySegments(candidates, guest.dockerHostId);
|
||||
addHostnameCandidates(candidates, guest.contextLabel);
|
||||
addHostnameCandidates(candidates, guest.node);
|
||||
addHostnameCandidates(candidates, guest.instance);
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const formatRuntimeLabel = (runtime?: string | null): string => {
|
||||
const normalized = normalizeCandidate(runtime);
|
||||
if (normalized === 'podman') return 'Podman';
|
||||
return 'Docker';
|
||||
};
|
||||
|
||||
const formatStatusLabel = (status?: string | null): string => cleanText(status) || 'unknown';
|
||||
|
||||
const createNestedItem = (guest: WorkloadGuest): NestedWorkloadContextItem => ({
|
||||
id: getCanonicalWorkloadId(guest),
|
||||
name: cleanText(guest.name) || cleanText(guest.containerId) || getCanonicalWorkloadId(guest),
|
||||
status: formatStatusLabel(guest.status),
|
||||
runtimeLabel: formatRuntimeLabel(guest.containerRuntime),
|
||||
});
|
||||
|
||||
const chooseContextLabel = (items: readonly NestedWorkloadContextItem[]): string => {
|
||||
const labels = Array.from(new Set(items.map((item) => item.runtimeLabel)));
|
||||
if (labels.length === 1) return labels[0];
|
||||
return 'Containers';
|
||||
};
|
||||
|
||||
export const buildNestedWorkloadContextByGuestId = ({
|
||||
guests,
|
||||
visibleGuests,
|
||||
excludedWorkloadTypes,
|
||||
platformScope,
|
||||
}: BuildNestedWorkloadContextParams): NestedWorkloadContextByGuestId => {
|
||||
const excludedTypes = new Set(excludedWorkloadTypes ?? []);
|
||||
if (!excludedTypes.has('app-container')) return {};
|
||||
|
||||
const parentKeyOwners = new Map<string, string | null>();
|
||||
const parentById = new Map<string, WorkloadGuest>();
|
||||
for (const guest of visibleGuests) {
|
||||
const type = resolveWorkloadType(guest);
|
||||
if (type !== 'vm' && type !== 'system-container') continue;
|
||||
|
||||
const parentId = getCanonicalWorkloadId(guest);
|
||||
parentById.set(parentId, guest);
|
||||
for (const candidate of buildNodeScopedIdentityCandidates(guest)) {
|
||||
const existing = parentKeyOwners.get(candidate);
|
||||
parentKeyOwners.set(
|
||||
candidate,
|
||||
existing === undefined || existing === parentId ? parentId : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const itemsByParentId = new Map<string, NestedWorkloadContextItem[]>();
|
||||
for (const guest of guests) {
|
||||
if (resolveWorkloadType(guest) !== 'app-container') continue;
|
||||
if (!workloadMatchesPlatformScope(guest, platformScope)) continue;
|
||||
|
||||
let parentId: string | null = null;
|
||||
for (const candidate of buildAppContainerParentCandidates(guest)) {
|
||||
const owner = parentKeyOwners.get(candidate);
|
||||
if (owner) {
|
||||
parentId = owner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!parentId || !parentById.has(parentId)) continue;
|
||||
|
||||
const items = itemsByParentId.get(parentId) ?? [];
|
||||
items.push(createNestedItem(guest));
|
||||
itemsByParentId.set(parentId, items);
|
||||
}
|
||||
|
||||
const contexts: NestedWorkloadContextByGuestId = {};
|
||||
for (const [parentId, items] of itemsByParentId) {
|
||||
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const label = chooseContextLabel(sortedItems);
|
||||
contexts[parentId] = {
|
||||
type: 'app-container',
|
||||
label,
|
||||
title: label === 'Containers' ? 'Nested containers' : `Nested ${label}`,
|
||||
count: sortedItems.length,
|
||||
href: NESTED_WORKLOAD_DETAILS_PATH,
|
||||
items: sortedItems,
|
||||
};
|
||||
}
|
||||
|
||||
return contexts;
|
||||
};
|
||||
|
|
@ -3,7 +3,7 @@ import { useNavigate } from '@solidjs/router';
|
|||
import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections';
|
||||
import type { VM, Container, Node } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { ViewMode, WorkloadGuest } from '@/types/workloads';
|
||||
import type { ViewMode, WorkloadGuest, WorkloadType } from '@/types/workloads';
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
getWorkloadsLoadingState,
|
||||
getWorkloadsNoInventoryState,
|
||||
} from '@/utils/workloadEmptyStatePresentation';
|
||||
import { getCanonicalWorkloadId } from '@/utils/workloads';
|
||||
import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads';
|
||||
import { nodeFromResource } from '@/utils/resourceStateAdapters';
|
||||
import {
|
||||
buildWorkloadSummaryGroupScopeMap,
|
||||
|
|
@ -40,6 +40,10 @@ import { useWorkloadsDerivedState } from './useWorkloadsDerivedState';
|
|||
import { useWorkloadRouteState } from './useWorkloadRouteState';
|
||||
import { buildWorkloadInventorySourceIssues } from './workloadInventorySourceIssues';
|
||||
import { useWorkloadTableMetricHistory } from './useWorkloadTableMetricHistory';
|
||||
import {
|
||||
buildNestedWorkloadContextByGuestId,
|
||||
type NestedWorkloadContextByGuestId,
|
||||
} from './nestedWorkloadContext';
|
||||
|
||||
const WORKLOADS_INFRASTRUCTURE_SOURCES_QUERY =
|
||||
'type=agent,docker-host,k8s-cluster,k8s-node,pbs,pmg,storage,physical_disk,ceph';
|
||||
|
|
@ -62,6 +66,8 @@ export interface WorkloadsSurfaceProps {
|
|||
useWorkloads?: boolean;
|
||||
forcedPlatform?: string;
|
||||
forcedViewMode?: ViewMode;
|
||||
excludedWorkloadTypes?: readonly WorkloadType[];
|
||||
showNestedExcludedWorkloads?: boolean;
|
||||
forcedGroupingMode?: WorkloadsGroupingMode;
|
||||
defaultSortKey?: WorkloadsSortKey;
|
||||
filterAriaLabel?: string;
|
||||
|
|
@ -152,8 +158,28 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
return deduped;
|
||||
};
|
||||
|
||||
const allGuests = createMemo<WorkloadGuest[]>(() =>
|
||||
workloadsEnabled() ? dedupeGuests(workloads.workloads()) : [],
|
||||
const excludedWorkloadTypeSet = createMemo(() => new Set(props.excludedWorkloadTypes ?? []));
|
||||
const rawGuests = createMemo<WorkloadGuest[]>(() =>
|
||||
workloadsEnabled() ? workloads.workloads() : [],
|
||||
);
|
||||
const allGuests = createMemo<WorkloadGuest[]>(() => {
|
||||
if (!workloadsEnabled()) return [];
|
||||
const excludedTypes = excludedWorkloadTypeSet();
|
||||
const visibleGuests =
|
||||
excludedTypes.size === 0
|
||||
? rawGuests()
|
||||
: rawGuests().filter((guest) => !excludedTypes.has(resolveWorkloadType(guest)));
|
||||
return dedupeGuests(visibleGuests);
|
||||
});
|
||||
const nestedWorkloadContextByGuestId = createMemo<NestedWorkloadContextByGuestId>(() =>
|
||||
props.showNestedExcludedWorkloads
|
||||
? buildNestedWorkloadContextByGuestId({
|
||||
guests: rawGuests(),
|
||||
visibleGuests: allGuests(),
|
||||
excludedWorkloadTypes: props.excludedWorkloadTypes,
|
||||
platformScope: props.forcedPlatform,
|
||||
})
|
||||
: {},
|
||||
);
|
||||
|
||||
const [showFilters, setShowFilters] = usePersistentSignal<boolean>(
|
||||
|
|
@ -479,6 +505,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
navigate,
|
||||
nodeByInstance,
|
||||
namespaceFilterConfig,
|
||||
nestedWorkloadContextByGuestId,
|
||||
platformFilterConfig: props.suppressPlatformFilter ? () => undefined : platformFilterConfig,
|
||||
platformOptions,
|
||||
reconnect,
|
||||
|
|
|
|||
|
|
@ -256,6 +256,17 @@ describe('platform overview layout guardrails', () => {
|
|||
|
||||
expect(proxmoxPageSurfaceSource).toContain('<ProxmoxNodesTable');
|
||||
expect(proxmoxPageSurfaceSource).toContain('<WorkloadsSurface');
|
||||
expect(proxmoxPageSurfaceSource).toContain(
|
||||
"const PROXMOX_WORKLOAD_EXCLUDED_TYPES = ['app-container'] as const",
|
||||
);
|
||||
expect(proxmoxPageSurfaceSource).toContain(
|
||||
'excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES',
|
||||
);
|
||||
expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads: true');
|
||||
expect(proxmoxPageSurfaceSource).toContain(
|
||||
'excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}',
|
||||
);
|
||||
expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads');
|
||||
expect(dockerPageSurfaceSource).toContain('<DockerHostsTable');
|
||||
expect(dockerPageSurfaceSource).toContain('<DockerContainersTable');
|
||||
expect(dockerPageSurfaceSource).toContain('<DockerImagesTable');
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const PROXMOX_RESOURCE_QUERY =
|
|||
|
||||
const PROXMOX_PLATFORM_FILTER = 'proxmox-pve';
|
||||
const PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE = 'proxmox';
|
||||
const PROXMOX_WORKLOAD_EXCLUDED_TYPES = ['app-container'] as const;
|
||||
const VALID_TABS = new Set<ProxmoxPageTabId>(PROXMOX_TAB_SPECS.map((tab) => tab.id));
|
||||
const PROXMOX_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
|
|
@ -91,9 +92,7 @@ export function ProxmoxPageSurface() {
|
|||
const replicationJobCount = createMemo(() =>
|
||||
replicationJobs.error ? 0 : (replicationJobs() ?? []).length,
|
||||
);
|
||||
const visibleTabs = createMemo(() =>
|
||||
buildVisibleProxmoxTabSpecs(model(), replicationJobCount()),
|
||||
);
|
||||
const visibleTabs = createMemo(() => buildVisibleProxmoxTabSpecs(model(), replicationJobCount()));
|
||||
const visibleTabIds = createMemo(
|
||||
() => new Set<ProxmoxPageTabId>(visibleTabs().map((tab) => tab.id)),
|
||||
);
|
||||
|
|
@ -262,6 +261,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
|||
nodes: [],
|
||||
useWorkloads: true,
|
||||
forcedPlatform: PROXMOX_PLATFORM_FILTER,
|
||||
excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES,
|
||||
showNestedExcludedWorkloads: true,
|
||||
suppressPlatformFilter: true,
|
||||
// The polymorphic 'info' column always holds the VMID on a pure-Proxmox
|
||||
// surface, so label it with the platform vocabulary instead of 'Info'.
|
||||
|
|
@ -344,6 +345,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
|||
nodes={[]}
|
||||
useWorkloads
|
||||
forcedPlatform={PROXMOX_PLATFORM_FILTER}
|
||||
excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}
|
||||
showNestedExcludedWorkloads
|
||||
compactGroupHeaders
|
||||
groupNodeDrawerMode="disabled"
|
||||
suppressFilterToolbar
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { ProxmoxBackupsTable } from '../ProxmoxBackupsTable';
|
||||
import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw';
|
||||
import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw';
|
||||
import {
|
||||
PLATFORM_TABLE_BODY_CLASS,
|
||||
PLATFORM_TABLE_HEADER_ROW_CLASS,
|
||||
|
|
@ -146,9 +147,7 @@ const pbsServerResource = {
|
|||
instanceId: 'pbs-main',
|
||||
version: '3.2.1',
|
||||
connectionHealth: 'healthy',
|
||||
datastores: [
|
||||
{ name: 'main', total: 10_000, used: 4_000, available: 6_000, usagePercent: 40 },
|
||||
],
|
||||
datastores: [{ name: 'main', total: 10_000, used: 4_000, available: 6_000, usagePercent: 40 }],
|
||||
},
|
||||
} as Resource;
|
||||
|
||||
|
|
@ -245,9 +244,7 @@ describe('ProxmoxBackupsTable', () => {
|
|||
expect(screen.getAllByText('Current').length).toBeGreaterThan(0);
|
||||
|
||||
// Per-source detail is one click down inside the workload's row.
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /expand details for pbs-docker/i }),
|
||||
);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /expand details for pbs-docker/i }));
|
||||
expect(screen.getByText('Restore evidence')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('PVE file').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0);
|
||||
|
|
@ -289,4 +286,17 @@ describe('ProxmoxBackupsTable', () => {
|
|||
'inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps backup coverage fed by Proxmox VM/LXC guests when Overview demotes app containers', () => {
|
||||
expect(proxmoxPageSurfaceSource).toContain(
|
||||
'excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES',
|
||||
);
|
||||
expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads: true');
|
||||
expect(proxmoxPageSurfaceSource).toContain(
|
||||
'excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}',
|
||||
);
|
||||
expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads');
|
||||
expect(proxmoxPageSurfaceSource).toContain('workloads={model().guests}');
|
||||
expect(proxmoxPageSurfaceSource).not.toContain('workloads={workloadsState.allGuests');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue