mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Make platform telemetry presentation evidence-aware
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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: {
|
||||
|
||||
+7
@@ -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');
|
||||
|
||||
+3
-3
@@ -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: {
|
||||
|
||||
+15
-15
@@ -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);
|
||||
});
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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) => {
|
||||
|
||||
+4
-3
@@ -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()');
|
||||
|
||||
+10
-12
@@ -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();
|
||||
|
||||
+13
-13
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
+10
-13
@@ -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);
|
||||
});
|
||||
|
||||
+12
-15
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -185,6 +185,7 @@ type Connection struct {
|
||||
agentID string
|
||||
agentTokenID string
|
||||
commandChannelConnected *bool
|
||||
inventoryCompleteness *RuntimeInventoryCompleteness
|
||||
}
|
||||
|
||||
type ConnectionSystemComponentRole string
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user