mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Fix Workloads attention status filtering
This commit is contained in:
@@ -3905,7 +3905,9 @@ after applying a view gives someone else the exact filtered state.
|
||||
Counts or orientation strips presented as part of a filtered resource table
|
||||
must derive from that table's canonical filtered collection as well. They must
|
||||
not continue showing page-wide inventory totals after FilterBar state, saved
|
||||
views, or search has narrowed the rows the operator can see.
|
||||
views, or search has narrowed the rows the operator can see. Filtered summary
|
||||
strips should omit zero-value categories and their separators rather than
|
||||
turning absent states into persistent visual noise.
|
||||
Implicit "remember last filters" is intentionally not added — defaulting
|
||||
to yesterday's filter state on a monitoring page hides real problems.
|
||||
That same shared filter-toolbar boundary also owns controlled select continuity
|
||||
|
||||
@@ -272,6 +272,12 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
seed a missing `status` parameter when a platform page is revisited, but the
|
||||
restore path must write the URL once with `replace` and must not force row
|
||||
filtering through a separate page-local state channel.
|
||||
Status buckets must remain mutually exclusive and derive from the already-
|
||||
loaded workload snapshot: warning or offline unified-resource health takes
|
||||
precedence for filter/stats posture, while healthy resources continue to use
|
||||
the provider-authored runtime power state. `useWorkloads.ts` carries both
|
||||
values; selectors must not collapse warning health into `Stopped` or add a
|
||||
second resource lookup to recover it.
|
||||
4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path.
|
||||
Agent command authorization is likewise a dispatch-time point lookup and
|
||||
atomic approval consume, not a route-wide scan or request-hot-path fan-out;
|
||||
|
||||
@@ -472,7 +472,9 @@ The adjacent Proxmox overview guest-totals strip is likewise presentation-only
|
||||
and must consume the embedded Workloads surface's filtered statistics. It must
|
||||
not fall back to page-wide guest summary counts when search, type, status, or
|
||||
node filters have narrowed the visible workload inventory; backup and recovery
|
||||
authority remain unchanged by either set of presentation counts.
|
||||
authority remain unchanged by either set of presentation counts. Filtered
|
||||
totals omit zero-value status categories and their separators so the strip
|
||||
does not add false orientation noise beside a narrowed result set.
|
||||
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
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c143a7cf322b407e8091ca4e27a16e92a5178cef",
|
||||
"verified_at": "2026-08-02T20:53:27Z",
|
||||
"base_sha": "bacff3e2ebbb53dfa54881132d971e11e50d452e",
|
||||
"verified_at": "2026-08-02T21:27:13Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
|
||||
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts",
|
||||
"frontend-modern/src/types/workloads.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "3dafdf4fec6e6843082f347258c7913f96bc1bf9fc23b10e2461d87ffa90110f"
|
||||
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "bca56f3abac29d36ed30e361747f4b32138bd0fd75112ff04aed8e2ca15dc01d",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "a26664090d776ac8c99bfd14c52fafa3157cf8aebf275807b2a65588664b1fe7",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts": "f6064a2dd39d50d2c24c91f7499e21bcb4f115f9e99ae90420536c8e6834b06a",
|
||||
"frontend-modern/src/types/workloads.ts": "52996a1fa0b08da1167770522302053f72da4f9d40ec3b0cd17b0dd9b1c91749"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview"
|
||||
@@ -23,17 +29,17 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Desktop filtered saved view with one stopped VM",
|
||||
"Phone-width filtered saved view with one stopped VM",
|
||||
"Desktop zero-result Attention filter",
|
||||
"Desktop unfiltered workload inventory",
|
||||
"Clean unfiltered state after saved-view removal and reload"
|
||||
"Desktop unfiltered Proxmox workload inventory",
|
||||
"Desktop Attention filter with 11 warning-health guests",
|
||||
"Desktop Stopped filter with 5 stopped-health guests",
|
||||
"Phone-width expanded filters with Attention selected",
|
||||
"Clean unfiltered state after clearing filters and reload"
|
||||
],
|
||||
"interactions": [
|
||||
"Confirmed the one-row VMs, Stopped, and Node pve1 view rendered 0 running and 1 stopped instead of estate-wide totals",
|
||||
"Inspected the filtered totals strip at 1280x900 and 390x844 for alignment, clipping, and horizontal overflow",
|
||||
"Cleared the saved filters and confirmed the totals returned to the full visible workload collection",
|
||||
"Applied the Attention filter and confirmed the totals strip was absent beside the canonical zero-result state",
|
||||
"Removed the temporary saved view, cleared filters, reloaded the route, and confirmed no saved default or filter state remained"
|
||||
"Applied Attention and confirmed the 11 node-offline warning guests rendered instead of an empty result",
|
||||
"Applied Stopped and confirmed only the 5 genuinely stopped guests remained",
|
||||
"Confirmed filtered totals omitted zero-value categories and stray separators",
|
||||
"Expanded the phone-width filter controls, selected Attention, and inspected table alignment and viewport clipping",
|
||||
"Cleared filters, reloaded the route, and confirmed the unfiltered totals restored to 53 running, 11 attention, and 5 stopped"
|
||||
]
|
||||
}
|
||||
|
||||
+4
-5
@@ -677,14 +677,14 @@ describe('workloadSelectors (branch coverage 2)', () => {
|
||||
).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('matches status exactly (case-sensitive) in running mode', () => {
|
||||
it('matches running status case-insensitively', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'lower', status: 'running' }),
|
||||
makeGuest(2, { id: 'capital', status: 'Running' }),
|
||||
];
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'running' }).map((g) => g.id),
|
||||
).toEqual(['lower']);
|
||||
).toEqual(['lower', 'capital']);
|
||||
});
|
||||
|
||||
it('counts DEGRADED-set and unknown statuses as degraded, excluding running and OFFLINE-set', () => {
|
||||
@@ -700,16 +700,15 @@ describe('workloadSelectors (branch coverage 2)', () => {
|
||||
).toEqual(['warn', 'migrating', 'empty']);
|
||||
});
|
||||
|
||||
it('treats capitalized "Running" as stopped due to the case-sensitive !== running check', () => {
|
||||
it('normalizes status casing before assigning mutually exclusive buckets', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'capital', status: 'Running' }),
|
||||
makeGuest(2, { id: 'lower', status: 'running' }),
|
||||
makeGuest(3, { id: 'stopped', status: 'stopped' }),
|
||||
];
|
||||
// stopped mode keeps g.status !== 'running' -> 'Running' survives (case quirk).
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'stopped' }).map((g) => g.id),
|
||||
).toEqual(['capital', 'stopped']);
|
||||
).toEqual(['stopped']);
|
||||
});
|
||||
|
||||
it('applies no status filter for an unrecognized statusMode (else arm)', () => {
|
||||
|
||||
+5
-8
@@ -261,7 +261,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
|
||||
expect(result.map((g) => g.id)).toEqual(['empty']);
|
||||
});
|
||||
|
||||
it('matches status case-sensitively in running mode (capital "Running" is excluded)', () => {
|
||||
it('normalizes status casing in running mode', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'capital', name: 'capital', status: 'Running' }),
|
||||
makeGuest(2, { id: 'lower', name: 'lower', status: 'running' }),
|
||||
@@ -273,8 +273,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
|
||||
statusMode: 'running',
|
||||
});
|
||||
|
||||
// g.status === 'running' is an exact, case-sensitive comparison.
|
||||
expect(result.map((g) => g.id)).toEqual(['lower']);
|
||||
expect(result.map((g) => g.id)).toEqual(['capital', 'lower']);
|
||||
});
|
||||
|
||||
it('matches the numeric vmid and string status/instance text-search candidates', () => {
|
||||
@@ -409,7 +408,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies a capitalized "Running" status as stopped due to case-sensitive running check', () => {
|
||||
it('classifies a capitalized "Running" status as running', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'capital',
|
||||
@@ -420,13 +419,11 @@ describe('workloadSelectors (branch coverage 2)', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
// g.status === 'running' is exact; the degraded check lowercases, so
|
||||
// 'Running' is neither running nor degraded and lands in stopped.
|
||||
expect(computeWorkloadStats(guests)).toStrictEqual({
|
||||
total: 1,
|
||||
running: 0,
|
||||
running: 1,
|
||||
degraded: 0,
|
||||
stopped: 1,
|
||||
stopped: 0,
|
||||
vms: 1,
|
||||
containers: 0,
|
||||
appContainers: 0,
|
||||
|
||||
@@ -124,7 +124,40 @@ describe('workloadSelectors', () => {
|
||||
selectedHostHint: null,
|
||||
selectedKubernetesContext: null,
|
||||
});
|
||||
expect(stopped.map((g) => g.status)).toEqual(['warning', 'migrating', 'offline']);
|
||||
expect(stopped.map((g) => g.status)).toEqual(['offline']);
|
||||
});
|
||||
|
||||
it('lets canonical resource health override stale runtime power for status filters', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { status: 'stopped', resourceStatus: 'warning' }),
|
||||
makeGuest(2, { status: 'running', resourceStatus: 'offline' }),
|
||||
makeGuest(3, { status: 'running', resourceStatus: 'online' }),
|
||||
];
|
||||
|
||||
const filterByStatus = (statusMode: 'running' | 'degraded' | 'stopped') =>
|
||||
filterWorkloads({
|
||||
guests,
|
||||
viewMode: 'all',
|
||||
statusMode,
|
||||
searchTerm: '',
|
||||
selectedNode: null,
|
||||
selectedHostHint: null,
|
||||
selectedKubernetesContext: null,
|
||||
}).map((guest) => guest.id);
|
||||
|
||||
expect(filterByStatus('running')).toEqual([guests[2].id]);
|
||||
expect(filterByStatus('degraded')).toEqual([guests[0].id]);
|
||||
expect(filterByStatus('stopped')).toEqual([guests[1].id]);
|
||||
expect(computeWorkloadStats(guests)).toEqual({
|
||||
total: 3,
|
||||
running: 1,
|
||||
degraded: 1,
|
||||
stopped: 1,
|
||||
vms: 2,
|
||||
containers: 0,
|
||||
appContainers: 1,
|
||||
pods: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies text and metric search filters, and supports combined filtering', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computeIOScale } from '@/components/Infrastructure/infrastructureSelect
|
||||
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
||||
import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery';
|
||||
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
|
||||
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
|
||||
import { OFFLINE_HEALTH_STATUSES } from '@/utils/status';
|
||||
import { getNodeDisplayName } from '@/utils/nodes';
|
||||
import {
|
||||
isContainerWorkloadViewMode,
|
||||
@@ -48,6 +48,29 @@ export interface WorkloadStats {
|
||||
pods: number;
|
||||
}
|
||||
|
||||
type WorkloadStatusBucket = 'running' | 'degraded' | 'stopped';
|
||||
|
||||
const RUNNING_HEALTH_STATUSES = new Set(['online', 'running', 'healthy']);
|
||||
|
||||
const classifyWorkloadStatus = (status: string): WorkloadStatusBucket => {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (RUNNING_HEALTH_STATUSES.has(normalized)) return 'running';
|
||||
if (OFFLINE_HEALTH_STATUSES.has(normalized)) return 'stopped';
|
||||
return 'degraded';
|
||||
};
|
||||
|
||||
const resolveWorkloadStatusBucket = (guest: WorkloadGuest): WorkloadStatusBucket => {
|
||||
const resourceStatus = (guest.resourceStatus || '').trim().toLowerCase();
|
||||
|
||||
// Unified-resource health owns warning/offline posture, while a healthy
|
||||
// resource still defers to the provider-authored runtime power state.
|
||||
if (resourceStatus && !RUNNING_HEALTH_STATUSES.has(resourceStatus)) {
|
||||
return classifyWorkloadStatus(resourceStatus);
|
||||
}
|
||||
|
||||
return classifyWorkloadStatus(guest.status || '');
|
||||
};
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
type SortValue = string | number | boolean | null | undefined;
|
||||
@@ -152,17 +175,11 @@ export const filterWorkloads = ({
|
||||
}
|
||||
|
||||
if (statusMode === 'running') {
|
||||
guests = guests.filter((g) => g.status === 'running');
|
||||
guests = guests.filter((g) => resolveWorkloadStatusBucket(g) === 'running');
|
||||
} else if (statusMode === 'degraded') {
|
||||
guests = guests.filter((g) => {
|
||||
const status = (g.status || '').toLowerCase();
|
||||
return (
|
||||
DEGRADED_HEALTH_STATUSES.has(status) ||
|
||||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
|
||||
);
|
||||
});
|
||||
guests = guests.filter((g) => resolveWorkloadStatusBucket(g) === 'degraded');
|
||||
} else if (statusMode === 'stopped') {
|
||||
guests = guests.filter((g) => g.status !== 'running');
|
||||
guests = guests.filter((g) => resolveWorkloadStatusBucket(g) === 'stopped');
|
||||
}
|
||||
|
||||
const trimmedSearch = searchTerm.trim();
|
||||
@@ -437,15 +454,15 @@ export const buildWorkloadSummaryGroupScopeMap = ({
|
||||
};
|
||||
|
||||
export const computeWorkloadStats = (guests: WorkloadGuest[]): WorkloadStats => {
|
||||
const running = guests.filter((g) => g.status === 'running').length;
|
||||
const degraded = guests.filter((g) => {
|
||||
const status = (g.status || '').toLowerCase();
|
||||
return (
|
||||
DEGRADED_HEALTH_STATUSES.has(status) ||
|
||||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
|
||||
);
|
||||
}).length;
|
||||
const stopped = guests.length - running - degraded;
|
||||
let running = 0;
|
||||
let degraded = 0;
|
||||
let stopped = 0;
|
||||
guests.forEach((guest) => {
|
||||
const bucket = resolveWorkloadStatusBucket(guest);
|
||||
if (bucket === 'running') running += 1;
|
||||
else if (bucket === 'degraded') degraded += 1;
|
||||
else stopped += 1;
|
||||
});
|
||||
const vms = guests.filter((g) => resolveWorkloadType(g) === 'vm').length;
|
||||
const containers = guests.filter((g) => resolveWorkloadType(g) === 'system-container').length;
|
||||
const appContainers = guests.filter((g) => resolveWorkloadType(g) === 'app-container').length;
|
||||
|
||||
@@ -381,22 +381,30 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
class="flex items-center gap-2 rounded border border-border bg-surface-alt px-2 py-1 text-xs text-muted"
|
||||
data-testid="proxmox-guest-totals"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<StatusDot size="xs" variant="success" ariaHidden />
|
||||
{visibleGuestStats().running} running
|
||||
</span>
|
||||
<Show when={visibleGuestStats().running > 0}>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<StatusDot size="xs" variant="success" ariaHidden />
|
||||
{visibleGuestStats().running} running
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={visibleGuestStats().degraded > 0}>
|
||||
<span aria-hidden="true">|</span>
|
||||
<Show when={visibleGuestStats().running > 0}>
|
||||
<span aria-hidden="true">|</span>
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<StatusDot size="xs" variant="warning" ariaHidden />
|
||||
{visibleGuestStats().degraded} attention
|
||||
</span>
|
||||
</Show>
|
||||
<span aria-hidden="true">|</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<StatusDot size="xs" variant="danger" ariaHidden />
|
||||
{visibleGuestStats().stopped} stopped
|
||||
</span>
|
||||
<Show when={visibleGuestStats().stopped > 0}>
|
||||
<Show when={visibleGuestStats().running > 0 || visibleGuestStats().degraded > 0}>
|
||||
<span aria-hidden="true">|</span>
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<StatusDot size="xs" variant="danger" ariaHidden />
|
||||
{visibleGuestStats().stopped} stopped
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -387,6 +387,12 @@ describe('ProxmoxBackupsTable', () => {
|
||||
expect(proxmoxPageSurfaceSource).not.toContain(
|
||||
'currentModel().summary.stoppedGuestCount} stopped',
|
||||
);
|
||||
expect(proxmoxPageSurfaceSource).toContain(
|
||||
'<Show when={visibleGuestStats().running > 0}>',
|
||||
);
|
||||
expect(proxmoxPageSurfaceSource).toContain(
|
||||
'<Show when={visibleGuestStats().stopped > 0}>',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the shared storage surface scoped to the whole Proxmox product family', () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('ProxmoxPageSurface contract', () => {
|
||||
render(() => <ProxmoxPageSurface />);
|
||||
|
||||
const totals = screen.getByTestId('proxmox-guest-totals');
|
||||
expect(totals).toHaveTextContent('0 running');
|
||||
expect(totals).not.toHaveTextContent('running');
|
||||
expect(totals).not.toHaveTextContent('attention');
|
||||
expect(totals).toHaveTextContent('1 stopped');
|
||||
});
|
||||
|
||||
@@ -276,7 +276,7 @@ describe('useWorkloads', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps Proxmox power state stable while aggregate freshness changes, then removes an authoritative deletion', async () => {
|
||||
it('keeps Proxmox power state stable while carrying aggregate health, then removes an authoritative deletion', async () => {
|
||||
const guest = (vmid: number, status: string, runtimeStatus: string) => ({
|
||||
...sampleResource,
|
||||
id: `cluster-a-pve1-${vmid}`,
|
||||
@@ -331,6 +331,11 @@ describe('useWorkloads', () => {
|
||||
'running',
|
||||
'running',
|
||||
]);
|
||||
expect(result!.workloads().map((workload) => workload.resourceStatus)).toEqual([
|
||||
'warning',
|
||||
'online',
|
||||
'warning',
|
||||
]);
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
data: [guest(101, 'online', 'running'), guest(102, 'online', 'running')],
|
||||
|
||||
@@ -462,6 +462,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
||||
resource.status ||
|
||||
(platformType === 'vmware-vsphere' ? resource.vmware?.powerState : null),
|
||||
),
|
||||
resourceStatus: (resource.status || '').trim().toLowerCase() || undefined,
|
||||
type:
|
||||
workloadType === 'vm'
|
||||
? 'vm'
|
||||
|
||||
@@ -12,6 +12,8 @@ export type ViewMode = 'all' | 'vm' | WorkloadContainerViewMode | 'pod';
|
||||
|
||||
export type WorkloadGuest = (VM | Container) & {
|
||||
workloadType?: WorkloadType;
|
||||
/** Canonical unified-resource health, kept separate from runtime power state in `status`. */
|
||||
resourceStatus?: string;
|
||||
displayId?: string;
|
||||
image?: string;
|
||||
// Provider/runtime-native identifier for app-container actions such as Docker image updates.
|
||||
|
||||
Reference in New Issue
Block a user