Make platform telemetry presentation evidence-aware

This commit is contained in:
rcourtman
2026-08-14 00:18:23 +01:00
parent 903b579f8a
commit ff2bfbb320
46 changed files with 891 additions and 270 deletions
@@ -6211,14 +6211,17 @@ payload and authorization proof.
The authenticated `GET /api/runtime/inventory-sources` monitoring projection
derives cached source health through the shared connection aggregator but is not
an agent enumeration, enrollment, update, or command route. Its four-field wire
type carries only source `type`, operator-facing `name`, normalized blocking
`state`, and workload-only `surfaces`.
an agent enumeration, enrollment, update, or command route. Its base wire type
carries only source `type`, operator-facing `name`, normalized lifecycle
`state`, and workload-only `surfaces`. An optional VMware completeness summary
may carry only degraded state, aggregate count, and bounded stage/category/
occurrence tuples for otherwise-successful inventory reads.
It has no stable connection or agent ID, token binding, address, report IP,
hostname identity object, operating-system, kernel or architecture fact, agent
version, update or module state, command-session state, commands-enabled value,
fleet policy, or credential-health object. The handler deliberately skips the
fleet policy, credential-health object, raw enrichment error, or affected
entity identity. The handler deliberately skips the
command-session enrichment used by the administrative ledger. Agent reporting,
registration, update, and command authority remain unchanged, and the
fully-populated projection test fails if any of those lifecycle facts cross the
@@ -9309,10 +9309,13 @@ same narrow response. `GET /api/connections` remains unchanged behind
`RequireAdmin` plus `settings:read` and continues to be the only connection
configuration and fleet ledger.
The response envelope is `{"sources": [...]}` and every source has exactly four
fields: operator-facing `type` and `name`, normalized blocking `state`, and a
`surfaces` array containing only effective workload coverage. The backend drops
healthy, disabled, non-workload, and coverage-free connections before the wire;
The response envelope is `{"sources": [...]}` and every source has four base
fields: operator-facing `type` and `name`, normalized lifecycle `state`, and a
`surfaces` array containing only effective workload coverage. A VMware source
whose otherwise-successful poll reported optional enrichment failures may add
`completeness={state:"degraded",issueCount,issues}`; each issue is limited to
stage, category, and occurrence count. The backend drops healthy sources with
no completeness issue, disabled, non-workload, and coverage-free connections before the wire;
resolves configured scope over declared surfaces; removes storage and backup
coverage; and normalizes cached invalid or expired credential health to
`state="unauthorized"`. There is no privileged variant.
@@ -9320,9 +9323,12 @@ coverage; and normalizes cached invalid or expired credential health to
The whitelist intentionally has no connection ID, address, host alias, raw
state reason or error, timestamp, source origin, agent identity, version or
module, fleet policy or credential-health object, capability, credential,
configuration, or mutation field.
`TestRuntimeInventorySourceWireShapeIsAnExactWhitelist` fails if the Go wire
type grows, while
configuration, raw enrichment message, affected entity identity, or mutation
field. `RuntimeInventoryCompleteness` is an explicit second whitelist rather
than a serialized VMware observed summary.
`TestRuntimeInventorySourceWireShapeIsAnExactWhitelist` fails if the base Go
wire type grows, while the completeness projection test proves raw messages,
entity IDs, and endpoints cannot cross the optional diagnostic shape, and
`TestRuntimeInventorySourcesOmitAdministrativeAndSensitiveFacts` projects a
fully populated administrative record and proves those values cannot travel.
The route-authorization test proves the exact viewer 200 / ledger 403 pairing,
@@ -47,6 +47,13 @@ the workload state, table, panel, and row contracts carry the selected basis
and resolved parent-node data to the canonical memory bar, and the memory
column header must expose the non-default Host basis after the control closes.
Shared workload, node, Docker-host, and resource-drawer history presentation
keeps current readings separate from stored samples. A current metric may
populate the legend while history is still being collected, but it must never
be expanded into synthetic timestamps or chart geometry. An empty stored
series renders the shared collecting-history state; zero remains a valid
reported reading, while an absent metric remains unavailable.
Presentation helpers that mirror a server-side classification must name the
predicate they mirror and expose it as a single exported function rather than
@@ -273,6 +273,13 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
`docker.uptimeSeconds`, `kubernetes.uptimeSeconds`); platforms whose
adapters only populate the canonical field — vSphere is the working
example — would otherwise render blank uptime cells for every row.
That mapper must preserve observation presence separately from the numeric
fallback required by the legacy `VM | Container` shape. `0` is a real
reported CPU, capacity, I/O, or uptime value; an absent or non-finite API
field is unavailable and must render as such. The mapper records that fact
once on `WorkloadGuest.telemetryAvailability`, never substitutes wall-clock
time for an absent `lastSeen`, and reads vSphere CPU allocation from
`VMware.CPUCount` before declaring the count unknown.
Workload guest rows may compose the shared saved web-interface adjacent
launch control from `frontend-primitives`, but row rendering must receive
any saved URL from already-loaded row metadata and must not add per-row
@@ -500,8 +507,8 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
scope must be applied before deriving host, Kubernetes context/namespace,
and container runtime facet options so embedded pages do not pay for or
display unrelated platform options.
Workloads source-health messaging must derive from the canonical
`/api/connections` ledger through
Workloads source-health messaging must derive from the viewer-safe
`/api/runtime/inventory-sources` projection through
`frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`.
Platform-owned embedded Workloads surfaces must scope those source-health
issues to the forced platform before rendering an empty inventory state, and
@@ -530,6 +537,14 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
pending, or paused, but it must not fabricate VM/container rows from host
telemetry or hide source blockers just because another source still has
workload rows.
The same projection may include an active VMware source only when its last
otherwise-successful refresh reported degraded optional enrichment. That
completeness record is a strict whitelist of stage, category, occurrence,
and aggregate count; raw messages, entity identifiers, endpoints, and
credentials remain private. The vSphere page shares this single bounded
poll with its embedded Workloads state and must say when displayed inventory
may be incomplete rather than treating omitted enrichment as a healthy
empty result.
The retired dashboard overview route must not return as a hot-path
orientation shortcut. First-viewport system count, health, source coverage,
and freshness now belong to the provider-first platform landing surface and
@@ -571,6 +586,11 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
the migration boundary.
11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx`
Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`.
A current metric may remain visible in a chart legend while history is
accumulating, but it must never be expanded into synthetic timestamps or a
flat line. Fewer than two stored points remains `Collecting history` and
only metrics-store samples may contribute chart geometry, bounds, or hover
inspection.
History cards must let the plot area stretch to the card height instead of
pinning the SVG wrapper to a fixed short height, so guest and node drawer
history cards do not leave unused card space when headers have different
@@ -1137,8 +1157,9 @@ node-context metric, not a universal Workloads table column, and the node detail
drawer may add one compact `Thermals` history group beside utilization and I/O
only after a grouped node row is selected. Host-agent CPU temperature must be
persisted into the same metrics-store history stream as other agent metrics,
with current node temperature used only as drawer-local fallback while history
is still accumulating.
with current node temperature displayed only as a drawer-local current reading
while history is still accumulating; it must not become a synthetic history
point.
Standalone agent thermal pressure follows a compact table-and-drawer rule:
platform tables may show a short pressure status such as `Nominal` or
`Constrained` in the existing temperature cell when no positive Celsius reading
@@ -2381,10 +2402,13 @@ interval refetch.
The server work remains one cached `buildConnections` aggregation with no
probing, persistence, grouped-system construction, or command-session
enrichment. It filters healthy, disabled, non-workload, and coverage-free rows
before serialization, so the response is no larger than the actionable banner
input. Client presentation is a single pass over that bounded list and performs
no per-source fetch or detail lookup.
enrichment. It filters disabled, coverage-free, and healthy sources with no
collection-completeness issue before serialization, so the response is no
larger than actionable blocker or degraded-completeness input. Client
presentation is a single pass over that bounded list and performs no per-source
fetch or detail lookup. Platform pages that need the same snapshot must inject
the owning query into embedded Workloads state so they do not create a second
poll.
### Resource policy reads stay outside the Workloads hot path
@@ -5173,7 +5173,10 @@ The projection filters coverage to workload labels (`vms`, containers, pods,
and Kubernetes) before serialization. Storage and backup labels, source
addresses, stable connection IDs, raw error text, credential state objects, and
all administrative configuration remain absent by construction. The
api-contracts contract owns the authoritative four-field payload and
optional VMware completeness summary may identify degraded inventory stages by
category and count, but it carries no raw message and is not backup
completeness, protection damage, snapshot failure, restore readiness, or
recovery evidence. The api-contracts contract owns the authoritative payload and
authorization proof.
### RBAC identity metadata and deprovisioning preserve storage boundaries
@@ -63,6 +63,12 @@ metric object exists, so `/api/resources`, `/api/state`, websocket resources,
history, alerts, and UI adapters never need to interpret `null` as either idle
or unknown. Unified resources must not infer validity from the numeric guest
compatibility field alone.
Resource and workload drawer consumers preserve the same evidence boundary:
current CPU, memory, disk, network, and disk-I/O observations may be shown as
current values, but only samples returned by the canonical history endpoint may
form a historical series. Drawer adapters must not manufacture prior samples
from a current snapshot, and must retain explicit metric absence independently
from a valid reported zero.
Proxmox node inventory continuity is monitoring-authored and
unified-resource-consumed. A powered-off or temporarily unreachable cluster
member must remain one canonical `agent` resource while monitoring still
+50 -16
View File
@@ -1,18 +1,52 @@
{
"version": 1,
"base_sha": "a7646e5f86098b014476669fdc8105a692c22780",
"verified_at": "2026-08-13T22:56:10Z",
"base_sha": "903b579f8aa24e90ee00c4cac6f7aa8393f69584",
"verified_at": "2026-08-13T23:13:56Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts"
"frontend-modern/src/api/runtimeInventorySources.ts",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts",
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx",
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts"
],
"content_sha256": {
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "6596f8b71c9cefb759d72751e4c5eeaf48292cd7fd90b3cc3a3f2026f59baf82",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts": "6db0718b9139daa8f98a41dc0d254bf3889b138ded7b3ac9a2ffa761ab3de70e"
"frontend-modern/src/api/runtimeInventorySources.ts": "c14c416bc12ff77f53aa7623a774e00129e813771e9e8bbcd295ca5698615f2c",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "effbe9e1a206e6fc4aeb8d0f4f45c4ebe15d983fc6e98db5285f9cf623ea654b",
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "b21cbb340be34ee833503eab8bcdbc8a75e14e9bbc8bd6e0be8c1d8aa5d26cf1",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "4ae7d3a10b0850edd6193ccd2002275fb623deed8948c0dbbb2cd3055381e927",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "f6de7da0928440d8edaacd34adc64de70577c88056cdaf88b409a91cc1954663",
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a4f0c6e11a51b37f6f8fc5602ae7fb22f58b4217426c5cb1cf57e49936a92073",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "30e2a4f60244a0a07017562bef58d1d735f03a7ddba72f49586ccbf86387a4fb",
"frontend-modern/src/components/Workloads/GuestRow.tsx": "8db3148e7711f6bcdce117c4c58b75ebf214717056a034273a39d90475cac337",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx": "9bdb7203eb698b7705914357b45245f17252aa135ef5b94eec331396fc04ec82",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "18058d98f8909893e4b2e62c7aae569ed1e57c1dd8432f7a1efeaf6edc8c270f",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts": "5ba1927c2f050b26d67a6998f34cabbddba9fa79df98ec1e2ecc9b311633eeaa",
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "dc0578bd3ea010412673a821291e6bd77a5fb229cb67e3493d4c4842af5e0345",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "487eed98aba85f2dad8f504694ebf24c8faff861eeb19d729c741cb5cc3367b5",
"frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts": "abe71a344a7ff660e62b1cde6cafac72567e485827c08481003567ed3e7c3363",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx": "e1d36317c1cefa262cfec544e8220608d53dd2fb546d8e058fa26caa5c0012d5",
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts": "7162841abb5e5061cf89342d265c8e170243ddf41477985da350e85126eea594",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "2e44e78339c0bbd17dc1d2d75a8beef934393d79f72a105b54a25d3a0cbe84cb",
"frontend-modern/src/hooks/useWorkloads.ts": "856b6e05e39f0188b371878cc5361fe72f4a05b77b8505b00e0f19d3fedcfd46",
"frontend-modern/src/types/workloads.ts": "d3d5958364ebb71740ceee7fb804dd54594297d0a1f173556450772fcefc252c"
},
"routes": [
"/alerts/thresholds"
"/vmware/overview"
],
"viewports": [
{
@@ -25,16 +59,16 @@
}
],
"states": [
"VMs & Containers section expanded in the card layout at 700px with Backup and Snapshot rendered as toggle badges (Disabled) instead of number inputs",
"guest card edit mode (cold-archive-01) showing numeric inputs with On toggles for CPU/Memory/Disk while Backup and Snapshot remain toggle badges with no number input",
"unsaved-changes banner after toggling a backup badge, then discarded",
"1280px desktop viewport still using the card layout for the 14-column guests section, badges rendering identically"
"desktop vSphere host and workload tables without unsupported backup status columns or failure icons, with API-backed VM CPU allocation values visible",
"etl-batch-01 guest drawer overview showing the explicitly identified Pulse Agent presentation",
"etl-batch-01 history tab showing current readings alongside Collecting history with no fabricated chart geometry",
"700px compact vSphere tables with no horizontal body overflow or unsupported backup artifacts"
],
"interactions": [
"expanded the VMs & Containers group",
"entered per-guest edit mode via Edit thresholds button",
"clicked a Backup toggle badge in card view mode and confirmed it flipped to enabled and staged unsaved changes",
"clicked Discard to clear the staged change",
"checked console errors (only pre-existing WebSocket reconnect noise from the absent 7655 backend)"
"expanded etl-batch-01 and opened its guest drawer",
"switched the guest drawer to History",
"inspected all three history chart groups and confirmed zero SVG trend paths while no stored samples were available",
"verified the desktop and 700px layouts had no console errors",
"verified the 700px document scroll width matched the viewport width"
]
}
@@ -21,6 +21,11 @@ describe('RuntimeInventorySourcesAPI', () => {
name: 'Primary vCenter',
state: 'unreachable',
surfaces: ['vms'],
completeness: {
state: 'degraded',
issueCount: 2,
issues: [{ stage: 'tags', category: 'permission', occurrences: 2 }],
},
},
],
});
@@ -32,6 +37,11 @@ describe('RuntimeInventorySourcesAPI', () => {
name: 'Primary vCenter',
state: 'unreachable',
surfaces: ['vms'],
completeness: {
state: 'degraded',
issueCount: 2,
issues: [{ stage: 'tags', category: 'permission', occurrences: 2 }],
},
},
],
});
@@ -3,7 +3,19 @@ import { apiFetchJSON } from '@/utils/apiClient';
export type RuntimeInventorySourceType = 'pve' | 'vmware' | 'docker' | 'kubernetes';
export type RuntimeInventorySourceState =
'paused' | 'pending' | 'stale' | 'unauthorized' | 'unreachable';
'active' | 'paused' | 'pending' | 'stale' | 'unauthorized' | 'unreachable';
export interface RuntimeInventoryCompletenessIssue {
stage?: string;
category?: string;
occurrences?: number;
}
export interface RuntimeInventoryCompleteness {
state: 'degraded';
issueCount: number;
issues?: RuntimeInventoryCompletenessIssue[];
}
/**
* Complete viewer-safe wire shape returned by GET /api/runtime/inventory-sources.
@@ -14,6 +26,7 @@ export interface RuntimeInventorySource {
name: string;
state: RuntimeInventorySourceState;
surfaces: string[];
completeness?: RuntimeInventoryCompleteness;
}
export interface RuntimeInventorySourcesResponse {
@@ -197,7 +197,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
<GuestDrawerHistory
target={target()}
range={drawer.metricsHistoryRange()}
fallbackMetrics={drawer.metricsHistoryFallbackMetrics()}
currentMetrics={drawer.metricsHistoryCurrentMetrics()}
groups={drawer.metricsHistoryGroups()}
/>
</div>
@@ -8,14 +8,14 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
GuestDrawerHistory: (props: {
target: { resourceType: string; resourceId: string } | null;
range: string;
fallbackMetrics?: Record<string, number | undefined>;
currentMetrics?: Record<string, number | undefined>;
}) => (
<div
data-testid="container-history"
data-resource-type={props.target?.resourceType}
data-resource-id={props.target?.resourceId}
data-range={props.range}
data-cpu={props.fallbackMetrics?.cpu}
data-cpu={props.currentMetrics?.cpu}
/>
),
GuestDrawerHistoryRangeSelect: (props: {
@@ -20,6 +20,7 @@ import resourceDetailDrawerVmwareModelSource from '@/components/Infrastructure/r
import resourceDetailDrawerTrueNASModelSource from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts?raw';
import resourceDetailDrawerDockerActionsStateSource from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts?raw';
import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw';
import guestDrawerHistorySource from '@/components/Workloads/GuestDrawerHistory.tsx?raw';
import actionAuditApiSource from '@/api/actionAudit.ts?raw';
import actionAuditPresentationSource from '@/utils/actionAuditPresentation.ts?raw';
import type { Resource } from '@/types/resource';
@@ -139,6 +140,12 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
});
describe('ResourceDetailDrawer change history section', () => {
it('keeps current readings separate from stored history samples', () => {
expect(guestDrawerHistorySource).toContain('currentMetrics');
expect(guestDrawerHistorySource).toContain('Collecting history');
expect(guestDrawerHistorySource).not.toContain('buildFallbackHistoryPoints');
});
it('keeps discovery context presentation separate from discovery runtime ownership', () => {
expect(discoveryTabSource).toContain('useDiscoveryTabState');
expect(discoveryTabStateSource).toContain('export function useDiscoveryTabState');
@@ -7,15 +7,15 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
GuestDrawerHistory: (props: {
target: { resourceType: string; resourceId: string } | null;
range: string;
fallbackMetrics?: Record<string, number | undefined>;
currentMetrics?: Record<string, number | undefined>;
}) => (
<div
data-testid="machine-history"
data-resource-type={props.target?.resourceType}
data-resource-id={props.target?.resourceId}
data-range={props.range}
data-cpu={props.fallbackMetrics?.cpu}
data-netin={props.fallbackMetrics?.netin}
data-cpu={props.currentMetrics?.cpu}
data-netin={props.currentMetrics?.netin}
/>
),
GuestDrawerHistoryRangeSelect: (props: {
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
getResourceMetricsHistoryFallbackMetrics,
getResourceMetricsHistoryCurrentMetrics,
getResourceMetricsHistoryGroups,
getResourceMetricsHistoryTarget,
} from '@/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel';
@@ -109,7 +109,7 @@ describe('getResourceMetricsHistoryTarget branch coverage', () => {
});
});
describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => {
it('returns every metric as a finite number when the resource is fully populated', () => {
// All finiteMetric calls take the "finite number -> return value" arm;
// memory/disk ternaries take the TRUE arm and getMemoryPercent/getDiskPercent
@@ -121,7 +121,7 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
network: { rxBytes: 1000, txBytes: 2000 },
diskIO: { readRate: 3000, writeRate: 4000 },
});
expect(getResourceMetricsHistoryFallbackMetrics(resource)).toStrictEqual({
expect(getResourceMetricsHistoryCurrentMetrics(resource)).toStrictEqual({
cpu: 12.5,
memory: 30, // (30 / 100) * 100
disk: 40, // (80 / 200) * 100
@@ -140,7 +140,7 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
// Every optional chain short-circuits to undefined; memory/disk ternaries
// take the FALSE arm because `resource.memory`/`resource.disk` are falsy.
const resource = baseResource({});
expect(getResourceMetricsHistoryFallbackMetrics(resource)).toStrictEqual({
expect(getResourceMetricsHistoryCurrentMetrics(resource)).toStrictEqual({
cpu: undefined,
memory: undefined,
disk: undefined,
@@ -158,37 +158,37 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
it('uses memory.current when total/used are absent', () => {
// getMemoryPercent: `memory.total && memory.used` is falsy -> returns current.
const resource = baseResource({ memory: { current: 45.5 } });
expect(getResourceMetricsHistoryFallbackMetrics(resource).memory).toBe(45.5);
expect(getResourceMetricsHistoryCurrentMetrics(resource).memory).toBe(45.5);
});
it('uses disk.current when total/used are absent', () => {
// getDiskPercent: `disk.total && disk.used` is falsy -> returns current.
const resource = baseResource({ disk: { current: 67 } });
expect(getResourceMetricsHistoryFallbackMetrics(resource).disk).toBe(67);
expect(getResourceMetricsHistoryCurrentMetrics(resource).disk).toBe(67);
});
it('coerces a NaN memory.current to undefined via finiteMetric', () => {
// getMemoryPercent returns NaN; finiteMetric: typeof === 'number' but
// Number.isFinite is FALSE -> undefined.
const resource = baseResource({ memory: { current: Number.NaN } });
expect(getResourceMetricsHistoryFallbackMetrics(resource).memory).toBeUndefined();
expect(getResourceMetricsHistoryCurrentMetrics(resource).memory).toBeUndefined();
});
it('coerces a NaN disk.current to undefined via finiteMetric', () => {
const resource = baseResource({ disk: { current: Number.NaN } });
expect(getResourceMetricsHistoryFallbackMetrics(resource).disk).toBeUndefined();
expect(getResourceMetricsHistoryCurrentMetrics(resource).disk).toBeUndefined();
});
it('coerces an Infinity cpu.current to undefined via finiteMetric', () => {
const resource = baseResource({
cpu: { current: Number.POSITIVE_INFINITY },
});
expect(getResourceMetricsHistoryFallbackMetrics(resource).cpu).toBeUndefined();
expect(getResourceMetricsHistoryCurrentMetrics(resource).cpu).toBeUndefined();
});
it('coerces a NaN cpu.current to undefined via finiteMetric', () => {
const resource = baseResource({ cpu: { current: Number.NaN } });
expect(getResourceMetricsHistoryFallbackMetrics(resource).cpu).toBeUndefined();
expect(getResourceMetricsHistoryCurrentMetrics(resource).cpu).toBeUndefined();
});
it('coerces a non-number cpu.current (malformed payload) to undefined', () => {
@@ -197,7 +197,7 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
const resource = baseResource({
cpu: { current: 'busy' as unknown as number },
});
expect(getResourceMetricsHistoryFallbackMetrics(resource).cpu).toBeUndefined();
expect(getResourceMetricsHistoryCurrentMetrics(resource).cpu).toBeUndefined();
});
it('coerces non-finite network and diskIO rates to undefined', () => {
@@ -208,7 +208,7 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
writeRate: Number.NaN,
},
});
expect(getResourceMetricsHistoryFallbackMetrics(resource)).toStrictEqual({
expect(getResourceMetricsHistoryCurrentMetrics(resource)).toStrictEqual({
cpu: undefined,
memory: undefined,
disk: undefined,
@@ -248,7 +248,7 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
},
});
const metrics = getResourceMetricsHistoryFallbackMetrics(resource);
const metrics = getResourceMetricsHistoryCurrentMetrics(resource);
expect(metrics.gpu).toBe(80);
expect(metrics.gpu_memory).toBe(87.5);
expect(metrics.gpu_temperature).toBe(70);
@@ -269,7 +269,7 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
'disk-io',
'thermals',
]);
expect(getResourceMetricsHistoryFallbackMetrics(resource).temperature).toBe(57.5);
expect(getResourceMetricsHistoryCurrentMetrics(resource).temperature).toBe(57.5);
});
it('keeps workload history groups free of host-only thermals', () => {
@@ -290,7 +290,7 @@ describe('getResourceMetricsHistoryFallbackMetrics branch coverage', () => {
// Mixes a present disk (TRUE arm) with an absent memory (FALSE arm) to prove
// the two ternaries are evaluated independently.
const resource = baseResource({ disk: { current: 18 } });
const result = getResourceMetricsHistoryFallbackMetrics(resource);
const result = getResourceMetricsHistoryCurrentMetrics(resource);
expect(result.memory).toBeUndefined();
expect(result.disk).toBe(18);
});
@@ -107,7 +107,7 @@ export const getResourceMetricsHistoryTarget = (
export const resourceSupportsMetricsHistory = (resource: Resource): boolean =>
getResourceMetricsHistoryTarget(resource) !== null;
export const getResourceMetricsHistoryFallbackMetrics = (
export const getResourceMetricsHistoryCurrentMetrics = (
resource: Resource,
): Record<string, number | undefined> => {
return {
@@ -61,7 +61,7 @@ import {
hasRuntimeOperationalContext as buildHasRuntimeOperationalContext,
} from './resourceDetailDrawerOperationalModel';
import {
getResourceMetricsHistoryFallbackMetrics,
getResourceMetricsHistoryCurrentMetrics,
getResourceMetricsHistoryGroups,
getResourceMetricsHistoryTarget,
resourceSupportsMetricsHistory,
@@ -344,8 +344,8 @@ export const useResourceDetailDrawerDerivedState = (
const hasKubernetesDetails = createMemo(() => kubernetesDetailSections().length > 0);
const kubernetesDetailsSummary = createMemo(() => buildKubernetesDetailsSummary(resource));
const metricsHistoryTarget = createMemo(() => getResourceMetricsHistoryTarget(resource));
const metricsHistoryFallbackMetrics = createMemo(() =>
getResourceMetricsHistoryFallbackMetrics(resource),
const metricsHistoryCurrentMetrics = createMemo(() =>
getResourceMetricsHistoryCurrentMetrics(resource),
);
const metricsHistoryGroups = createMemo(() => getResourceMetricsHistoryGroups(resource));
const hasMetricsHistory = createMemo(() => resourceSupportsMetricsHistory(resource));
@@ -478,7 +478,7 @@ export const useResourceDetailDrawerDerivedState = (
hasServiceDetails,
serviceDetailsSummary,
metricsHistoryTarget,
metricsHistoryFallbackMetrics,
metricsHistoryCurrentMetrics,
metricsHistoryGroups,
hasMetricsHistory,
hasDiscoveryTab,
@@ -496,6 +496,27 @@ describe('GuestDrawer', () => {
expect(screen.queryByText('Open related infrastructure')).toBeNull();
});
it('shows current readings without fabricating history points', async () => {
chartsApiMocks.getMetricsHistory.mockResolvedValue({
resourceType: 'vm',
resourceId: 'inst1:node1:100',
range: '24h',
start: 0,
end: 0,
metrics: {},
source: 'store',
});
render(() => <GuestDrawer guest={makeGuest({ cpu: 0.25 })} onClose={vi.fn()} />);
await fireEvent.click(screen.getByText('History'));
await waitFor(() => expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalled());
const utilizationChart = screen.getAllByTestId('guest-history-group-chart')[0];
expect(utilizationChart).toHaveTextContent('CPU25.0%');
expect(utilizationChart).toHaveTextContent('Collecting history');
expect(utilizationChart.querySelector('path')).toBeNull();
});
it('updates grouped metric header values on History chart hover', async () => {
render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
@@ -627,6 +648,19 @@ describe('GuestDrawer', () => {
expect(screen.getByText('QEMU 5.2.0')).toBeInTheDocument();
});
it('identifies an API-mapped in-guest Pulse Agent without calling it QEMU', () => {
render(() => (
<GuestDrawer
guest={makeGuest({ agentVersion: '6.2.0', agentKind: 'pulse' })}
onClose={vi.fn()}
/>
));
expect(screen.getByText('Pulse Agent')).toBeInTheDocument();
expect(screen.getByText('Pulse 6.2.0')).toHaveAttribute('title', 'Pulse Agent 6.2.0');
expect(screen.queryByText(/QEMU/)).toBeNull();
});
it('shows plain agent version for containers', () => {
render(() => (
<GuestDrawer guest={makeGuest({ agentVersion: '1.0.0', type: 'lxc' })} onClose={vi.fn()} />
@@ -13,7 +13,7 @@ import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getGuestDrawerHistoryFallbackMetrics, type GuestDrawerProps } from './guestDrawerModel';
import { getGuestDrawerCurrentMetrics, type GuestDrawerProps } from './guestDrawerModel';
import { useGuestDrawerState } from './useGuestDrawerState';
import { GuestDrawerHistory, GuestDrawerHistoryRangeSelect } from './GuestDrawerHistory';
import { GuestDrawerOverview } from './GuestDrawerOverview';
@@ -21,6 +21,7 @@ import { GuestDrawerOverview } from './GuestDrawerOverview';
export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
const {
activeTab,
agentHeading,
agentLabel,
agentTitle,
agentContextCopied,
@@ -58,9 +59,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
workloadActionAgentTitle,
} = useGuestDrawerState(props);
const headingId = () => `guest-drawer-heading-${guestId()}`;
const historyFallbackMetrics = createMemo(() =>
getGuestDrawerHistoryFallbackMetrics(props.guest),
);
const historyCurrentMetrics = createMemo(() => getGuestDrawerCurrentMetrics(props.guest));
const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.guest.status));
@@ -136,6 +135,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
guest={props.guest}
guestId={guestId()}
guestOsSummary={guestOsSummary()}
agentHeading={agentHeading()}
agentLabel={agentLabel()}
agentTitle={agentTitle()}
hasAgentInfo={hasAgentInfo()}
@@ -164,7 +164,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
<GuestDrawerHistory
target={historyTarget()}
range={historyRange()}
fallbackMetrics={historyFallbackMetrics()}
currentMetrics={historyCurrentMetrics()}
/>
</div>
)}
@@ -12,6 +12,7 @@ import { FormSelect } from '@/components/shared/FormSelect';
import { filterSelectClass } from '@/components/shared/FilterToolbar';
import {
HISTORY_CHART_RANGES,
formatHistoryChartTooltipValue,
formatHistoryChartTimeLabel,
} from '@/components/shared/historyChartModel';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
@@ -30,7 +31,7 @@ import {
} from './guestDrawerModel';
interface GuestDrawerHistoryProps {
fallbackMetrics?: Record<string, number | null | undefined>;
currentMetrics?: Record<string, number | null | undefined>;
groups?: GuestDrawerHistoryGroupConfig[];
range: HistoryTimeRange;
target: GuestDrawerHistoryTarget | null;
@@ -48,6 +49,7 @@ interface GuestDrawerHistoryQueryKey {
}
interface GuestDrawerHistoryGroupChartProps {
currentMetrics?: Record<string, number | null | undefined>;
group: GuestDrawerHistoryGroupConfig;
loading: boolean;
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
@@ -189,32 +191,6 @@ const findClosestGuestDrawerHistoryPoint = (
return closest;
};
const buildFallbackHistoryPoints = (value: number): AggregatedMetricPoint[] => {
if (!Number.isFinite(value)) return [];
const end = Date.now();
return [
{ timestamp: end - 60_000, value, min: value, max: value },
{ timestamp: end, value, min: value, max: value },
];
};
const mergeFallbackHistoryMetrics = (
metrics: Record<string, AggregatedMetricPoint[] | undefined>,
fallbackMetrics: Record<string, number | null | undefined> | undefined,
): Record<string, AggregatedMetricPoint[] | undefined> => {
if (!fallbackMetrics) return metrics;
let next: Record<string, AggregatedMetricPoint[] | undefined> | null = null;
for (const [metric, value] of Object.entries(fallbackMetrics)) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
const existing = metrics[metric] ?? [];
if (existing.length >= 2) continue;
next ??= { ...metrics };
next[metric] = buildFallbackHistoryPoints(value);
}
return next ?? metrics;
};
const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps> = (props) => {
const [hoverTimestamp, setHoverTimestamp] = createSignal<number | null>(null);
const series = createMemo(() =>
@@ -256,9 +232,16 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
const displaySeries = createMemo(() =>
series().map((item) => {
const hovered = hoveredByMetric().get(item.metric);
const currentValue = props.currentMetrics?.[item.metric];
return {
...item,
displayPoints: hovered ? [hovered.point] : item.points,
valueLabel: hovered
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
: item.points.length > 0
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
: typeof currentValue === 'number' && Number.isFinite(currentValue)
? formatHistoryChartTooltipValue(currentValue, item.unit)
: '-',
};
}),
);
@@ -314,7 +297,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<circle cx="5" cy="5" r="4" fill={item.color} />
</svg>
<span class="font-medium text-base-content">{item.label}</span>
<span>{getGuestDrawerHistoryValueLabel(item.displayPoints, item.unit)}</span>
<span>{item.valueLabel}</span>
</span>
)}
</For>
@@ -459,9 +442,6 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
});
const metrics = createMemo(() => historyQuery.value().metrics ?? {});
const displayMetrics = createMemo(() =>
mergeFallbackHistoryMetrics(metrics(), props.fallbackMetrics),
);
const groups = createMemo(() => props.groups ?? GUEST_DRAWER_HISTORY_GROUPS);
const errorText = createMemo(() => (historyQuery.error() ? 'Failed to load history data' : ''));
@@ -494,7 +474,8 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
<GuestDrawerHistoryGroupChart
group={group}
loading={historyQuery.loading() && !historyQuery.resolvedOnce()}
metrics={displayMetrics()}
metrics={metrics()}
currentMetrics={props.currentMetrics}
range={props.range}
/>
)}
@@ -29,6 +29,7 @@ interface GuestDrawerOverviewProps {
guest: GuestDrawerProps['guest'];
guestId: string;
guestOsSummary: string;
agentHeading: string;
agentLabel: string;
agentTitle: string;
hasAgentInfo: boolean;
@@ -137,7 +138,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
<span class="font-medium text-base-content">{props.guest.cpus}</span>
</div>
</Show>
<Show when={props.guest.uptime > 0}>
<Show
when={
props.guest.telemetryAvailability
? props.guest.telemetryAvailability.uptime
: props.guest.uptime > 0
}
>
<div class="flex items-center justify-between">
<span class="text-muted">Uptime</span>
<span class="font-medium text-base-content">
@@ -153,7 +160,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
</Show>
<Show when={props.hasAgentInfo}>
<div class="flex items-center justify-between">
<span class="text-muted">Guest agent</span>
<span class="text-muted">{props.agentHeading}</span>
<span class="font-medium text-base-content truncate ml-2" title={props.agentTitle}>
{props.agentLabel}
</span>
@@ -103,6 +103,9 @@ export function GuestRow(props: GuestRowProps) {
const cpuPercent = createMemo(() => getWorkloadCPUPercent(props.guest.cpu) ?? 0);
const metricDisplayMode = createMemo(() => props.metricDisplayMode ?? 'bars');
const isSparklineMode = createMemo(() => metricDisplayMode() === 'sparklines');
const telemetryAvailable = (
metric: keyof NonNullable<WorkloadGuest['telemetryAvailability']>,
): boolean => props.guest.telemetryAvailability?.[metric] ?? true;
const isHostMemoryBasis = createMemo(() => props.memoryDisplayBasis === 'host');
const hostMemoryTotal = createMemo(() => {
const total = props.parentMemoryTotal;
@@ -113,6 +116,7 @@ export function GuestRow(props: GuestRowProps) {
);
const memoryDisplayUnavailable = createMemo(
() =>
!telemetryAvailable('memory') ||
props.guest.memory?.usageUnavailable === true ||
(isHostMemoryBasis() && hostMemoryTotal() <= 0),
);
@@ -347,27 +351,32 @@ export function GuestRow(props: GuestRowProps) {
<Show when={isColVisible('cpu')}>
<td class="px-1.5 sm:px-2 py-0.5 align-middle" data-workload-col="cpu">
<Show
when={isSparklineMode()}
fallback={
<div class="h-4">
<EnhancedCPUBar
usage={cpuPercent()}
cores={isMobile() ? undefined : props.guest.cpus}
resourceId={metricsKey()}
anomaly={cpuAnomaly()}
thresholds={cpuThresholds()}
/>
</div>
}
when={telemetryAvailable('cpu')}
fallback={<div class="text-center text-xs text-slate-400"></div>}
>
{renderMetricSparkline(
'cpu',
formatMetricPercent(cpuPercent()),
`${props.guest.name} CPU history`,
'%',
'inline',
formatMetricPercent,
)}
<Show
when={isSparklineMode()}
fallback={
<div class="h-4">
<EnhancedCPUBar
usage={cpuPercent()}
cores={isMobile() ? undefined : props.guest.cpus || undefined}
resourceId={metricsKey()}
anomaly={cpuAnomaly()}
thresholds={cpuThresholds()}
/>
</div>
}
>
{renderMetricSparkline(
'cpu',
formatMetricPercent(cpuPercent()),
`${props.guest.name} CPU history`,
'%',
'inline',
formatMetricPercent,
)}
</Show>
</Show>
</td>
</Show>
@@ -498,7 +507,7 @@ export function GuestRow(props: GuestRowProps) {
<td class="px-1.5 sm:px-2 py-0.5 align-middle">
<div class="flex justify-center">
<Show
when={isRunning()}
when={isRunning() && telemetryAvailable('uptime')}
fallback={
<span class="text-xs text-slate-400" aria-hidden="true">
@@ -705,7 +714,9 @@ export function GuestRow(props: GuestRowProps) {
<Show when={isSparklineMode()}>
{renderMetricSparkline(
'netIo',
isRunning() ? `${formatSpeed(networkIn())} / ${formatSpeed(networkOut())}` : '—',
isRunning() && telemetryAvailable('networkIO')
? `${formatSpeed(networkIn())} / ${formatSpeed(networkOut())}`
: '—',
`${props.guest.name} network I/O history`,
'B/s',
'tooltip',
@@ -714,7 +725,7 @@ export function GuestRow(props: GuestRowProps) {
</Show>
<Show when={!isSparklineMode()}>
<Show
when={isRunning()}
when={isRunning() && telemetryAvailable('networkIO')}
fallback={
<div class="text-center">
<span class="text-xs text-slate-400" aria-hidden="true">
@@ -758,7 +769,9 @@ export function GuestRow(props: GuestRowProps) {
<Show when={isSparklineMode()}>
{renderMetricSparkline(
'diskIo',
isRunning() ? `${formatSpeed(diskRead())} / ${formatSpeed(diskWrite())}` : '—',
isRunning() && telemetryAvailable('diskIO')
? `${formatSpeed(diskRead())} / ${formatSpeed(diskWrite())}`
: '—',
`${props.guest.name} disk I/O history`,
'B/s',
'tooltip',
@@ -767,7 +780,7 @@ export function GuestRow(props: GuestRowProps) {
</Show>
<Show when={!isSparklineMode()}>
<Show
when={isRunning()}
when={isRunning() && telemetryAvailable('diskIO')}
fallback={
<div class="text-center">
<span class="text-xs text-slate-400" aria-hidden="true">
@@ -17,7 +17,7 @@ import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from './guestDrawerModel';
import { NodeDrawerOverview } from './NodeDrawerOverview';
import {
NODE_DRAWER_HISTORY_GROUPS,
getNodeDrawerHistoryFallbackMetrics,
getNodeDrawerCurrentMetrics,
getNodeDrawerHistoryTarget,
} from './nodeDrawerModel';
@@ -45,7 +45,7 @@ export const NodeDrawer: Component<NodeDrawerProps> = (props) => {
const headingId = () => `node-drawer-heading-${props.node.id}`;
const displayName = createMemo(() => getNodeDisplayName(props.node));
const historyTarget = createMemo(() => getNodeDrawerHistoryTarget(props.node));
const fallbackMetrics = createMemo(() => getNodeDrawerHistoryFallbackMetrics(props.node));
const currentMetrics = createMemo(() => getNodeDrawerCurrentMetrics(props.node));
const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.node.status));
const temperatureThresholds = createMemo(() =>
props.temperatureThresholds !== undefined
@@ -97,7 +97,7 @@ export const NodeDrawer: Component<NodeDrawerProps> = (props) => {
<div class={activeTab() === 'history' ? '' : 'hidden'} style={{ 'overflow-anchor': 'none' }}>
<GuestDrawerHistory
fallbackMetrics={fallbackMetrics()}
currentMetrics={currentMetrics()}
groups={NODE_DRAWER_HISTORY_GROUPS}
range={historyRange()}
target={historyTarget()}
@@ -258,6 +258,60 @@ describe('GuestRow', () => {
expect(cpuBar.dataset.usage).toBe('75');
});
it('renders unknown canonical telemetry as unavailable instead of reported zero', () => {
renderGuestRow({
guest: makeGuest({
cpu: 0,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 0,
telemetryAvailability: {
cpu: false,
memory: false,
disk: false,
networkIO: false,
diskIO: false,
uptime: false,
},
}),
visibleColumnIds: ['name', 'cpu', 'memory', 'netIo', 'diskIo', 'uptime'],
});
expect(screen.queryByTestId('cpu-bar')).toBeNull();
expect(screen.getByTestId('memory-bar')).toHaveAttribute('data-unavailable', 'true');
expect(screen.queryByText('0 B/s')).toBeNull();
expect(screen.queryByText('0s')).toBeNull();
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(4);
});
it('keeps API-reported zero telemetry visible as a real value', () => {
renderGuestRow({
guest: makeGuest({
cpu: 0,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 0,
telemetryAvailability: {
cpu: true,
memory: true,
disk: true,
networkIO: true,
diskIO: true,
uptime: true,
},
}),
visibleColumnIds: ['name', 'cpu', 'netIo', 'diskIo', 'uptime'],
});
expect(screen.getByTestId('cpu-bar')).toHaveAttribute('data-usage', '0');
expect(screen.getAllByText('0 B/s')).toHaveLength(4);
expect(screen.getByText('0s')).toBeTruthy();
});
it.each([1, 4, 8])(
'does not renormalize authoritative guest CPU by %i allocated cores',
(cpus) => {
@@ -1533,7 +1533,8 @@ describe('Workloads performance contract', () => {
expect(guestDrawerHistorySource).toContain('data-testid="guest-history-range-control"');
expect(guestDrawerHistorySource).toContain('h-7 min-h-11 py-0 text-[11px] sm:min-h-0');
expect(guestDrawerHistorySource).toContain('onPointerMove={handleHoverMove}');
expect(guestDrawerHistorySource).toContain('fallbackMetrics');
expect(guestDrawerHistorySource).toContain('currentMetrics');
expect(guestDrawerHistorySource).not.toContain('buildFallbackHistoryPoints');
expect(guestDrawerHistorySource).toContain('props.groups ?? GUEST_DRAWER_HISTORY_GROUPS');
expect(guestDrawerHistorySource).toContain('flex min-h-[154px] flex-col');
expect(guestDrawerHistorySource).toContain('relative min-h-24 flex-1');
@@ -1545,8 +1546,8 @@ describe('Workloads performance contract', () => {
expect(nodeDrawerOverviewSource).toContain('Telemetry');
expect(nodeDrawerOverviewSource).toContain('Thermals');
expect(nodeDrawerOverviewSource).toContain('InfoCardFrame');
expect(nodeDrawerModelSource).toContain("id: 'thermals'");
expect(nodeDrawerModelSource).toContain("metric: 'temperature'");
expect(nodeDrawerModelSource).toContain('HOST_METRICS_HISTORY_GROUPS');
expect(nodeDrawerModelSource).not.toContain("id: 'thermals'");
expect(workloadPanelSource).toContain('NodeDrawer');
expect(workloadPanelSource).toContain('data-inline-node-detail-for');
expect(workloadPanelSource).toContain('const selectedGuestId = props.selectedGuestId()');
@@ -8,7 +8,7 @@ import {
getGuestDrawerAgentLabel,
getGuestDrawerAgentTitle,
getGuestDrawerBackupPresentation,
getGuestDrawerHistoryFallbackMetrics,
getGuestDrawerCurrentMetrics,
getGuestDrawerHistoryRangeBounds,
getGuestDrawerHistoryScale,
getGuestDrawerHistoryTarget,
@@ -76,7 +76,7 @@ describe('guestDrawerModel (branch coverage)', () => {
});
});
describe('getGuestDrawerHistoryFallbackMetrics', () => {
describe('getGuestDrawerCurrentMetrics', () => {
it('scales the canonical workload cpu ratio by 100 and returns finite metric values', () => {
const guest = makeGuest({
cpu: 1.0,
@@ -87,7 +87,7 @@ describe('guestDrawerModel (branch coverage)', () => {
diskRead: 10,
diskWrite: 5,
});
expect(getGuestDrawerHistoryFallbackMetrics(guest)).toStrictEqual({
expect(getGuestDrawerCurrentMetrics(guest)).toStrictEqual({
cpu: 100,
memory: 0.4,
disk: 0.3,
@@ -99,27 +99,25 @@ describe('guestDrawerModel (branch coverage)', () => {
});
it('uses the same ratio contract above 100 percent', () => {
expect(getGuestDrawerHistoryFallbackMetrics(makeGuest({ cpu: 2.0 })).cpu).toBe(200);
expect(getGuestDrawerCurrentMetrics(makeGuest({ cpu: 2.0 })).cpu).toBe(200);
});
it('treats the cpu boundary of exactly 1.5 as a ratio (150)', () => {
expect(getGuestDrawerHistoryFallbackMetrics(makeGuest({ cpu: 1.5 })).cpu).toBe(150);
expect(getGuestDrawerCurrentMetrics(makeGuest({ cpu: 1.5 })).cpu).toBe(150);
});
it('drops cpu when it is a non-finite number', () => {
expect(
getGuestDrawerHistoryFallbackMetrics(makeGuest({ cpu: Number.NaN })).cpu,
).toBeUndefined();
expect(getGuestDrawerCurrentMetrics(makeGuest({ cpu: Number.NaN })).cpu).toBeUndefined();
});
it('drops cpu when it is not a number (typeof guard)', () => {
expect(
getGuestDrawerHistoryFallbackMetrics(makeGuest({ cpu: 'busy' as unknown as number })).cpu,
getGuestDrawerCurrentMetrics(makeGuest({ cpu: 'busy' as unknown as number })).cpu,
).toBeUndefined();
});
it('drops memory/disk when those objects are absent (optional-chain arm)', () => {
const result = getGuestDrawerHistoryFallbackMetrics(
const result = getGuestDrawerCurrentMetrics(
makeGuest({
memory: undefined as unknown as WorkloadGuest['memory'],
disk: undefined as unknown as WorkloadGuest['disk'],
@@ -130,7 +128,7 @@ describe('guestDrawerModel (branch coverage)', () => {
});
it('does not synthesize a history point from unavailable live memory', () => {
const result = getGuestDrawerHistoryFallbackMetrics(
const result = getGuestDrawerCurrentMetrics(
makeGuest({
memory: {
total: 8192,
@@ -145,7 +143,7 @@ describe('guestDrawerModel (branch coverage)', () => {
});
it('drops a non-finite network value via the finite() guard', () => {
const result = getGuestDrawerHistoryFallbackMetrics(
const result = getGuestDrawerCurrentMetrics(
makeGuest({ networkIn: Number.POSITIVE_INFINITY }),
);
expect(result.netin).toBeUndefined();
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { CoreTemp, Node, Temperature } from '@/types/api';
import { getNodeDrawerHistoryFallbackMetrics } from '../nodeDrawerModel';
import { getNodeDrawerCurrentMetrics } from '../nodeDrawerModel';
const makeNode = (overrides: Partial<Node> = {}): Node => ({
id: 'agent:pve-node-1',
@@ -45,14 +45,14 @@ const makeTemp = (overrides: Partial<Temperature> = {}): Temperature => ({
...overrides,
});
describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
describe('getNodeDrawerCurrentMetrics (branch coverage 0712c)', () => {
it('returns temperature: undefined when the temperature object is present but available is missing', () => {
// Optional chain `?.available` with a truthy object whose `available` key is absent:
// !undefined === true -> early null -> ?? undefined (right arm).
const node = makeNode({
temperature: makeTemp({ available: undefined as unknown as boolean }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -63,7 +63,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: 0, cpuMax: undefined }),
});
const result = getNodeDrawerHistoryFallbackMetrics(node);
const result = getNodeDrawerCurrentMetrics(node);
expect(result.temperature).toBe(0);
expect(result).toStrictEqual({ temperature: 0 });
});
@@ -73,7 +73,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: 62.5, cpuMax: undefined }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 62.5,
});
});
@@ -84,7 +84,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: Number.POSITIVE_INFINITY, cpuMax: 71 }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 71,
});
});
@@ -95,7 +95,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: 55, cpuMax: undefined, cores: [] }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 55,
});
});
@@ -114,7 +114,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 92,
});
});
@@ -129,7 +129,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
cores: [{ core: 0 } as unknown as CoreTemp, { core: 1, temp: 75 }],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 75,
});
});
@@ -144,7 +144,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
cores: { '0': { temp: 99 } } as unknown as CoreTemp[],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -162,7 +162,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: -3,
});
});
@@ -173,7 +173,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: Number.NaN, cpuMax: Number.NaN }),
});
const result = getNodeDrawerHistoryFallbackMetrics(node);
const result = getNodeDrawerCurrentMetrics(node);
expect(result).toStrictEqual({ temperature: undefined });
expect('temperature' in result).toBe(true);
});
@@ -192,7 +192,7 @@ describe('getNodeDrawerHistoryFallbackMetrics (branch coverage 0712c)', () => {
],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 95,
});
});
@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { Node, Temperature } from '@/types/api';
import {
getNodeDrawerHistoryFallbackMetrics,
getNodeDrawerHistoryTarget,
} from '../nodeDrawerModel';
import { getNodeDrawerCurrentMetrics, getNodeDrawerHistoryTarget } from '../nodeDrawerModel';
const makeNode = (overrides: Partial<Node> = {}): Node => ({
id: 'agent:pve-node-1',
@@ -155,11 +152,11 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
});
});
describe('getNodeDrawerHistoryFallbackMetrics', () => {
describe('getNodeDrawerCurrentMetrics', () => {
it('returns temperature: undefined for an empty temperature object', () => {
// temperature?.available on {} -> undefined -> !undefined === true -> null -> ?? undefined.
const node = makeNode({ temperature: {} as unknown as Temperature });
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -173,7 +170,7 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
cpuMax: undefined,
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 50,
});
});
@@ -190,7 +187,7 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 95,
});
});
@@ -208,7 +205,7 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 88,
});
});
@@ -218,7 +215,7 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: undefined, cpuMax: undefined, cores: [] }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -228,7 +225,7 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: Number.MAX_VALUE, cpuMax: undefined }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: Number.MAX_VALUE,
});
});
@@ -238,7 +235,7 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: undefined, cpuMax: 68 }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 68,
});
});
@@ -248,7 +245,7 @@ describe('nodeDrawerModel (branch coverage 0713)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: 42, cpuMax: undefined }),
});
const result = getNodeDrawerHistoryFallbackMetrics(node);
const result = getNodeDrawerCurrentMetrics(node);
expect(Object.keys(result)).toEqual(['temperature']);
expect(result.temperature).toBe(42);
});
@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { Node, Temperature } from '@/types/api';
import {
getNodeDrawerHistoryFallbackMetrics,
getNodeDrawerHistoryTarget,
} from '../nodeDrawerModel';
import { getNodeDrawerCurrentMetrics, getNodeDrawerHistoryTarget } from '../nodeDrawerModel';
const makeNode = (overrides: Partial<Node> = {}): Node => ({
id: 'agent:pve-node-1',
@@ -172,11 +169,11 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
});
});
describe('getNodeDrawerHistoryFallbackMetrics', () => {
describe('getNodeDrawerCurrentMetrics', () => {
it('returns temperature: undefined when node.temperature is undefined', () => {
// getCpuTemperature(undefined) -> !temperature?.available -> null -> ?? undefined.
const node = makeNode({ temperature: undefined });
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -184,7 +181,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
it('returns temperature: undefined when node.temperature is null', () => {
// getCpuTemperature(null) -> !temperature?.available -> null -> ?? undefined (right arm).
const node = makeNode({ temperature: null as unknown as Temperature });
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -194,7 +191,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
const node = makeNode({
temperature: makeTemp({ available: false, cpuPackage: 62 }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -204,7 +201,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: 62 }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 62,
});
});
@@ -214,7 +211,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: Number.NaN, cpuMax: 70 }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 70,
});
});
@@ -231,7 +228,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 90,
});
});
@@ -245,7 +242,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
cores: [{ core: 0, temp: Number.NaN }],
}),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 80,
});
});
@@ -255,7 +252,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: Number.NaN, cpuMax: Number.NaN }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -265,7 +262,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: undefined, cpuMax: undefined }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: undefined,
});
});
@@ -275,7 +272,7 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
const node = makeNode({
temperature: makeTemp({ cpuPackage: 40, cpuMax: 55 }),
});
expect(getNodeDrawerHistoryFallbackMetrics(node)).toStrictEqual({
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 55,
});
});
@@ -62,29 +62,27 @@ export interface GuestDrawerBackupPresentation {
export const isGuestDrawerVM = (guest: Guest): guest is VM => resolveWorkloadType(guest) === 'vm';
// Fallback current-value metrics for the guest drawer's history charts.
// Mirrors `getNodeDrawerHistoryFallbackMetrics` — supplies a single
// finite value per metric so `mergeFallbackHistoryMetrics` can synthesize
// a flat 2-point line, replacing the "Collecting history" placeholder
// when the metrics-store hasn't yet accumulated 2+ samples for that
// resource within the selected range. Keys must match the `metric`
// strings declared in `GUEST_DRAWER_HISTORY_GROUPS`.
export const getGuestDrawerHistoryFallbackMetrics = (
guest: Guest,
): Record<string, number | undefined> => {
const cpuPercent = getWorkloadCPUPercent(guest.cpu);
const memUsage = guest.memory?.usageUnavailable ? undefined : guest.memory?.usage;
const diskUsage = guest.disk?.usage;
// Current-value metrics displayed beside history legends while the metrics
// store is still accumulating samples. These values never become chart points:
// a current reading is not evidence of a historical trend.
export const getGuestDrawerCurrentMetrics = (guest: Guest): Record<string, number | undefined> => {
const availability = guest.telemetryAvailability;
const available = (metric: keyof NonNullable<Guest['telemetryAvailability']>): boolean =>
availability?.[metric] ?? true;
const cpuPercent = available('cpu') ? getWorkloadCPUPercent(guest.cpu) : undefined;
const memUsage =
available('memory') && !guest.memory?.usageUnavailable ? guest.memory?.usage : undefined;
const diskUsage = available('disk') ? guest.disk?.usage : undefined;
const finite = (value: number | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
return {
cpu: finite(cpuPercent),
memory: finite(memUsage),
disk: finite(diskUsage),
netin: finite(guest.networkIn),
netout: finite(guest.networkOut),
diskread: finite(guest.diskRead),
diskwrite: finite(guest.diskWrite),
netin: available('networkIO') ? finite(guest.networkIn) : undefined,
netout: available('networkIO') ? finite(guest.networkOut) : undefined,
diskread: available('diskIO') ? finite(guest.diskRead) : undefined,
diskwrite: available('diskIO') ? finite(guest.diskWrite) : undefined,
};
};
@@ -275,15 +273,26 @@ export const hasGuestDrawerOsInfo = (guest: Guest): boolean =>
export const getGuestDrawerAgentLabel = (guest: Guest): string => {
const version = (guest.agentVersion || '').trim();
if (!version) return '';
return isGuestDrawerVM(guest) ? `QEMU ${version}` : version;
if (guest.agentKind === 'pulse') return `Pulse ${version}`;
return guest.agentKind === 'qemu-guest' ||
(guest.agentKind === undefined && isGuestDrawerVM(guest))
? `QEMU ${version}`
: version;
};
export const getGuestDrawerAgentTitle = (guest: Guest): string => {
const version = (guest.agentVersion || '').trim();
if (!version) return '';
return isGuestDrawerVM(guest) ? `QEMU guest agent ${version}` : version;
if (guest.agentKind === 'pulse') return `Pulse Agent ${version}`;
return guest.agentKind === 'qemu-guest' ||
(guest.agentKind === undefined && isGuestDrawerVM(guest))
? `QEMU guest agent ${version}`
: version;
};
export const getGuestDrawerAgentHeading = (guest: Guest): string =>
guest.agentKind === 'pulse' ? 'Pulse Agent' : 'Guest agent';
export interface GuestDrawerMemoryRow {
label: string;
value: string;
@@ -21,8 +21,6 @@ export const getNodeDrawerHistoryTarget = (node: Node): NodeDrawerHistoryTarget
return { resourceType: 'agent', resourceId };
};
export const getNodeDrawerHistoryFallbackMetrics = (
node: Node,
): Record<string, number | undefined> => ({
export const getNodeDrawerCurrentMetrics = (node: Node): Record<string, number | undefined> => ({
temperature: getCpuTemperature(node.temperature) ?? undefined,
});
@@ -32,6 +32,7 @@ import {
} from './workloadTopology';
import {
getGuestDrawerAgentLabel,
getGuestDrawerAgentHeading,
getGuestDrawerAgentTitle,
getGuestDrawerBackupPresentation,
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
@@ -89,6 +90,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
});
const hasOsInfo = createMemo(() => hasGuestDrawerOsInfo(props.guest));
const agentLabel = createMemo(() => getGuestDrawerAgentLabel(props.guest));
const agentHeading = createMemo(() => getGuestDrawerAgentHeading(props.guest));
const agentTitle = createMemo(() => getGuestDrawerAgentTitle(props.guest));
const hasAgentInfo = createMemo(() => agentLabel().length > 0);
const hasWorkloadActionAgent = createMemo(() => hasExplicitWorkloadActionAgent(props.guest));
@@ -199,6 +201,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
return {
activeTab,
agentHeading,
agentLabel,
agentTitle,
backupPresentation,
@@ -120,6 +120,14 @@ export interface WorkloadsSurfaceProps {
additionalDefaultHiddenColumnIds?: string[];
columnLabelOverrides?: Partial<Record<string, string>>;
groupLabelBadges?: Record<string, WorkloadGroupLabelBadge>;
/** Lets an owning platform page share one viewer-safe source-health poll. */
inventorySourcesQuery?: WorkloadsInventorySourcesQuery;
}
export interface WorkloadsInventorySourcesQuery {
error: Accessor<unknown>;
refetch: (options?: { background?: boolean }) => Promise<RuntimeInventorySourcesResponse>;
value: Accessor<RuntimeInventorySourcesResponse>;
}
export type WorkloadSortKey = WorkloadsSortKey;
@@ -148,8 +156,10 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
cacheKey: 'workloads-infrastructure-sources',
enabled: workloadsEnabled,
});
const inventorySourcesResourceKey = createMemo(() => (workloadsEnabled() ? 'enabled' : null));
const inventorySourcesSnapshot = createNonSuspendingQuery<
const inventorySourcesResourceKey = createMemo(() =>
workloadsEnabled() && !props.inventorySourcesQuery ? 'enabled' : null,
);
const ownedInventorySourcesSnapshot = createNonSuspendingQuery<
RuntimeInventorySourcesResponse,
string
>({
@@ -158,6 +168,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
initialValue: EMPTY_INVENTORY_SOURCES_RESPONSE,
cacheKey: (key) => `workloads-inventory-sources:${key}`,
});
const inventorySourcesSnapshot = props.inventorySourcesQuery ?? ownedInventorySourcesSnapshot;
const dedupeGuests = (guests: WorkloadGuest[]): WorkloadGuest[] => {
const seen = new Set<string>();
@@ -386,7 +397,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
alertsActivation.getMetricThresholds('node', 'temperature', nodeOverrideIdCandidates(node));
createEffect(() => {
if (!workloadsEnabled()) return;
if (!workloadsEnabled() || props.inventorySourcesQuery) return;
const handle = window.setInterval(() => {
void inventorySourcesSnapshot.refetch({ background: true });
}, WORKLOADS_INVENTORY_SOURCES_POLL_INTERVAL_MS);
@@ -38,6 +38,7 @@ const CONNECTION_TYPE_LABELS: Record<RuntimeInventorySourceType, string> = {
};
const STATE_RANK: Record<RuntimeInventorySourceState, number> = {
active: 0,
paused: 1,
pending: 2,
stale: 3,
@@ -18,7 +18,7 @@ import { asTrimmedString } from '@/utils/stringUtils';
import { DockerHostDrawerOverview } from './DockerHostDrawerOverview';
import {
DOCKER_HOST_DRAWER_HISTORY_GROUPS,
getDockerHostDrawerHistoryFallbackMetrics,
getDockerHostDrawerCurrentMetrics,
getDockerHostDrawerHistoryTarget,
} from './dockerHostDrawerModel';
@@ -39,7 +39,7 @@ export const DockerHostDrawer: Component<DockerHostDrawerProps> = (props) => {
const headingId = () => `docker-host-drawer-heading-${props.host.id}`;
const displayName = createMemo(() => asTrimmedString(props.host.name) || props.host.id);
const historyTarget = createMemo(() => getDockerHostDrawerHistoryTarget(props.host));
const fallbackMetrics = createMemo(() => getDockerHostDrawerHistoryFallbackMetrics(props.host));
const currentMetrics = createMemo(() => getDockerHostDrawerCurrentMetrics(props.host));
const discoveryConfig = createMemo(() => toDiscoveryConfig(props.host));
const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.host.status));
@@ -83,7 +83,7 @@ export const DockerHostDrawer: Component<DockerHostDrawerProps> = (props) => {
<div class={activeTab() === 'history' ? '' : 'hidden'} style={{ 'overflow-anchor': 'none' }}>
<GuestDrawerHistory
fallbackMetrics={fallbackMetrics()}
currentMetrics={currentMetrics()}
groups={DOCKER_HOST_DRAWER_HISTORY_GROUPS}
range={historyRange()}
target={historyTarget()}
@@ -4,7 +4,7 @@ import type { Resource } from '@/types/resource';
import {
DOCKER_HOST_DRAWER_HISTORY_GROUPS,
getDockerHostDrawerHistoryFallbackMetrics,
getDockerHostDrawerCurrentMetrics,
getDockerHostDrawerHistoryTarget,
} from '../dockerHostDrawerModel';
@@ -118,42 +118,42 @@ describe('dockerHostDrawerModel', () => {
});
});
describe('getDockerHostDrawerHistoryFallbackMetrics', () => {
describe('getDockerHostDrawerCurrentMetrics', () => {
it('returns the host temperature when it is a finite number', () => {
expect(getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: 42 }))).toEqual({
expect(getDockerHostDrawerCurrentMetrics(makeHost({ temperature: 42 }))).toEqual({
temperature: 42,
});
});
it('falls back to docker.temperature when host temperature is absent', () => {
expect(
getDockerHostDrawerHistoryFallbackMetrics(makeHost({ docker: { temperature: 51 } })),
).toEqual({ temperature: 51 });
expect(getDockerHostDrawerCurrentMetrics(makeHost({ docker: { temperature: 51 } }))).toEqual({
temperature: 51,
});
});
it('prefers host temperature over docker.temperature', () => {
expect(
getDockerHostDrawerHistoryFallbackMetrics(
getDockerHostDrawerCurrentMetrics(
makeHost({ temperature: 10, docker: { temperature: 99 } }),
),
).toEqual({ temperature: 10 });
});
it('keeps zero as a valid temperature (boundary)', () => {
expect(getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: 0 }))).toEqual({
expect(getDockerHostDrawerCurrentMetrics(makeHost({ temperature: 0 }))).toEqual({
temperature: 0,
});
});
it('keeps a finite negative temperature', () => {
expect(getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: -5 }))).toEqual({
expect(getDockerHostDrawerCurrentMetrics(makeHost({ temperature: -5 }))).toEqual({
temperature: -5,
});
});
it('rejects NaN on the host temperature and falls through to docker', () => {
expect(
getDockerHostDrawerHistoryFallbackMetrics(
getDockerHostDrawerCurrentMetrics(
makeHost({ temperature: Number.NaN, docker: { temperature: 33 } }),
),
).toEqual({ temperature: 33 });
@@ -161,33 +161,29 @@ describe('dockerHostDrawerModel', () => {
it('rejects Infinity and -Infinity on the host temperature', () => {
expect(
getDockerHostDrawerHistoryFallbackMetrics(
makeHost({ temperature: Number.POSITIVE_INFINITY }),
),
getDockerHostDrawerCurrentMetrics(makeHost({ temperature: Number.POSITIVE_INFINITY })),
).toEqual({ temperature: undefined });
expect(
getDockerHostDrawerHistoryFallbackMetrics(
makeHost({ temperature: Number.NEGATIVE_INFINITY }),
),
getDockerHostDrawerCurrentMetrics(makeHost({ temperature: Number.NEGATIVE_INFINITY })),
).toEqual({ temperature: undefined });
});
it('returns undefined when both host and docker temperatures are non-finite', () => {
expect(
getDockerHostDrawerHistoryFallbackMetrics(
getDockerHostDrawerCurrentMetrics(
makeHost({ temperature: Number.NaN, docker: { temperature: Number.POSITIVE_INFINITY } }),
),
).toEqual({ temperature: undefined });
});
it('returns undefined when neither temperature source is present', () => {
expect(getDockerHostDrawerHistoryFallbackMetrics(makeHost())).toEqual({
expect(getDockerHostDrawerCurrentMetrics(makeHost())).toEqual({
temperature: undefined,
});
});
it('always returns exactly one temperature key', () => {
const metrics = getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: 7 }));
const metrics = getDockerHostDrawerCurrentMetrics(makeHost({ temperature: 7 }));
expect(Object.keys(metrics)).toEqual(['temperature']);
});
});
@@ -28,7 +28,7 @@ export const getDockerHostDrawerHistoryTarget = (
return { resourceType: 'agent', resourceId };
};
export const getDockerHostDrawerHistoryFallbackMetrics = (
export const getDockerHostDrawerCurrentMetrics = (
host: Resource,
): Record<string, number | undefined> => {
const finite = (value: number | undefined): number | undefined =>
@@ -1,5 +1,6 @@
import { useLocation } from '@solidjs/router';
import { Show, createMemo, createResource, type Accessor } from 'solid-js';
import { For, Show, createMemo, createResource, type Accessor } from 'solid-js';
import AlertTriangle from 'lucide-solid/icons/triangle-alert';
import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
@@ -8,7 +9,12 @@ import {
formatAgentVersionDisplay,
} from '@/features/platformPage/agentVersion';
import { ResourceAPI } from '@/api/resources';
import {
RuntimeInventorySourcesAPI,
type RuntimeInventorySource,
} from '@/api/runtimeInventorySources';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import { updateStore } from '@/stores/updates';
import {
PlatformErrorState,
@@ -18,7 +24,10 @@ import {
} from '@/features/platformPage/sharedPlatformPage';
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
import {
useWorkloadsState,
type WorkloadsInventorySourcesQuery,
} from '@/components/Workloads/useWorkloadsState';
import {
DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
type WorkloadsStatusOption,
@@ -53,6 +62,7 @@ const VALID_TABS = new Set<VmwarePageTabId>(VMWARE_TAB_SPECS.map((tab) => tab.id
const VMWARE_PLATFORM_FILTER = 'vmware-vsphere';
const VMWARE_WORKLOAD_STATUS_STORAGE_SCOPE = 'vmware';
const VMWARE_INVENTORY_SOURCES_POLL_MS = 15_000;
const VMWARE_WORKLOAD_COLUMN_VISIBILITY_SCOPE = 'vmware-vms';
// Backup column on the workload table is driven exclusively by Proxmox
// vzdump / PBS data (`resource.proxmox.lastBackup` in useWorkloads).
@@ -80,6 +90,65 @@ const VMWARE_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
const VmwareIcon = getPlatformIcon('vmware');
const vmwareIcon = () => <VmwareIcon class="h-6 w-6 text-slate-400" />;
function VmwareInventoryCompletenessNotice(props: {
error: unknown;
onRetry: () => void;
sources: RuntimeInventorySource[];
}) {
const degradedSources = createMemo(() =>
props.sources.filter(
(source) => source.type === 'vmware' && source.completeness?.state === 'degraded',
),
);
return (
<Show when={props.error || degradedSources().length > 0}>
<div
class="rounded-sm border border-amber-300 bg-amber-50/70 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/20 dark:text-amber-100"
data-testid="vmware-inventory-completeness-notice"
>
<div class="flex items-start gap-2">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">
{props.error
? 'vSphere inventory completeness is unavailable'
: 'Some vSphere inventory details are incomplete'}
</p>
<Show
when={!props.error}
fallback={
<p class="text-xs leading-5 text-amber-800 dark:text-amber-200">
Pulse could not read collection diagnostics, so this page cannot confirm that the
current vCenter inventory is complete.{' '}
<button class="font-semibold underline" type="button" onClick={props.onRetry}>
Retry
</button>
</p>
}
>
<ul class="space-y-1 text-xs leading-5 text-amber-800 dark:text-amber-200">
<For each={degradedSources()}>
{(source) => (
<li>
{source.name}: the last successful collection reported{' '}
{source.completeness?.issueCount ?? 0}{' '}
{(source.completeness?.issueCount ?? 0) === 1
? 'optional read issue'
: 'optional read issues'}
. Affected details may be absent from this page.
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
</div>
</Show>
);
}
export function VmwarePageSurface() {
const location = useLocation();
const { resources, loading, error, refetch } = useUnifiedResources({
@@ -87,6 +156,13 @@ export function VmwarePageSurface() {
cacheKey: 'vmware-workspace',
initialHydration: 'prefer-ws-then-rest',
});
const inventorySources = createNonSuspendingQuery({
source: () => 'enabled',
fetcher: () => RuntimeInventorySourcesAPI.list(),
initialValue: { sources: [] },
cacheKey: (key) => `workloads-inventory-sources:${key}`,
pollMs: VMWARE_INVENTORY_SOURCES_POLL_MS,
});
const requestedTab = createMemo<VmwarePageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1] as VmwarePageTabId | undefined;
return segment && VALID_TABS.has(segment) ? segment : 'overview';
@@ -166,6 +242,11 @@ export function VmwarePageSurface() {
/>
}
>
<VmwareInventoryCompletenessNotice
error={inventorySources.error()}
onRetry={() => void inventorySources.refetch()}
sources={inventorySources.value().sources ?? []}
/>
<Show
when={model().resources.length > 0}
fallback={
@@ -193,6 +274,7 @@ export function VmwarePageSurface() {
setMetricDisplayMode={setMetricDisplayMode}
metricHistoryRange={metricHistoryRange}
setMetricHistoryRange={setMetricHistoryRange}
inventorySourcesQuery={inventorySources}
/>
</Show>
<Show when={activeTab() === 'storage'}>
@@ -263,6 +345,7 @@ interface VmwareOverviewProps {
setMetricDisplayMode: (value: WorkloadsMetricDisplayMode) => void;
metricHistoryRange: Accessor<WorkloadTableMetricHistoryRange>;
setMetricHistoryRange: (value: WorkloadTableMetricHistoryRange) => void;
inventorySourcesQuery: WorkloadsInventorySourcesQuery;
}
function VmwareOverview(props: VmwareOverviewProps) {
@@ -278,6 +361,7 @@ function VmwareOverview(props: VmwareOverviewProps) {
columnVisibilityStorageScope: VMWARE_WORKLOAD_COLUMN_VISIBILITY_SCOPE,
additionalDefaultHiddenColumnIds: [...VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS],
compactGroupHeaders: true,
inventorySourcesQuery: props.inventorySourcesQuery,
groupNodeDrawerMode: 'disabled',
metricDisplayMode: props.metricDisplayMode,
onMetricDisplayModeChange: props.setMetricDisplayMode,
@@ -7,6 +7,7 @@ const mockUseUnifiedResources = vi.fn();
const mockPathname = vi.hoisted(() => vi.fn(() => '/vmware/overview'));
const mockVersionInfo = vi.hoisted(() => vi.fn());
const mockGetGlobalTimeline = vi.hoisted(() => vi.fn());
const mockListInventorySources = vi.hoisted(() => vi.fn());
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
({
@@ -49,6 +50,12 @@ vi.mock('@/api/resources', () => ({
},
}));
vi.mock('@/api/runtimeInventorySources', () => ({
RuntimeInventorySourcesAPI: {
list: (...args: unknown[]) => mockListInventorySources(...args),
},
}));
vi.mock('@solidjs/router', async () => {
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
return {
@@ -118,6 +125,7 @@ describe('VmwarePageSurface contract', () => {
mockPathname.mockReturnValue('/vmware/overview');
mockVersionInfo.mockReturnValue(null);
mockGetGlobalTimeline.mockResolvedValue({ recentChanges: [] });
mockListInventorySources.mockResolvedValue({ sources: [] });
setResources([]);
});
@@ -212,4 +220,37 @@ describe('VmwarePageSurface contract', () => {
expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument();
});
it('surfaces viewer-safe vCenter collection completeness diagnostics', async () => {
mockListInventorySources.mockResolvedValue({
sources: [
{
type: 'vmware',
name: 'Production vCenter',
state: 'active',
surfaces: ['vms'],
completeness: {
state: 'degraded',
issueCount: 3,
issues: [{ stage: 'tags', category: 'permission', occurrences: 3 }],
},
},
],
});
setResources([
makeResource({
id: 'esxi-host-1',
type: 'agent',
vmware: { entityType: 'host', managedObjectId: 'host-1' },
}),
]);
render(() => <VmwarePageSurface />);
const notice = await screen.findByTestId('vmware-inventory-completeness-notice');
expect(notice).toHaveTextContent('Some vSphere inventory details are incomplete');
expect(notice).toHaveTextContent(
'Production vCenter: the last successful collection reported 3 optional read issues.',
);
});
});
@@ -585,6 +585,91 @@ describe('useWorkloads', () => {
dispose();
});
it('preserves API telemetry absence separately from reported zero values on every platform', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
data: [
{
id: 'vmware-vm-unknown-telemetry',
type: 'vm',
name: 'unknown-telemetry',
status: 'online',
lastSeen: 'not-a-timestamp',
sources: ['vmware'],
metrics: {},
agent: { agentVersion: '6.2.0' },
vmware: {
managedObjectId: 'vm-903',
powerState: 'poweredOn',
cpuCount: 8,
},
},
{
id: 'docker-container-zero-telemetry',
type: 'app-container',
name: 'zero-telemetry',
status: 'running',
lastSeen: '2026-08-13T10:00:00Z',
sources: ['docker'],
uptime: 0,
metrics: {
cpu: { percent: 0 },
memory: { used: 0, total: 1024, percent: 0 },
disk: { used: 0, total: 2048, percent: 0 },
netIn: { value: 0 },
netOut: { value: 0 },
diskRead: { value: 0 },
diskWrite: { value: 0 },
},
docker: { containerId: 'zero', runtime: 'docker', uptimeSeconds: 0 },
},
],
meta: { totalPages: 1 },
});
let dispose = () => {};
let result: ReturnType<UseWorkloadsModule['useWorkloads']> | undefined;
createRoot((d) => {
dispose = d;
const [enabled] = createSignal(true);
result = useWorkloads(enabled);
});
await waitForWorkloadCount(() => result!.workloads().length, 2);
const byName = new Map(result!.workloads().map((workload) => [workload.name, workload]));
expect(byName.get('unknown-telemetry')).toMatchObject({
cpus: 8,
agentKind: 'pulse',
lastSeen: '',
telemetryAvailability: {
cpu: false,
memory: false,
disk: false,
networkIO: false,
diskIO: false,
uptime: false,
},
});
expect(byName.get('zero-telemetry')).toMatchObject({
cpu: 0,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 0,
telemetryAvailability: {
cpu: true,
memory: true,
disk: true,
networkIO: true,
diskIO: true,
uptime: true,
},
});
dispose();
});
it('renders vSphere row tags from the vCenter facet, never the provenance keyword set', async () => {
// `Resource.Tags` on vSphere is a mixed keyword set: adapter provenance
// strings (kept so resource search and the `?tags=` filter keep matching)
+62 -22
View File
@@ -154,6 +154,7 @@ type APIResource = {
managedObjectId?: string;
powerState?: string;
guestOsFamily?: string;
cpuCount?: number;
tags?: ResourceVMwareTag[];
};
discoveryTarget?: {
@@ -280,12 +281,29 @@ const normalizeWorkloadStatus = (status?: string | null): string => {
const resolveWorkloadType = resolveWorkloadTypeFromString;
const finiteMetricNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const firstFiniteMetricNumber = (...values: unknown[]): number | undefined => {
for (const value of values) {
const finite = finiteMetricNumber(value);
if (finite !== undefined) return finite;
}
return undefined;
};
const hasMetricValue = (metric?: APIMetricValue): boolean =>
firstFiniteMetricNumber(metric?.percent, metric?.value, metric?.used, metric?.total) !==
undefined;
const buildMetric = (metric?: APIMetricValue) => {
const total = metric?.total ?? 0;
const used = metric?.used ?? 0;
const total = finiteMetricNumber(metric?.total) ?? 0;
const used = finiteMetricNumber(metric?.used) ?? 0;
const free =
metric?.total !== undefined && metric?.used !== undefined ? Math.max(0, total - used) : 0;
const usage = metric?.percent ?? metric?.value ?? (total > 0 ? (used / total) * 100 : 0);
const usage =
firstFiniteMetricNumber(metric?.percent, metric?.value) ??
(total > 0 ? (used / total) * 100 : 0);
return { total, used, free, usage };
};
@@ -299,9 +317,9 @@ const mapNetworkInterfaces = (interfaces?: APINetworkInterface[]) =>
}));
const toIsoString = (value?: string): string => {
if (!value) return new Date().toISOString();
if (!value) return '';
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) return new Date().toISOString();
if (Number.isNaN(parsed)) return '';
return new Date(parsed).toISOString();
};
@@ -439,7 +457,27 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
: rawDisplayId
: undefined;
const cpuPercent = resource.metrics?.cpu?.percent ?? resource.metrics?.cpu?.value ?? 0;
const cpuPercent =
firstFiniteMetricNumber(resource.metrics?.cpu?.percent, resource.metrics?.cpu?.value) ?? 0;
const cpuAvailable = hasMetricValue(resource.metrics?.cpu);
const memoryAvailable = hasMetricValue(resource.metrics?.memory);
const diskAvailable = hasMetricValue(resource.metrics?.disk);
const networkIOAvailable =
hasMetricValue(resource.metrics?.netIn) || hasMetricValue(resource.metrics?.netOut);
const diskIOAvailable =
hasMetricValue(resource.metrics?.diskRead) || hasMetricValue(resource.metrics?.diskWrite);
const uptime = firstFiniteMetricNumber(
resource.proxmox?.uptime,
resource.agent?.uptimeSeconds,
resource.docker?.uptimeSeconds,
resource.kubernetes?.uptimeSeconds,
resource.uptime,
);
const cpuCount = firstFiniteMetricNumber(
resource.proxmox?.cpus,
resource.virtualMachine?.vcpus,
resource.vmware?.cpuCount,
);
const appContainerRuntimeId =
workloadType === 'app-container' ? (resource.docker?.containerId || '').trim() : '';
@@ -492,12 +530,13 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
? 'pod'
: 'app-container',
cpu: getWorkloadCPUFraction(cpuPercent),
cpus: resource.proxmox?.cpus ?? resource.virtualMachine?.vcpus ?? 1,
cpus: cpuCount !== undefined && cpuCount > 0 ? cpuCount : 0,
memory: (() => {
const base = buildMetric(resource.metrics?.memory);
const cache = resource.proxmox?.memoryCache ?? 0;
return {
...base,
usageUnavailable: !memoryAvailable,
// buildMetric derives free as total-used (available); carve the
// reclaimable cache back out so free means truly-free pages.
free: Math.max(0, base.free - cache),
@@ -521,22 +560,15 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
osName: resource.agent?.osName ?? resource.proxmox?.osName ?? resource.vmware?.guestOsFamily,
osVersion: resource.agent?.osVersion ?? resource.proxmox?.osVersion,
agentVersion: resource.agent?.agentVersion,
agentKind: resource.agent?.agentVersion ? 'pulse' : undefined,
networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces),
networkIn: resource.metrics?.netIn?.value ?? 0,
networkOut: resource.metrics?.netOut?.value ?? 0,
diskRead: resource.metrics?.diskRead?.value ?? 0,
diskWrite: resource.metrics?.diskWrite?.value ?? 0,
uptime:
resource.proxmox?.uptime ??
resource.agent?.uptimeSeconds ??
resource.docker?.uptimeSeconds ??
resource.kubernetes?.uptimeSeconds ??
// Canonical Resource.Uptime is the universal fallback — vSphere's
// adapter populates only this field (no platform-specific
// vmware.uptime carve-out), so the chain has to land here for VMware
// VMs to surface uptime in the workloads table.
resource.uptime ??
0,
networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0,
networkOut: finiteMetricNumber(resource.metrics?.netOut?.value) ?? 0,
diskRead: finiteMetricNumber(resource.metrics?.diskRead?.value) ?? 0,
diskWrite: finiteMetricNumber(resource.metrics?.diskWrite?.value) ?? 0,
// Zero can be a real API value for a newly started workload. The
// availability facet below distinguishes that from no uptime observation.
uptime: uptime ?? 0,
template: resource.proxmox?.template ?? false,
lastBackup: (() => {
if (!resource.proxmox?.lastBackup) return 0;
@@ -596,6 +628,14 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
kubernetesClusterId: workloadType === 'pod' ? resource.kubernetes?.clusterId : undefined,
platformType,
platformScopes,
telemetryAvailability: {
cpu: cpuAvailable,
memory: memoryAvailable,
disk: diskAvailable,
networkIO: networkIOAvailable,
diskIO: diskIOAvailable,
uptime: uptime !== undefined,
},
discoveryTarget,
discoveryReadiness: resource.discoveryReadiness,
vmware:
+17
View File
@@ -10,6 +10,15 @@ export type WorkloadType = 'vm' | 'system-container' | 'app-container' | 'pod';
export type WorkloadContainerViewMode = 'container' | 'system-container' | 'app-container';
export type ViewMode = 'all' | 'vm' | WorkloadContainerViewMode | 'pod';
export interface WorkloadTelemetryAvailability {
cpu: boolean;
memory: boolean;
disk: boolean;
networkIO: boolean;
diskIO: boolean;
uptime: boolean;
}
export type WorkloadGuest = (VM | Container) & {
workloadType?: WorkloadType;
/** Canonical unified-resource health, kept separate from runtime power state in `status`. */
@@ -27,9 +36,17 @@ export type WorkloadGuest = (VM | Container) & {
// Canonical platform-page membership. A runtime workload may belong to more
// than one platform scope, e.g. Docker inside a Proxmox LXC.
platformScopes?: string[];
/**
* Records which values were actually present on the unified-resource API.
* A reported zero is available; an absent field is not. Legacy callers that
* do not supply this facet retain their existing presentation.
*/
telemetryAvailability?: WorkloadTelemetryAvailability;
// For app-container workloads, this is underlying runtime telemetry
// ("docker", "podman", etc.), not the owning platform.
containerRuntime?: string;
/** Identifies the producer of agentVersion instead of inferring it from VM type. */
agentKind?: 'pulse' | 'qemu-guest';
updateStatus?: DockerContainerUpdateStatus;
// Server-evaluated capability refusals from the unified resource. The
// update button reads this to disable itself with the refusal reason
+32
View File
@@ -362,9 +362,41 @@ func buildVMwareConnection(
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
}, now)
conn.Fleet.CredentialHealth = connectionFleetCredentialHealth(conn, connectionPasswordCredentialKind(inst.Username, inst.Password), nil, nil, now)
if summary, ok := summaries[strings.TrimSpace(inst.ID)]; ok {
conn.inventoryCompleteness = runtimeVMwareInventoryCompleteness(summary.Observed)
}
return conn
}
func runtimeVMwareInventoryCompleteness(observed *monitoring.VMwareConnectionObservedSummary) *RuntimeInventoryCompleteness {
if observed == nil || !observed.Degraded {
return nil
}
issues := make([]RuntimeInventoryCompletenessIssue, 0, len(observed.Issues))
derivedIssueCount := 0
for _, issue := range observed.Issues {
occurrences := issue.Occurrences
if occurrences < 1 {
occurrences = 1
}
derivedIssueCount += occurrences
issues = append(issues, RuntimeInventoryCompletenessIssue{
Stage: strings.TrimSpace(issue.Stage),
Category: strings.TrimSpace(issue.Category),
Occurrences: occurrences,
})
}
issueCount := observed.IssueCount
if issueCount < derivedIssueCount {
issueCount = derivedIssueCount
}
return &RuntimeInventoryCompleteness{
State: "degraded",
IssueCount: issueCount,
Issues: issues,
}
}
func buildTrueNASConnection(
inst config.TrueNASInstance,
health map[string]monitoring.InstanceHealth,
@@ -1140,6 +1140,42 @@ func TestBuildConnections_VMwareAndTrueNASEnabledFlag(t *testing.T) {
}
}
func TestBuildConnections_VMwareProjectsSanitizedInventoryCompleteness(t *testing.T) {
in := aggregatorInputs{
vmwareInstances: []config.VMwareVCenterInstance{{
ID: "vc1", Name: "vc", Host: "vc.lan", Enabled: true, MonitorVMs: true,
}},
vmwareSummaries: map[string]monitoring.VMwareConnectionSummary{
"vc1": {
Observed: &monitoring.VMwareConnectionObservedSummary{
Degraded: true,
Issues: []monitoring.VMwareConnectionObservedIssue{{
Stage: "guest-details",
Category: "permission",
Message: "vm-42 at https://vcenter.secret.local/sdk denied",
}},
},
},
},
now: time.Now(),
}
var vmwareConnection Connection
for _, connection := range buildConnections(in) {
if connection.Type == ConnectionTypeVMware {
vmwareConnection = connection
break
}
}
completeness := vmwareConnection.inventoryCompleteness
if completeness == nil || completeness.State != "degraded" || completeness.IssueCount != 1 {
t.Fatalf("inventory completeness = %#v", completeness)
}
if len(completeness.Issues) != 1 || completeness.Issues[0].Stage != "guest-details" || completeness.Issues[0].Category != "permission" {
t.Fatalf("inventory issues = %#v", completeness.Issues)
}
}
func TestBuildConnections_PlatformPollerSummariesDriveRuntimeState(t *testing.T) {
now := time.Now().UTC()
vmwareSuccess := now.Add(-40 * time.Second)
+1
View File
@@ -185,6 +185,7 @@ type Connection struct {
agentID string
agentTokenID string
commandChannelConnected *bool
inventoryCompleteness *RuntimeInventoryCompleteness
}
type ConnectionSystemComponentRole string
+29 -11
View File
@@ -30,8 +30,9 @@ var runtimeInventoryBlockingStates = map[ConnectionState]struct{}{
}
// RuntimeInventorySource is the complete monitoring-tier wire shape for one
// enabled source currently blocking workload inventory. It is deliberately a
// standalone whitelist rather than a serialized or embedded Connection.
// enabled source currently blocking workload inventory or reporting degraded
// collection completeness. It is deliberately a standalone whitelist rather
// than a serialized or embedded Connection.
//
// Name is the operator-facing source label needed to identify the problem.
// State is normalized to unauthorized when any cached credential signal says
@@ -39,10 +40,26 @@ var runtimeInventoryBlockingStates = map[ConnectionState]struct{}{
// No source locator, stable connection ID, raw error, timestamp, agent identity,
// fleet policy, capability, credential, or mutation field can cross this type.
type RuntimeInventorySource struct {
Type ConnectionType `json:"type"`
Name string `json:"name"`
State ConnectionState `json:"state"`
Surfaces []string `json:"surfaces"`
Type ConnectionType `json:"type"`
Name string `json:"name"`
State ConnectionState `json:"state"`
Surfaces []string `json:"surfaces"`
Completeness *RuntimeInventoryCompleteness `json:"completeness,omitempty"`
}
// RuntimeInventoryCompleteness is a viewer-safe summary of optional inventory
// reads that failed during an otherwise successful collection. Raw errors,
// entity identifiers, endpoints, and credentials never cross this boundary.
type RuntimeInventoryCompleteness struct {
State string `json:"state"`
IssueCount int `json:"issueCount"`
Issues []RuntimeInventoryCompletenessIssue `json:"issues,omitempty"`
}
type RuntimeInventoryCompletenessIssue struct {
Stage string `json:"stage,omitempty"`
Category string `json:"category,omitempty"`
Occurrences int `json:"occurrences,omitempty"`
}
type RuntimeInventorySourcesResponse struct {
@@ -118,15 +135,16 @@ func runtimeInventorySources(connections []Connection) []RuntimeInventorySource
if runtimeInventorySourceCredentialsInvalid(connection) {
state = ConnectionStateUnauthorized
}
if _, blocking := runtimeInventoryBlockingStates[state]; !blocking {
if _, blocking := runtimeInventoryBlockingStates[state]; !blocking && connection.inventoryCompleteness == nil {
continue
}
sources = append(sources, RuntimeInventorySource{
Type: connection.Type,
Name: connection.Name,
State: state,
Surfaces: surfaces,
Type: connection.Type,
Name: connection.Name,
State: state,
Surfaces: surfaces,
Completeness: connection.inventoryCompleteness,
})
}
@@ -62,6 +62,60 @@ func TestRuntimeInventorySourcesProjectsOnlyBlockingWorkloadCoverage(t *testing.
}
}
func TestRuntimeInventorySourcesProjectsDegradedCompletenessWithoutRawErrors(t *testing.T) {
sources := runtimeInventorySources([]Connection{{
Type: ConnectionTypeVMware,
Name: "Production vCenter",
State: ConnectionStateActive,
Enabled: true,
Surfaces: []string{"vms"},
inventoryCompleteness: &RuntimeInventoryCompleteness{
State: "degraded",
IssueCount: 3,
Issues: []RuntimeInventoryCompletenessIssue{{
Stage: "tags",
Category: "permission",
Occurrences: 3,
}},
},
}})
if len(sources) != 1 {
t.Fatalf("sources = %d, want 1", len(sources))
}
if sources[0].State != ConnectionStateActive || sources[0].Completeness == nil {
t.Fatalf("source = %#v, want active source with degraded completeness", sources[0])
}
if sources[0].Completeness.IssueCount != 3 || sources[0].Completeness.Issues[0].Stage != "tags" {
t.Fatalf("completeness = %#v", sources[0].Completeness)
}
}
func TestRuntimeVMwareInventoryCompletenessDropsMessagesAndEntityIdentity(t *testing.T) {
completeness := runtimeVMwareInventoryCompleteness(&monitoring.VMwareConnectionObservedSummary{
Degraded: true,
IssueCount: 1,
Issues: []monitoring.VMwareConnectionObservedIssue{{
Stage: "guest-details",
Category: "permission",
Message: "vm-42 at https://vcenter.secret.local/sdk denied",
Occurrences: 1,
}},
})
if completeness == nil {
t.Fatal("expected degraded completeness")
}
payload, err := json.Marshal(completeness)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, forbidden := range []string{"vm-42", "vcenter.secret.local", "denied"} {
if strings.Contains(string(payload), forbidden) {
t.Fatalf("completeness leaked %q: %s", forbidden, payload)
}
}
}
func TestRuntimeInventorySourcesNormalizesCredentialFailureWithoutPublishingFleet(t *testing.T) {
expiredAt := time.Now().UTC().Add(-time.Hour)
sources := runtimeInventorySources([]Connection{