diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index a77903e369..65a2054db1 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -95,11 +95,6 @@ "path": "cmd/pulse-agent/service_windows.go", "kind": "file" }, - { - "repo": "pulse", - "path": "frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx", - "kind": "file" - }, { "repo": "pulse", "path": "frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts", @@ -5666,26 +5661,11 @@ "performance-and-scalability" ], "evidence": [ - { - "repo": "pulse", - "path": "frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx", - "kind": "file" - }, - { - "repo": "pulse", - "path": "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", - "kind": "file" - }, { "repo": "pulse", "path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", "kind": "file" }, - { - "repo": "pulse", - "path": "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", - "kind": "file" - }, { "repo": "pulse", "path": "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts", @@ -5923,7 +5903,7 @@ }, { "repo": "pulse", - "path": "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", + "path": "frontend-modern/src/hooks/useUnifiedResources.ts", "kind": "file" }, { diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6813b04986..dd1d022b02 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -15,6 +15,14 @@ ## Purpose +### Alert projections carry a volatile metric status — issue #2068 + +`models.Alert` gains an optional `metricStatus` (`models.MetricAlertStatus`) +beside the alert-engine metadata. It is a live evaluation snapshot owned by +the alerts subsystem. Agent report admission, host continuity and +re-enrollment never read or write it, and it is not persisted with agent or +state records. + ### Source-record lookup preserves report admission Host and Docker report admission still performs canonical host-view matching @@ -288,6 +296,19 @@ not disable the local host, SMART, Ceph, or Proxmox reporting modules, alter agent enrollment or identity, or add remote command authority. The disabled path is pinned by `TestCollectClusterSensors_Disabled`; configuration parsing is pinned by `TestLoadConfigDisableClusterPeerSensorsFlag`. +Pulse attributes peer readings to the agent that reported them. Peers are +named by bare Proxmox node name, which a node of another connection can share, +so monitoring keys its cluster sensor cache by reporting agent and node name. +A reading serves a node only when the agent's currently linked node belongs to +that node's connection, or to another connection with a fingerprint-proven view +of the node (a cluster added twice); attribution follows the agent's link like +its own sensors. An unlinked reporter's peer readings serve no node, nor do +those of a reporter whose manual link names a node ID the read state does not +hold. This reads +the existing agent link and adds no identity, enrollment, link or command +authority; the ingest path is pinned by +`TestApplyHostReportScopesClusterSensorsToReportersConnection` in +`internal/monitoring/monitor_host_agents_test.go`. On supported Linux systemd hosts, the opt-in safe runtime is a root-owned, unprivileged monitoring collector plus the no-network typed helper, with remediation installed only as the separate root-owned `pulse-agent-runner`. @@ -734,6 +755,23 @@ agent's SMART temperature and I/O counters as last-known values but marks them report replaces them. Expiry is compare-and-set on the report time the offline sweep judged stale, so a report admitted between that judgement and the expiry is never expired. +A legacy agent's report carries no collection state, so its expiry records +`unavailable` without a source; monitoring's Proxmox-disk copy and the +unified-resources registry both read that through +`diskinventory.LegacyHostAgentStatus` (the `host_agent` source), so the +withdrawal still supersedes the copy a Proxmox disk keeps once its host stops +being polled. +Unraid array-inventory temperatures follow the same lease: while the host is +offline the unified-resources adapter reports them `unavailable` with the same +reason (`models.HostAgentStoppedReportingReason`) and leaves them out of the +disk's risk, and the metrics-history API does not return any such retained +disk temperature as a live point. +Downstream readers keep that distinction too. The disk temperature charts do +not pad a series to now with the retained value. The AI chat context, Patrol and +the AI disk tools present it only as a last-known value with its reason. The +performance report and the reporting runtime snapshot leave it out of their +disk tables. The performance report colours a collected reading by the +tenant's alert disk temperature thresholds for the disk type, not a fixed line. An enabled availability target assigned to a host agent creates an agent-lifecycle lease for that exact target/agent pairing. First assignment @@ -2733,6 +2771,11 @@ agent inventory, registration state, or command-channel readiness. reports, but workspace logo material remains API/security/reporting ownership and must not become agent credential, install-token, or fleet lifecycle state. + Report alert rows built there carry an alert's handover resolution, so a + node alert that moved to its linked Pulse agent reads as moved, not + recovered. That is report presentation only: the report never decides + agent linkage, ownership of a metric, or agent freshness, and it adds no + lifecycle state. The same isolation rule applies to Patrol investigation-record propagation through shared AI intelligence handlers and `internal/api/router.go`: lifecycle surfaces may observe the resulting resource context, but they must diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 56b2a47c66..2e1d2f5826 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -138,6 +138,28 @@ status does not establish current resource health. Assistant handoff includes the latest bounded events, their source provenance and query coverage. It preserves the existing exclusion of raw command arguments/output. An absent closure does not acquire a duration measured up to the current time. +A PVE disk alert row written under the physical disk its hardware identity +names (`unifiedresources.OwnedAlertReference`) is matched to occurrences under +its alert's own reference, as when it was journaled there, while its evidence +keeps the owning disk. Reads by alert identifier and by that reference +therefore return the same occurrences as before ownership, including one +occurrence whose rows two disks own after one took over the path while +failing. The one difference is an owned row whose occurrence's firing the +read does not hold (another disk owns it, or retention removed it): before +any event is assigned, its recorded start takes the occurrence that starts +there, else the closest saved shell within the start tolerance, else opens +the occurrence under the ID the alert-centric read gives it, instead of +leaving the row as partial evidence. That boundary is not canonical, so a +truncated read still keeps events without a start away from it. A read by a +disk returns the occurrences its rows belong to and admits their saved +shells, so a note recorded from a disk read reaches the occurrence the +alert-centric read shows. The exception is two occurrences of one alert +starting within the shell tolerance: the alert-centric read assigns a saved +shell by firing order, which a disk read missing a firing cannot reproduce. +`TestIncidentQueryKeepsOccurrencesOfHardwareOwnedDiskAlerts` and +`TestIncidentQueryTruncatedReadKeepsOwnedOccurrencesGuarded` in +`internal/ai/memory/incident_query_disk_owner_test.go` compare both layouts, +the first on the memory and SQLite stores. ### Canonical Patrol and Assistant continuation, 2026-09-07 @@ -288,6 +310,41 @@ exposing the source-specific ambiguous `wearout` name, and distinguishes it from `smart.percentageUsed` as consumed endurance. The regression proof is `internal/ai/tools/physical_disk_evidence_test.go`. +Disk temperatures reach the model as current readings only when they were +collected now. `tools.SplitDiskTemperature` classifies a physical disk +temperature with `diskinventory.TemperatureCollected`. The physical-disk +projection's `temperature` and the `disk_health` SMART row's `temperature` +carry only a reading the current observation collected. A value normalization +retained instead (a disk in standby, a host agent past its reporting lease) +moves to `last_known_temperature` with `last_known_temperature_reason`. The +same split feeds the AI chat context and Patrol. The "Physical Disks Needing +Attention" list and Patrol's disk issue gate, triage flags and finding +verification judge heat on the collected reading alone. Their text renders a +retained value as "last known 41C (disk is in standby)". Proofs: +`TestDiskToolsReportRetainedTemperatureAsLastKnown` in +`internal/ai/tools/physical_disk_evidence_test.go`, +`TestBuildUnifiedResourceContextJudgesDiskHeatOnCollectedTemperature` in +`internal/ai/resource_context_test.go` and +`TestPatrolPhysicalDiskRowsJudgeHeatOnCollectedTemperature` in +`internal/ai/patrol_ai_more_test.go`. + +They judge that collected reading against the alert disk temperature policy, +not a fixed line. `ThresholdProvider.GetDiskTemperatureThreshold(diskType)` +(the alert manager's per-type trigger and clear) resolves each disk's limits +once in `patrolPhysicalDiskRows`, and the run state carries the provider +(`patrolRuntimeState.thresholdProvider`). With no provider the factory alert +configuration applies. A disk is hot from its trigger: a triage warning, a disk +issue and an AI chat "needing attention" entry, as its temperature alert and +Physical Disks Running Hot verdict are. A disk-high finding recovers at or +below the clear value (under the trigger when there is no band below it), as +the alert does. With no current reading, a disk last seen hot leaves that +verification unknown rather than recovered. Scoped runs keep the provider, and a +provider set before Patrol starts is handed to it. Below the trigger Patrol flags nothing, so an NVMe at 63C stays +quiet and a SATA disk at 56C is flagged. Proofs: +`internal/ai/patrol_disk_temperature_test.go` and +`TestAlertThresholdAdapter_DiskTemperatureFollowsAlertPolicy` in +`internal/ai/alert_threshold_adapter_test.go`. + Retained summaries disclose that point and bucket timestamps describe returned history inside the requested window. Their spacing does not measure collection uptime or explain missing history. Collector lifecycle and retention settings @@ -4651,9 +4708,14 @@ metadata when it projects from the canonical timeline). `TestService_buildAlertContext_HandoverIsNotARecovery`, `TestExecuteListResolvedAlertsReportsHandover` and `TestIncidentStore_HandoverCloseIsNotARecovery` pin it. Performance reports -(`pkg/reporting`, the report narrator) do not carry the resolution yet: a -report generated within the five-minute recently-resolved window after a -handover still lists that alert as resolved. The projection also collapses open/resolved churn: +carry the resolution too: the report narrator sends such an alert with +`resolved: false` and the engine's summary as `resolution`, plus +`successor_alert_listed` when the same report lists the alert the resource +it moved to raised for that metric. Its system prompt forbids counting the +alert as resolved or calling the resource healthy on its account, and tells +the model to describe a covered condition once, from the successor's alert +(`TestBuildReportNarratorPayload_HandoverIsNotARecovery`). The +deterministic verdict lives in `pkg/reporting` (see api-contracts). The projection also collapses open/resolved churn: when a record's timeline holds at least four open-to-resolved or resolved-to-open transitions inside the last 24 hours, the item carries a `flapping` summary (transition count, window, first and latest transition) and @@ -4668,6 +4730,21 @@ Assistant receives a selected attention item only after the operator opens it. The handoff is explanation-only and cannot infer a capability, approve an action, or replace evidence and lifecycle truth. +A held threshold alert's attention summary leads with the live reading (issue +#2068). An alert's `Message` keeps the last breach while the alert waits below +its trigger for the clear level, so for a `MetricStatus` in the `latched` or +`recovering` phase that is under ten minutes old, the projection builds +`plainLanguageSummary` from the status instead: the reading now, then for a +latched alert the alert level it is back under and the clear level with its +hold, and for a recovering alert the clear level, delay and progress so far +(rounded down). The words and rounding follow the frontend's +`features/alerts/metricAlertPresentation.ts`, and a guest's disk is named +from `metadata.label`. Temperatures stay in °C like the alert messages, +because the server does not know a viewer's unit preference. A breaching +alert keeps `Message`, which tracks the breach, and a stale or missing status +falls back to it. The Patrol inbox, the investigation handoff and API clients +read that summary, so none of them re-derives the phase. + ### Patrol model-readiness evidence Keep the Patrol model-readiness advisor provider-neutral and evidence-based. @@ -6508,6 +6585,20 @@ Patrol scope payloads. API-backed TrueNAS systems may still keep `truenas` platform metadata and separate run-history coverage counts, but AI resource type fields must normalize to canonical `agent` once they cross the governed runtime boundary. +Patrol scope resolution registers the alert subsystem's Docker references as +aliases, built by the same `internal/alerts` builders rather than formatted +locally: `DockerHostResourceID` on the Docker host record, and on each +container record `dockerAlertScopeAlias`, which passes the container's name so +a container reported without an ID answers to its marked name reference +(`docker:/name:`) and never to its host's reference. +`dockerServiceAlertScopeAliases` registers service references, including +`docker:/service/name:` for a service without an ID, on the +owning host. The legacy snapshot record of a container without an ID has no ID +to scope by, so only the unified read state can scope its alerts. Proof: +`TestResolvePatrolScopeResolvesDockerAlertResourceIDs` in +`internal/ai/patrol_run_test.go` and +`TestDockerServiceAlertScopeAliasesDeriveIDFromName` in +`internal/ai/patrol_state_scope_alias_test.go`. The same governed-context rule also applies to the main unified AI resource overview: infrastructure, workload, alert-label, and top-consumer summaries must not leak raw resource names, cluster labels, IP addresses, or unresolved @@ -6788,8 +6879,8 @@ That posture snapshot must render redaction labels through the canonical unified-resource hint order, not alphabetically, so the AI summary, drawer, and any future policy surfaces all present the same redaction precedence. Its sensitivity and routing counts must also follow the canonical -unified-resource order and shared human-readable count summaries, so both the -backend summary and the frontend policy card stay aligned on the same +unified-resource order and shared human-readable count summaries, so the +backend summary and any frontend posture presentation stay aligned on the same presentation sequence. The unified AI resource data-governance block must also use the shared unified-resource redaction-label helper directly, so the same canonical diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 56139aec7d..bd078f2f01 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -15,6 +15,40 @@ ## Purpose +### Open threshold alerts carry a volatile live status — issue #2068 + +`evaluateCanonicalMetricAlert` builds `MetricStatus` from the evaluator's own +inputs on every evaluation, including the hysteresis hold and the recovery +run, where `Value`, `Message` and `LastSeen` stay at the last breach and no +branch may notify. The phase comes from the trigger, the clear level handed to +the reducer (a value at or below it counts toward recovery) and the reducer's +recovery run; progress uses that run's monotonic elapsed time. The status is +stripped from the active-state checkpoint, the recovery mirror and the +degraded recovery projection, so a held alert adds no durable writes, and +after a restart it is absent until the next evaluation. Resolved records carry +no live status. Stale-alert cleanup and the retention TTLs use the latest +observation (`lastObservedAt`), so an alert held below its trigger for over a +day is not auto-resolved as unmonitored. Frontend surfaces format the status +through `features/alerts/metricAlertPresentation.ts` and never re-derive the +phase or recovery timing. + +### Alert card and open incident lead with the live reading — issue #2068 + +The Alerts overview card leads an open threshold alert with the presentation's +summary and clear rule, keeps the last breach as the summary's hover text, and +labels the alert and clear levels in the status's unit (temperatures follow +the viewer's °C/°F preference, as every frontend reading does). Without a live +status it keeps the message and labels an alert level only for metric types +whose unit is known; other thresholds (queue ages, counts) are stated in their +own message, so the card never guesses a percent. The resource incident panel +describes only the open occurrence whose `alertIdentifier` and `openedAt` +match an active alert's id and `startTime` (the incident store's occurrence +key), from that alert's live status, rechecked on the shared relative-time +clock so it turns stale when evaluations stop; closed occurrences keep their +recorded message. Every disk alert on a guest carries the guest's name, so the +presentation names the disk from `metadata.label`, as the per-disk message +does. + ### Retained guest observations are not alert evidence Guest memory marked `last-known`, `unavailable` or an unknown observation state @@ -373,9 +407,10 @@ own inline boundary. A wearout arm keyed on `> 0` silently exempts the single worst reading a disk can publish, which let a spent SSD read critical on the Physical Disks surface while raising no alert at all. -Proxmox wearout recovery requires three consecutive reported readings of at -least 10% remaining life. An absent reading does not prove recovery, and a -new low reading resets the recovery run; until recovery is confirmed the +Proxmox wearout recovery requires three consecutive reported readings above +10% remaining life; a reading of exactly 10% still matches the alert. An +absent reading does not prove recovery, and a new low reading resets the +recovery run; until recovery is confirmed the existing alert retains its low-life value and occurrence. This bounds the resolved-then-refired notification loop when endurance data briefly looks healthy (#2112), while a sustained replacement/corrected reading can still @@ -384,6 +419,23 @@ resolve. `TestCheckDiskHealthWearoutFlappingDoesNotRepeatNotifications` and deduplication and confirmed recovery. This is synthetic source proof, not a claim that the reporter's installed image has been repaired. +The Proxmox wearout value is percent life remaining, so its alert message names +it that way (`SSD life remaining is 7%`), matching the host-agent SMART +`wearout_low` summary; the threshold travels in the alert's `Threshold` field, +which the Alerts card and notifications render beside the message. Calling the +number "wearout" read as 7% worn and contradicted the low-life warning. +`CheckDiskHealth` logs a failed disk at error and a worn disk at warn only on +the evaluation that opens the alert (the canonical `activated` transition); +later polls that still find the disk failed or at or below the wearout +threshold, including mock mode's per-tick evaluation, log at debug. Logging on +every evaluation repeated one failed disk's error on every poll for as long as +it stayed failed. `TestCheckDiskHealthLowWearoutCreatesAlert` pins the message +and `TestCheckDiskHealthLogsAlertOpeningOnce` pins the log-once boundary at the +threshold, including a fresh error when a recovered disk fails again. That test +captures logs through a hook installed in the package `TestMain`, because +assigning `log.Logger` inside a test races with managers earlier tests left +running. + Host SMART counter growth is an event boundary rather than a warning on every historical non-zero value. For an agent-only disk, the first reported UDMA CRC error count establishes an in-memory baseline and raises no alert. A later @@ -770,6 +822,34 @@ that fast path entirely: active-alert and resolved-alert stores keep their own commit path, resource `alerts` facets are not in the fast-path allow-list, and a row whose patch touches alert-relevant structure always takes the full canonical merge. +That commit path replaces each stored alert with the server's payload for its +id instead of merging into it. A field the server omits is removed from the +store, whether the payload came from a socket snapshot, a keyed delta, or REST +recovery: a threshold alert restored after a restart carries no `metricStatus` +until its next evaluation, so the previous live reading must not keep reading +as "now"; an unacknowledge from another session clears `ackTime` and +`ackUser`; a dropped `nodeDisplayName` or `metadata` disappears. Resolved +alerts follow the same rule. The pending acknowledgement hold still skips a +payload that contradicts an unconfirmed local acknowledge or unacknowledge, +and the confirming payload then replaces the optimistic copy. Because a +replacement reconciles the stored alert in place, local optimistic updates are +stored as copies, so it never rewrites a record a caller keeps for rollback. +The store's `updateAlert` holds the only optimistic copy of an acknowledgement. +`useAlertAcknowledgementState` writes through it and keeps no override of its +own, so once the server confirms it, a later server change such as an +unacknowledge from another session reaches the open tab. When no hold is +pending and the stored alert already shows the local acknowledge or +unacknowledge, the store sets no hold and keeps the server's `ackTime` and +`ackUser`: the request's own broadcast can reach the socket before its HTTP +response, and a hold would then wait for a second confirmation and skip the +next real change. Every local acknowledgement write supersedes a REST recovery +in flight: the store discards that response, which the server may have built +before accepting the write, and fetches again once it settles. A hold that +gives up after 15 seconds re-syncs from `/api/alerts/active`, waiting for a +recovery already in flight and fetching again if that one applies nothing, +because the payloads the hold skipped may not be followed by another alert +change. `websocket-unified.test.ts`, `useAlertAcknowledgementState.test.tsx` +and `useAlertOverviewState.test.tsx` pin these paths. Operational evidence and lifecycle identity are typed through `internal/operationaltrust`. Evidence envelopes distinguish completeness, confidence, permissions, freshness, correlation, and bounded provider detail. @@ -1460,6 +1540,45 @@ short-ID, unified-hash, and slash-tail forms as trailing lookup candidates). Container override work must not reintroduce a runtime-container-ID persistence key. +Docker alert references come only from the builders in +`internal/alerts/docker.go`: a host's own alert `docker:` +(`DockerHostResourceID`, used only by its connectivity alert), a container +`docker:/` (`DockerContainerResourceID`), and a Swarm +service `docker:/service/` (`DockerServiceResourceID`). +A container or service reported without an ID alerts under its name, marked in +the ID position (`docker:/name:`, +`docker:/service/name:`). Docker and Swarm IDs never +contain a colon, so a name reference never takes its host's reference or +another service's ID reference, and one with neither an ID nor a name raises +no alert. Report cleanup, host removal and the host's offline alert firing +clear children by the `docker:/` prefix; the container and service +policy switches classify by the first path segment after the host +(`dockerAlertResourcePath`), so a container name containing `/service/` stays +a container; and Patrol's scope aliases and resource history parse the same +forms. Service overrides key on the same reference, so an override written for +a service without an ID under its old unmarked name no longer applies. A +reference can contain the canonical state separator (a Docker host +disambiguated as `::`), so `splitCanonicalStateID` splits a +state ID before the alert's recorded spec ID, or else after its resource ID, +and only without either at the first `::`. Splitting at the first `::` gave +such a host's alerts the reference of the host named ``; an alert saved +that way recovers its reference on restore because its spec ID is intact. +Before name references existed, a container without an ID alerted under its +host's reference. Only the connectivity alert belongs there, so the host's +next report, or its offline alert firing, clears any such alert +left active whose recorded host matches and whose container ID is empty. A +pending image update recorded there keeps its age under the container's new +reference when that first report still shows the update pending; a first +report with missing or failed update evidence clears it, and the delay starts +again on the next positive report. Proof: +`TestDockerAlertsWithoutIDsUseTheirOwnReferences`, +`TestDockerHostIDWithSeparatorKeepsItsAlertReferences`, +`TestDockerAlertsRestoredWithCutShortReferencesRecover`, +`TestDockerContainerWithoutIDKeepsPendingUpdateAgeAcrossUpgrade` and +`TestDockerContainerResourceID` in `internal/alerts/alerts_test.go`, and +`TestOwnerAlertTimelinesUseCanonicalHistoryIdentity` in +`internal/monitoring/monitor_alert_handling_test.go`. + Backup orphan evaluation is also inventory-scoped. The alerts runtime may evaluate recovery rollups for backup age, but unresolved Proxmox PVE backup subjects must not be treated as orphaned until monitoring has supplied the @@ -1665,6 +1784,35 @@ an alert the next evaluation raises again. `TestConfigSaveKeepsAlertTheEvaluatorKeepsWithoutRecoveryBand` in `internal/alerts/host_unraid_lifecycle_test.go` pin these paths. +### The disk temperature policy decides disk heat + +The policy `CheckHost` applies to a SMART disk (the agent Disk Temp default, +refined by the disk type's `DiskTempByType` entry, and switched off for every +type when that default is off) is the canonical answer to "is this disk too +hot". `diskTemperatureThresholdForType` in `internal/alerts/host.go` holds it, +and `Manager.DiskTemperatureThreshold(diskType)` exposes it, without host +overrides, to Patrol through `AlertThresholdAdapter.GetDiskTemperatureThreshold`. +`DefaultDiskTemperatureThreshold` serves callers with no manager. Disk risk +(`internal/storagehealth`) judges no temperature. The Physical Disks Temp cell +and Health verdict mirror the per-type triggers and a switched-off default in +`resolveDiskTemperatureDisplayThresholds`; host overrides do not reach that +page yet. That resolver returns null when the agent Disk Temp default is off. +The displays that read it treat null as off: no threshold colour +(`getTemperatureTextClass` with `diskTemperature`) and never hot. A +`DiskTempByType` entry cannot be switched off on its own, because +normalization restores a non-positive entry to its default. +A disk is hot from its trigger. Alerts and Patrol findings stay open until the +reading falls to the clear value, or under the trigger when there is no band +below it. The Physical Disks verdict and the TrueNAS Health cell judge only the +current reading against the trigger. PDF reports colour disk temperatures by +the same thresholds. Judges that still differ: TrueNAS disk temperature alerts +use `TrueNASDiskDefaults.Temperature` (a flat 55/50), and the TrueNAS disk +drawer tones the reading from a fixed 55C. +`TestDiskTemperatureThresholdMatchesCheckHostPolicy` in +`internal/alerts/threshold_resolution_shared_test.go` pins per-type resolution, a raised +NVMe trigger that `CheckHost` also honours, the copy, the disabled default and +the nil manager. + ### Agent disk temperature alerts clear when their disk leaves the report `CheckHost` also clears a disk temperature alert, and drops its pending @@ -2038,6 +2186,17 @@ wearout recovery rules. `TestProxmoxDiskCanonicalResourceIDTrimsIdentity` pins the persisted identity shape; monitoring's registry-backed `TestProxmoxPhysicalDiskMuteResolvesAndSuppressesWearoutAlert` pins the policy path. These are source invariants, not evidence of installed field relief. +Each PVE disk alert records the evaluated disk's serial and WWN +(`unifiedresources.MetadataDiskSerial`, `MetadataDiskWWN`), and resource +history owns each lifecycle row by that identity rather than by the path. A +row without a usable serial or WWN names only an identity-less disk at its +path, and a row whose identity is ambiguous, or names no disk but one another +resource already holds, stays under the path reference. +Reads by the path reference find those rows through the alert identifiers +`ProxmoxPhysicalDiskAlertIdentifiers` lists, so spec IDs and occurrence IDs +must keep matching it; +`TestCheckDiskHealthAlertsCarryHistoryOwnershipIdentity` in +`internal/alerts/alerts_test.go` pins both. Shared metric threshold runtime now lives in `internal/alerts/metric_runtime.go`. That file owns metric threshold lookup, per-metric delay and intent resolution, reducer input composition, active-alert diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ad7c344d4c..6b204396df 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -20,6 +20,19 @@ ## Purpose +### Open threshold alerts expose their live evaluation — issue #2068 + +`Alert.metricStatus` (`/api/alerts/active`, the websocket state and +`/api/state`) is optional and additive. It carries `phase` (`breaching`, +`latched` or `recovering`), `value` (the value compared with the rule, the +average for a rolling-average rule) with `rawValue` and +`evaluationWindowSeconds` when windowed, `unit`, `observedAt`, `trigger`, +`recovery` (the alert clears at or below it), `recoveryDelaySeconds`, and, +while recovering, `recoveryStartedAt` and `recoveryElapsedSeconds`. Legacy +`value`, `message` and `lastSeen` keep meaning the last reading that met the +trigger. The field is absent for non-threshold alerts and until the first +evaluation after a restart, and clients then fall back to `message`. + ### Organisation deletion retains data after incomplete monitoring shutdown Authenticated organisation-owner deletion waits for tenant-loop exit and sealed @@ -3294,7 +3307,7 @@ a new API state machine, queue contract, or verification-accounting field. and the shared dashboard-load bundle inside `frontend-modern/src/stores/aiIntelligence.ts`, so the page orchestration stays on the store-owned bundle instead of enumerating the AI fetches inline and the Patrol page refresh lifecycle in `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, so slow or stalled secondary reads from that shared dashboard-load bundle may continue resolving in the background while the operator-facing Patrol refresh control remains generation-aware, timeout-bounded, and reusable once Patrol findings and status are already visible and the Patrol header support drawer in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, so API-owned Patrol status and trigger facts can feed a secondary Schedule & model surface without turning provider model, schedule, trigger tuning, or background-only runtime-policy pauses into the primary Patrol control decision - and the shared `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` card, so the AI summary page renders the governed policy-posture counts while the resource drawer stays on per-resource policy lines instead of carrying duplicate posture UI loops + and any frontend presentation of the AI summary's `policy_posture` counts, which must render that canonical snapshot through one shared component instead of page-local posture loops; no frontend surface renders those counts today, and the resource drawer stays on per-resource policy lines and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so recent-change, learned-correlation, and policy-coverage summary text stays derived from the canonical AI payload in one place instead of as hook-local count and pluralization logic and the Watch-only forward-path handoffs owned by `frontend-modern/src/features/patrol/patrolControlPresentation.ts` together with the Actions inbox empty-state read of `GET /api/ai/patrol/autonomy`, so finding-level and inbox-level mode guidance consumes the canonical Patrol autonomy read/save contract through the Patrol state hook (`handleAutonomyChange`) instead of introducing a second mode mutation path or a page-local autonomy dialect and that same Patrol investigation-context owner, so the current Patrol @@ -5371,6 +5384,15 @@ Backend API payloads and `frontend-modern/src/types/api.ts` must preserve that optional map without making it a required compatibility field, and clients must keep it as descriptive host telemetry rather than a temperature metric, resource identity, alert metric, or storage/recovery signal. +Host sensor SMART rows (`smart[]`) also carry the optional per-field +`collection` status (`PhysicalDiskCollectionStatus`). Backend API payloads and +`frontend-modern/src/types/api.ts` (`HostDiskSMART.collection`) must preserve it +on the agent facet. When a host agent passes its reporting lease, a SMART +temperature that was current (`available`, or present without provenance) keeps +its value and becomes `unavailable`. A state that was already not available is +kept. Clients must read that state through the shared +`isPhysicalDiskTemperatureCurrent` decision before treating the value as a +current reading. Agent resource-context sections expose host package posture only as bounded operational facts: package manager, pending count, inventory state, inspection freshness, and reboot-required state. Raw package identifiers, versions, and @@ -6314,6 +6336,34 @@ windows. `/api/metrics-store/history` must accept `resourceType=disk`, keep `MetricsTarget.ResourceID` that unified resources already expose, instead of leaving storage drawers or other callers to fork a disk-local history route or invent an alternate disk identity. +When a disk range has no samples, the `smart_temp` live fallback (single-metric +and all-metrics) is served only for a temperature that is collected +(`diskinventory.TemperatureCollected`). A temperature that normalization kept +under a non-available collection state, such as a disk in standby or a host +agent past its reporting lease, is omitted, so the range stays empty instead of +returning that value at `now` with `source: "live"`. Mock mode derives a +synthetic disk temperature series from the disk's reading, or pads a sparse +stored one out to `now`, only from a temperature collected now; without one the +stored samples are returned as they are. An empty mock range can still fall +through to the monitor's generic demo chart series for that disk ID, which is +not derived from the disk's reading. +Reports follow the same rule. A performance report's disk table +(`enrichNodeReport` in `internal/api/metrics_reporting_handlers.go`) and the +reporting runtime snapshot's disks (`internal/api/reporting_runtime_snapshot.go`) +take a disk temperature through `reportDiskTemperature`. It keeps a reading only +when `diskinventory.TemperatureCollected` holds, so a retained value is reported +as 0 (no reading) rather than tabulated as measured. Proof: +`TestContract_ReportsOmitRetainedDiskTemperatures` in +`internal/api/contract_test.go`. +Each reported disk also carries its alert disk temperature thresholds +(`reporting.DiskInfo.TemperatureWarning` / `TemperatureCritical`, the clear +value and the trigger). `reportDiskTemperatureThresholds` resolves them from +the tenant's alert manager (`alerts.Manager.DiskTemperatureThreshold`, the +factory policy when there is none). The PDF disk table colours a reading amber +from the clear value and red from the trigger, as the Physical Disks Temp +column does, and leaves it plain when disk temperature alerting is off. It no +longer uses a fixed 50/60C. Proof: +`TestContract_ReportsCarryDiskTemperatureAlertThresholds`. That same metrics-history contract also owns Kubernetes pod identity normalization. `/api/metrics-store/history` must accept legacy bare pod IDs such as `cluster-1:pod:pod-1`, canonicalize them onto the unified pod metrics @@ -7282,11 +7332,12 @@ transport or settings failures as stale-data state rather than throwing through the route: the page stays mounted, preserves any last-known Patrol evidence, and exposes a retry affordance while backend/API failures remain available to debug logging and API-level diagnostics. -The AI summary page now also renders the canonical -`frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` -card for policy posture, so sensitivity, routing, and redaction counts are -presented through one governed frontend component while the resource drawer -keeps only the per-resource policy lines. +No frontend surface currently renders the AI summary's `policy_posture` +counts; the shared posture card the Patrol page once mounted has been removed. +A surface that shows those counts again must present sensitivity, routing, +and redaction counts through one governed shared component fed by that +canonical payload, while the resource drawer keeps only the per-resource +policy lines. The unified action, lifecycle, and export audit reads now also clamp oversized `limit` requests to the governed maximum of `1000`, so the control-plane audit surface stays bounded even when callers ask for arbitrarily large history @@ -9604,6 +9655,39 @@ narrator must fail closed: nil provider, parse failure, timeout, or empty response causes the engine to fall back to the heuristic narrative without surfacing the AI failure to the caller, so reporting is never blocked by AI availability. +Report alerts keep the alert engine's resolution. The node, VM and +container enrichers in `internal/api/metrics_reporting_handlers.go` build +every row through `reportActiveAlertInfo` and `reportResolvedAlertInfo`, +which carry the alert's `ResourceID` and copy `models.AlertResolution` into +`reporting.AlertInfo.Resolution`, so a node alert that moved to its Pulse +agent inside the five-minute recently-resolved window is never reported as +a recovery (`TestContract_ReportingAlertsCarryHandoverResolution`). In +`pkg/reporting`, `AlertInfo.Recovered()` is true only for a close without a +resolution, and `unresolvedAlerts` treats an alert that closed without +recovering as still live unless the report also shows its successor's own +reading for that metric: an alert of the same type on the successor or one +of its children that is open, or recovered at or after the handover (an +earlier successor recovery does not count). That alert then carries the +condition, so it is counted once. Fleet reports match each handover against +every resource's alerts (`unresolvedAlertsAmong` over +`MultiReportData.fleetAlerts`). The single-resource PDF health card, Quick +Stats and active list and the heuristic narrator's verdict, observations and +recommendations read that rule: a lone handover renders WARNING (or CRITICAL +at its level) with `Alert moved to - check the agent for the current +reading`, and the alerts table shows `Moved` in neutral text with the +engine's summary as a note. Fleet reports count it as an active alert in the +aggregate, health card, table and resource blocks, and the fleet heuristic +recommends checking the agent it moved to. "Triggered and resolved" counts +recoveries only, and the report narrator's payload marks a covered handover +with `successor_alert_listed` so the model describes the condition once. +Reports requested as `agent`, which is how the Reports picker addresses every +Proxmox node and standalone agent in v6, carry no alert rows yet: +`enrichReportRequest` enriches node, VM and container reports only, so an +agent report's deterministic verdict ignores its machine's alerts (HEALTHY, +or NO DATA without metrics). The rule above reaches those reports once they +attach the machine's alerts. The fleet heuristic offers its all-clear +pattern and "No fleet-wide action required" only while no alert, moved +ones included, is active. Multi-resource fleet reports (`engine.GenerateMulti`) now also carry an optional fleet-level narrative through a distinct `pkg/reporting.FleetNarrator` interface, kept separate from the diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 34c5094087..693ed47bcc 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1168,9 +1168,8 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`. aliases such as `/settings/workloads/docker` or nested `/settings/infrastructure/platforms/*` paths. Each platform page must remain chrome-only: routing plus sub-tab navigation that embeds the - canonical `WorkloadsSurface`, `StorageSurface`, `RecoverySurface`, or - `UnifiedResourceTable` in `embedded tableOnly` mode with a forced - platform/source filter. The shell must not introduce dashboard cards, + canonical `WorkloadsSurface`, `StorageSurface`, or `RecoverySurface` in + `embedded tableOnly` mode with a forced platform/source filter. The shell must not introduce dashboard cards, bespoke per-family tables, synthetic placeholder data, or reintroduce Infrastructure as a primary navigation entry without a governed contract decision recorded by the frontend-primitives owner. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 6522d39af6..6d91671d13 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -17,6 +17,14 @@ ## Purpose +### Active-alert state projection carries the live metric status — issue #2068 + +`activeAlertsSnapshot` copies the alert manager's volatile `MetricStatus` +into `models.Alert.metricStatus`, so the websocket and `/api/state` alert +lists carry the reading being evaluated now for a threshold alert held below +its trigger, while `value` and `message` keep the last breach. The projection +adds no copy of its own: `GetActiveAlerts` already returns deep clones. + ### Fresh broadcast projection owns one resource/target capture Canonical live, fallback and standalone-host continuity broadcast reads prefer @@ -1043,6 +1051,44 @@ join another container's history. The real alert-manager callback path is covered by `TestDockerAlertTimelineUsesCanonicalHistoryIdentity` in `internal/monitoring/monitor_alert_handling_test.go`. +Proxmox node, guest and storage alert lifecycle events carry source-native IDs +and pass through the same writer, which resolves them to the canonical +resource. Resource facets, resource intelligence and Assistant resource context +therefore include their alert history, including events emitted after the +resource left inventory. Event IDs still hash the alert's source reference, so +replay stays idempotent. The real alert-manager callback path is covered by +`TestProxmoxAlertTimelineUsesCanonicalHistoryIdentity` in +`internal/monitoring/monitor_alert_handling_test.go`. + +Docker host and Swarm service alert lifecycle events, and sub-resource alert +events (ZFS pools and devices, host filesystems, disks, RAID arrays, sensors +and the Unraid array), pass through the same writer, which binds them to the +Docker host, Swarm service, storage or host named in the unified-resources +history identity clause. Container names and shortened container IDs still +bind nothing, and neither does the marked name a container or Swarm service +reported without an ID alerts under, so its events join neither its host's +history nor that of a service whose ID it is named like. A producer +that changes one of these reference shapes must change +`dockerHostHistoryReference` or `historySubResourceOwner` with it. +`TestOwnerAlertTimelinesUseCanonicalHistoryIdentity` in +`internal/monitoring/monitor_alert_handling_test.go` pins the Docker host, +Swarm service, ID-less container and service, ZFS pool and device, and host +filesystem shapes against the real alert manager. + +PVE disk health and wearout lifecycle events pass through the same writer but +never bind their path reference: each row goes to the physical disk its +recorded serial or WWN names, or stays under the reference, under the +unified-resources history identity clause. When a reboot reorders devices +before the registry catches up, a row that records a serial or WWN goes to the +disk the stale registry places at the path only if that disk reports the same +identity (the unified-resources clause lists where inventory merges or keys +disks differently); a row without either names only an identity-less disk at +its path. +`TestProxmoxDiskAlertTimelineFollowsRecordedHardware` in +`internal/monitoring/monitor_alert_handling_test.go` pins that path, and the +reference's incidents for the Alerts history Resource action, against the real +alert manager. + TrueNAS native alert projection preserves the trimmed, uppercase provider level in ResourceIncident.NativeSeverity. INFO and NOTICE retain the same canonical monitor risk; consumers must not lose their distinct actionability when projecting provider evidence. Native CRITICAL, ALERT, and EMERGENCY all project to canonical critical severity; EMERGENCY must not be discarded as unknown or make a still-active condition appear recovered. WARNING remains warning, and INFO and NOTICE remain informational at this projection boundary. Verification: `TestIncidentProjectionPreservesNativeSeverity` in `internal/truenas/provider_pool_health_contract_test.go` covers all seven native levels and case/whitespace normalization. `TestTrueNASNativeSeverityDispatch` in `internal/alerts/truenas_native_dispatch_test.go` verifies downstream INFO suppression, NOTICE preservation, notification severity, duplicate-poll retention, and confirmed recovery callback identity. The TrueNAS lifecycle tests in `internal/alerts/unified_incidents_test.go` require repeated EMERGENCY evidence to interrupt recovery confirmation. These are fixture-based projection and manager checks, not appliance ingestion or external notification-provider receipt proof. @@ -1462,6 +1508,31 @@ later coarse healthy value and lets explicit SMART endurance replace contradictory Proxmox wearout. Missing permission, ambiguous identity, standby, and absent SMART fields remain neutral rather than borrowing telemetry from another disk. +Retaining evidence a disk poll could not collect follows the same identity +rule. `previousPhysicalDiskEvidence` looks up the earlier record by serial, then +by WWN, each compared through `diskinventory.HardwareIdentityMatch` against +both fields of the earlier record. `HardwareIdentityMatch`, which the +registry's linked-disk join also uses, may additionally re-pad a WWN field +written in the host agent's unpadded NAA 5 `naa-oui-id` spelling +(`5-c50-a1b2c3d4`, from smartctl's WWN fields) to the 16-digit form udev and +Proxmox report; serials are never re-padded. The registry's merged disk view +keeps the agent's WWN spelling, so without it one disk seen by both reporters +would read as two. The Proxmox source ID and the device token +name a slot, not a disk, so a slot match is refused when both records carry a +serial or WWN and no value is shared between them. Missing identity is not +conflicting identity: Proxmox's literal `unknown` and other placeholders count +as unreported, and a record whose serial arrives empty keeps the slot's last +known serial, still marked `missing`. Before this rule, a replacement in the +same slot took the previous occupant's retained temperature, controller, pool +and I/O, and a replacement record with an empty serial also took the old +serial, which keyed it to the old disk's canonical resource and SMART metric +series, so every later poll carried the borrowed serial forward +(`TestPhysicalDiskReplacementInSameSlotDoesNotInheritPreviousSerial`, +`TestPreviousPhysicalDiskEvidenceRejectsSlotMatchAcrossConflictingIdentity`). +This lookup is the only place the rule applies: a replacement that reports no +usable serial or WWN cannot be told apart from the same disk and still +inherits, and the linked agent's SMART merge, which runs first, can still fill +or promote a serial from a stale SMART row it matched by device path. Negative percentage-used counters remain unknown; values above 100 clamp to exhausted before deriving remaining life, so invalid or over-limit controller data cannot wrap into a fabricated healthy value. @@ -1648,6 +1719,62 @@ boundaries: `TestMockKubernetesLoneHeavyPodStaysUnderNodeCeiling` in `internal/mock/generator_test.go`. +Mock alert evaluation covers physical disks as well. Live Proxmox disks are +evaluated by the physical disk poller in `maybePollPhysicalDisksAsync`, which +only polls configured Proxmox instances, so fixture disks had no evaluation +path. The fixture deliberately keeps a FAILED health cohort and worn SSDs +stable across restarts, so the estate showed failing and "Replace Now" disks +with no `disk-health` or `disk-wearout` alert. `checkMockAlerts` now evaluates +fixture disks through `checkMockPhysicalDiskAlerts`. Both paths share the +per-disk rule in `checkPhysicalDiskAlerts` (a device matched by the linked +agent's `--disk-exclude` patterns is evaluated as healthy, otherwise +`CheckDiskHealth` judges the disk), and the mock pass applies the poller's +boundary around it: + +- Only disks on online nodes are evaluated, as the poller only evaluates nodes + it reached. A disk on an offline node keeps whatever alert it had. The mock + pass treats every disk on an online fixture node as collected; the poller + also skips a node whose disk query and SMART fallback both failed. Nodes are + matched by instance and name, as the poller only pairs a disk with its own + instance's nodes. +- Excluding a disk resolves the alerts it already raised, because the excluded + device is evaluated as healthy rather than skipped. +- The poller's wait for host-agent links to settle after a restart (#1674) is + not applied: fixture links and exclusions are complete from the first pass. +- A disk whose node name leaves the estate loses its alerts through + `CleanupAlertsForNodes`, which the same pass already runs. That cleanup + matches node names only, so it relies on fixture node names being unique + across instances, which they are. + +The disk loop relies on the pass's single up-front mode check, like every +loop in `checkMockAlerts` except the host-agent step, which re-checks under its +own lock. A pass already past that check when mock mode is switched off can +reopen its fixture alerts after `SetMockMode(false)` clears them, and +other paths that evaluate fixture data (frontend refresh into unified alert +evaluation, backup rollups) have the same window. That mode-exit race predates +the disk loop and is not closed here; a lock around `checkMockAlerts` alone +would leave the other paths open. + +The disk evaluation runs on the mock alert tick, not the poller's disk +interval (five minutes by default, `PhysicalDiskPollingMinutes` per instance). +`CheckDiskHealth` logs at error level whenever it evaluates a FAILED disk +(models with a known firmware bug skip the health check) and at warn level for +a worn SSD, so an evaluated fixture disk in either state logs on every tick. On +2026-10-06, private stacks with the public demo's estate (8 nodes) carried 12 +Proxmox disks, none FAILED. Baseline against patched, the only new disk alert +was one warning-level `disk-wearout` (a 7% SSD on `pve5`), whose resource ID is +one of the disk resource's canonical aliases, and neither stack had a +`disk-health` alert. `CheckDiskHealth` raises only those two alert types, so +the other differences between the two boots (ZFS and Docker service alerts) are +not from the disk pass; those fixtures draw random state each boot. The default +test estate raises five `disk-health` and one `disk-wearout` alert. The tests +pin the boundary: `TestCheckMockAlertsEvaluatesPhysicalDisks` and +`TestCheckMockPhysicalDiskAlertsFollowsPollerBoundary` in +`internal/monitoring/monitor_mock_alerts_test.go`, with +`TestProxmoxDiskAlertsRunOnMergedDiskState` in +`internal/monitoring/canonical_guardrails_test.go` keeping the poller on the +shared helper. + Host and container-runtime disk collection supports an explicit include list for filesystems hidden by Pulse's automatic virtual/container filtering. The include list is bounded to that automatic filter; explicit disk exclusions @@ -3772,12 +3899,36 @@ state supersede the availability it supplied earlier (`diskinventory.MergeReportedStatus`), so neither a skipped disk poll nor a host that is never disk-polled again can carry it forward. Expiry is compare-and-set on the report time the evaluation judged stale, so a report -accepted in between is never expired. Every SMART temperature history writer +accepted in between is never expired. +A legacy agent (from before collection provenance) withdraws its readings +without a source, so `hostAgentSMARTCollection` stamps its reading copied onto +the Proxmox disk through `diskinventory.LegacyHostAgentStatus`, the same rule +the unified-resources registry applies to match that withdrawal to the copy: a +silent legacy agent's retained temperature is not collected on the canonical +disk either, even when no disk poll refreshes the copy. Proof: +`TestSilentLegacyAgentCanonicalDiskFollowsItsWithdrawal` in +`internal/monitoring/physical_disk_roundtrip_test.go`. +Every SMART temperature history writer records a temperature only when its current collection state is available (or predates collection state), so a retained reading has to keep its non-available state to stay out of history; a path that relabels a carried reading as available, such as a node-temperature carry stamped `proxmox_node_smart`, is not covered by this rule. +`diskinventory.TemperatureCollected` is the shared form of that test for +consumers outside the history writers: the metrics-history API serves a disk +`smart_temp` live point, and mock mode derives a synthetic disk temperature +series from the disk's reading or pads a stored one, only from a collected +temperature, so a retained standby or silent-agent reading never reappears as a +point at the current time. +The SMART history writers in `internal/monitoring/monitor.go` and +`monitor_agents.go` call that helper rather than a private copy of the rule, +and `GetPhysicalDiskTemperatureCharts` pads a short disk temperature series out +to now only with a collected reading: with a retained one the stored samples +stay as they are, and a disk with none gets no series. Mock mode can still +substitute its generic demo series for that disk ID before this step; that +series is not derived from the disk's reading. Proof: +`TestDiskTemperatureChartsPadOnlyWithCollectedReading` in +`internal/monitoring/monitor_metrics_slo_test.go`. Unified-resource physical-disk round trips must retain named `StorageGroup` membership rather than degrading it to the generic `Used` filesystem label. @@ -3789,6 +3940,46 @@ exists or the agent payload has no usable positive reading. Identity-only or zero-temperature SMART rows do not count as usable by themselves, but the runtime must not keep probing legacy SSH solely to augment an otherwise healthy agent temperature payload with SMART data. +A host agent's sensors feed the node the agent is linked to. Several Proxmox +connections can each have a node of the same name (one `px1` per site), and +`Host.LinkedNodeID` always holds the Proxmox source node ID, whether the link is +automatic, manual or restored from host continuity. The poller therefore hands +`getHostAgentTemperatureForNode` in `internal/monitoring/host_agent_temps.go` the +polled node itself, and the lookup works on that node's slot. A node the read +state holds under its own source ID is its own slot. The state folds the views +of one machine reached through two connections (a cluster added twice, or a +multi-homed host added by each address) into one node kept under one view's ID, +chosen by its merge preference, and an automatic agent link follows that node. +So a polled view the read state does not hold also takes the one same-named +node proven to be the same machine, by `models.NodeObservationsSameMachine` or, +for the agent linked to that node, `models.HostAgentBridgesNodeViews` (the agent +reports both endpoints and the views' TLS fingerprints do not contradict), both +in `internal/models/node_machine_identity.go`. These are pairwise forms of +evidence `UpdateNodesForInstance` folds on, and it shares the agent +corroboration rule with them; +`TestNodeObservationsSameMachineMatchesStateFold` and +`TestHostAgentBridgesNodeViewsMatchesStateFold` pin them against the state's +fold for the covered cases. Two or more such candidates are ambiguous and none +is taken, as the state declines an ambiguous alias. An agent linked to a slot ID +is the node's agent. A manual link stays pinned to the source ID the operator +chose, because the state never transfers operator intent to a replacement +provider identity, so while the state keeps that machine under the other view's +ID the node has no linked agent and no agent reading. Otherwise the hostname +fallback takes only an agent with no node, VM or container link whose hostname +is exactly the node name, and only when no Proxmox node outside the slot and no +second unlinked agent has that name. Cluster-sibling readings an agent collects over SSH arrive keyed by bare +node name, so the cluster sensor cache records the reporting agent. A reading +serves the node only when the node that agent is currently linked to belongs to +one of the slot's connections, or to a connection whose configured view of the +node carries a matching TLS fingerprint (the other connection of a cluster added +twice). The reading follows the agent's link the way the agent's own sensors do, +and a reading from an unlinked agent serves no node. Without these bounds, site +B's `px1` showed, alerted on and recorded history for site A's agent or +cluster-sibling sensors. `TestNodeTemperatureStaysWithinSameNamedNodesSite` in +`internal/monitoring/monitor_additional_test.go` pins the poller lookup, +including the folded-view cases, and +`TestApplyHostReportScopesClusterSensorsToReportersConnection` in +`internal/monitoring/monitor_host_agents_test.go` pins the agent ingest side. A node temperature presented as available must describe the node now, because node alert evaluation, node history writes, reporting, and the UI all read it as a live measurement. A host agent linked to a Proxmox node merges into one host @@ -3805,7 +3996,52 @@ cluster sensor cache, which keeps its own recency check. When every source returns nothing, `internal/monitoring/monitor_polling_node_helpers.go` may carry a previous reading, with its original `LastUpdate`, only inside the carry window (twice the PVE polling interval, never under five minutes); an older reading is -dropped rather than re-presented as current. +dropped rather than re-presented as current. A carried reading also keeps a +lapsed host agent's lease. Agent readings are stamped with the agent's report +time, so `carriedTemperatureOutlivesAgentLease` drops a carried reading stamped +no later than the last report of the node's matched agent (by link, then +hostname, whether or not that report had sensors) once that agent has lapsed. +Readings record no source, so the bound is conservative: an SSH, cluster-cache +or agent-plus-SSH merged reading (stamped with the agent's time) of that age is +dropped too, being already older than the lease, while a reading stamped after +that report keeps the carry window. A carried reading (`carriedNodeTemperature`) +keeps only the node's CPU and GPU temperatures: its SMART and NVMe rows would +also stamp the node's physical disks, whose freshness and standby state a +carried copy cannot follow (a disk that spun down would be stamped and recorded +with its pre-sleep value), so they are left out, and nothing is carried when +only they remain. +The poller reads its previous poll's nodes back from the unified read state +through `internal/monitoring/monitor_previous_state.go`. `nodeLastOnline` and +the temperature carry with its CPU low/record tracking match them by node ID, +which is the Proxmox source ID, so the projection must return +`NodeView.SourceID()`, never the unified resource ID, and carry the stored +`TemperatureDetails`. Nodes preserved through an instance outage +(`preserveOrExpireNodes`) or authoritative membership reconciliation are written +back into state from that projection, so it also restores the identity inputs +the registry derives a node's canonical ID and cross-view merges from: cluster +name, provider-scoped flag, native aliases, TLS fingerprint and linked agent ID, +the config-derived ones stamped the way `pollPVENode` stamps them. With the +unified ID a node re-keys on every failed poll, and without its cluster +identity a cluster member changes canonical ID for the length of an outage. +A preserved node keeps its +temperature only under the same carry rules and loses it when it goes +offline. Node memory carry-over after a failed status read uses the poller's own +validated `NodeMemorySnapshot`, not the read state, which holds the agent's +reading on a node merged with a host agent, and keeps used, cache and free +summing to the total. +The poller's node verdicts reach the read state as its own. A cluster member +`pollPVENode` reports offline on a live poll stays `offline` in +`NodesSnapshot`, `/api/resources` and every read-state consumer when its linked +host agent falls silent. A node `preserveOrExpireNodes` expires stays `offline` +once its Proxmox sighting goes stale, unless another source says otherwise: a +reporting agent or a current availability check keeps it `online`, and a quiet +agent whose last verdict was not offline makes it `warning`. The registry's +stale pass used to turn the first into `online` and the second into `warning`, +because it read a sighting that had delivered recently as online +(unified-resources, "Status follows each source's own verdict"). A node inside +the offline grace keeps its `online` verdict and reads `warning` once its +sighting goes stale. Node alerts were never affected, because `CheckNode` reads +the poller's own `models.Node`. Legacy SSH temperature collection must also use the Pulse sensor-wrapper contract before falling back to raw lm-sensors output. `internal/monitoring/temperature.go` must request `/usr/local/sbin/pulse-sensors` when it exists, parse the wrapper @@ -4327,6 +4563,18 @@ emit structured storage topology such as Unraid per-disk state, the shared assessment layer must derive canonical risk and alert severity from that richer disk topology instead of letting coarser aggregate counters override it and flap the operator-facing storage alert surface. +That shared assessment never judges disk temperature. Heat is a metric owned +by the alert disk temperature policy (`alerts.Manager.DiskTemperatureThreshold`: +the per-type `diskTempByType` trigger, else the agent Disk Temp default), which +users tune per disk type. Disk risk is rebuilt by registries that cannot see +that configuration, so a heat rule here would contradict it: a flat 60/70C rule +once called a healthy 63C NVMe hot and a 56C SATA disk fine. No disk type, +temperature or collection state reaches `storagehealth.Sample`, the TrueNAS +provider and the Unraid inventory assessment included, and a hot disk keeps an +`online` status. Proof: `TestDiskAssessmentsNeverJudgeTemperature` in +`internal/storagehealth/risk_test.go` and +`TestRecordsLeaveDiskHeatToTheAlertPolicy` in +`internal/truenas/provider_test.go`. That same monitoring-owned storage polling boundary also owns cluster-shared Proxmox storage status coherence. `internal/monitoring/monitor_polling_storage.go` must merge shared storage observations across nodes into one cluster-scoped diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 9e9e38cc64..4756dc70fd 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -1361,8 +1361,21 @@ remove settled items and append genuinely new work. Acknowledging an occurrence is presented as `Mark reviewed`: on success the item leaves the active inbox, Patrol announces the remaining decision count, and advances to the next current item. Temporary suppression uses the same advancement loop and explicitly says -that the occurrence will return at the chosen time. Clearing the final item -returns to the calm inbox with an honest completion announcement. +when it ends and which state it hands the occurrence back to. Clearing the +final item returns to the calm inbox with an honest completion announcement. +Ending a suppression, early or at its expiry, puts the alert in Acknowledged +when it is acknowledged and in Open otherwise (`unsuppressOperationalAlert`), +and Acknowledged is not active attention. So for an issue marked reviewed +(`operationalRecord.acknowledgement` present) the suppression form's duration +reads `End suppression after` and the early-end control reads `End +suppression`; an unreviewed issue keeps `Return it to active attention after` +and `Return to active attention`. The handled-view notice after ending it reads +the state the post-change reload reports for the item, `Suppression ended. It +stays reviewed.` for Acknowledged and `Returned to decision inbox` otherwise, +so a detail read before the review changed elsewhere cannot misstate the +outcome; it falls back to the detail's acknowledgement only when the item is +gone from the reload. `getAttentionSuppressionEndPresentation` owns that copy +and `PatrolAttentionWorkbench.test.tsx` pins all three cases. The attention queue orders genuine operator decisions by severity, actionable approval/review state, and latest observation time. Collapsed rows expose the @@ -1388,6 +1401,12 @@ condition went, and that it is not a recovery) sits directly under the summary rather than only inside Evidence and history (`getAttentionHandoverClose` in `patrolHomePresentation.ts`). The timeline names that cause `Ownership transferred`, never `Recovery evidence`. +The assessment handoff tells the same close the same way: a recent +`alert_resolved` change carrying `alert_resolution` metadata enters the +handoff context and briefing evidence as the alert engine's summary through +the shared `formatResourceChangeHeadline`, never as `Alert resolved: ...` +(`patrolInvestigationContextModel.ts`, pinned by +`patrolInvestigationContextModel.test.ts`). The active Patrol queue now uses compact severity-accented rows for prioritisation and a single focused review panel for the selected issue. The @@ -2104,10 +2123,10 @@ That same summary card now keeps recent changes and learned correlations primary while leaving the broader learning counters as backend coverage, so the page does not present telemetry-style counts as a headline intelligence story. -That Patrol summary card now also includes the canonical data-governance -posture snapshot from the shared AI summary payload, so the visible page can -show the same sensitivity, routing, and redaction distribution that the -runtime derives from unified resources. +The shared AI summary payload also carries the canonical data-governance +posture snapshot that the runtime derives from unified resources, but the +Patrol page does not render its sensitivity, routing, and redaction +distribution; the posture rule below governs any return of those counts. The resource drawer now carries canonical dependency and dependent correlation context plus canonical correlation evidence through the resource-intelligence payload, so the resource-level AI card can surface @@ -2179,13 +2198,14 @@ cards now own the canonical Infrastructure resource-link default, so the Patrol page and resource drawer inherit resource-filter href construction through the shared summary cards instead of rebuilding local wrappers in each surface. -The Patrol intelligence page now also renders the canonical -`frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` -card, so the data-governance posture counts stay rendered from one governed -frontend component on the page instead of being duplicated in the resource -drawer. +The Patrol intelligence page renders no data-governance posture counts: they +left the page with its supporting-context block, and the shared posture card +that rendered them has been removed. If Patrol shows posture counts again, it +must render them through one shared unified-resource component instead of a +Patrol-local count loop, and the resource drawer keeps only per-resource +policy lines. That same Patrol summary surface now keeps health and findings primary while -making recent changes, learned correlations, and policy posture available only +making recent changes and learned correlations available only through the on-demand `Details` context inspector, so expansion lane concepts stay available for deeper investigation without reading as the headline Patrol product story. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index a26b7e22af..5d26056b6e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -17,6 +17,13 @@ Drawer observation labels derive locally from the selected snapshot without renewing timestamps, adding reads, remounting charts or changing stored metric coordinates. +### Guest drawer alert copy formats backend status only + +Guest drawer attention rows format `alert.metricStatus` through +`getGuestDrawerAlertAttention` and the shared `getAlertAttentionCopy`. No +threshold comparison, recovery timing or resource-metric lookup runs in the +drawer, and the host-capacity memory comparison stays a suffix on that copy. + ### Drawer provenance stays local to snapshot presentation Guest and canonical resource drawer memory presentation share the same pure @@ -159,6 +166,13 @@ clearing, false/zero values, unchanged peer-site rows and full/fast equivalence. merges, row, Overview and History with synthetic inputs. This is no native QGA/thaw, installed recovery, fleet-performance or release-availability claim. +Choosing a node's temperature source in `resourceStateAdapters.ts` reads one +more field of the facet already in the canonical payload +(`proxmox.temperatureDetails`), only when the existing record selection yields +no usable reading, and keeps it only when it holds a CPU reading. That is one +extra record mapping per such node per adapter call. It adds no resource read, +merge pass, timer or subscription. + ### Unavailable filesystem values share the existing disk model The filesystem list and guest Overview use the same nullable measured-byte ratio @@ -474,16 +488,6 @@ in-place click and chevron activation over a selection. 64. `frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts` 65. `frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts` 66. `frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts` -67. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` -68. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` -69. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` -70. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` -71. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` -72. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` -73. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` -74. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` -75. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` -76. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` 77. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` 78. `frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx` 79. `frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx` @@ -504,7 +508,6 @@ in-place click and chevron activation over a selection. 94. `frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.test.ts` 95. `frontend-modern/src/components/Workloads/GuestDrawer.test.tsx` 96. `frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts` -97. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx` 98. `frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts` 99. `frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx` 100. `frontend-modern/src/utils/workloadsSummaryCache.ts` @@ -546,18 +549,8 @@ at desktop and phone widths; this is presentation proof, not native collection or a measured fleet performance improvement. -1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -4. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `unified-resources`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -5. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `unified-resources`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -6. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `unified-resources`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -7. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `unified-resources`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -8. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `unified-resources`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -9. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` shared with `unified-resources`: unified resource table state derivation, sort-cycle policy, service sorting, and responsive column layout are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 10. `frontend-modern/src/components/Infrastructure/useTableWindowing.ts` shared with `frontend-primitives`: the shared bounded table-window controller is both a canonical frontend rendering primitive and a fleet-scale scrolling hot-path boundary. -10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `unified-resources`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 12. `frontend-modern/src/features/platformPage/PlatformWindowedList.tsx` shared with `frontend-primitives`: the shared bounded list renderer is both a canonical platform-page primitive and a fleet-scale mounted-DOM performance boundary. 13. `frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx` shared with `frontend-primitives`: the shared bounded table-row renderer is both a canonical platform-page primitive and a fleet-scale mounted-DOM performance boundary. 14. `frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts` shared with `frontend-primitives`: the platform windowing controller is both a canonical frontend scroll primitive and a directional-runway performance hot path. @@ -678,7 +671,18 @@ the store with reference-stable untouched items so the per-tick deep walk of the whole projection is gone. Active alerts travel the same keyed transport (`activeAlertsDelta`, replacing the ~37KB whole-array re-ship whenever any alert changed) while their application stays immediate per the alerts -subsystem boundary. These projection baselines are socket-owned: an oversized +subsystem boundary. Each committed alert is reconciled in place against its +copy in the keyed active-alert store, so a reader of an unchanged alert there, +or of an unchanged nested value such as its live `metricStatus` or `metadata`, +is not notified although every commit carries freshly parsed objects; omitted +fields are deleted rather than shallow-merged. The `state.activeAlerts` array +is still replaced on every alert commit. A pending acknowledgement hold that +gives up after 15 seconds re-syncs from `/api/alerts/active` (reusing a +recovery already in flight, and fetching again if that one applies nothing) +rather than waiting for an alert delta a quiet estate may never send. A local +acknowledgement write that overlaps a recovery discards its response and +fetches once more. These projection baselines are +socket-owned: an oversized frame invalidates resources, connected infrastructure, and active alerts together, while REST recovery hydrates display state only. A later keyed delta without a socket baseline requests the throttled recovery path instead of @@ -1060,21 +1064,16 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. `/auth/trial-activate` and `POST /api/license/trial/start` from public-path and CSRF inventories must stay as constant-time route-table absence rather than replacing the old callback with persistence-backed router probes. - Infrastructure host-table health explanations follow the same hot-path - rule. `UnifiedResourceHostTableCard.tsx` may render compact issue labels - from the already-materialized resource incident and storage-risk fields, but - it must not introduce per-row API reads, broad resource scans, storage - topology recomputation, or layout-measuring work just to explain warning or - degraded rows. Infrastructure table source derivation follows the same - bounded-work rule: `unifiedResourceTableStateModel.ts` must read the - already-materialized top-level `resource.sources` array before legacy - `platformData.sources` hints, and must not reconstruct platform identity by - scanning sibling resources or making row-time API calls. 5. Extend workload hot-path filter, sort, grouping, and stats math through `frontend-modern/src/components/Workloads/workloadSelectors.ts`, and extend workload identity, discovery routing, and node-topology helpers through `frontend-modern/src/components/Workloads/workloadTopology.ts`, rather than duplicating selector or topology logic in `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx` Platform-owned Workloads surfaces may force a platform scope, but that 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. + Net I/O and Disk I/O outlier statistics (median, MAD, p97, p99) are built + from guest throughput by `computeWorkloadIOEmphasis(...)` in + `workloadSelectors.ts`, which produces the stats that + `getOutlierEmphasis(...)` in `guestRowModel.tsx` reads; that math must not + move back behind a `Resource`-shaped infrastructure selector cast. Workloads source-health messaging must derive from the viewer-safe `/api/runtime/inventory-sources` projection through `frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`. @@ -1204,6 +1203,11 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. Saves run one metadata write per changed link, then dispatch the metadata-changed events together so whole-snapshot listeners coalesce onto one in-flight refetch instead of refetching per link. + The drawer overview lazy-loads `GuestPhysicalDisks.tsx` under a local + `Suspense`, so the physical-disk presentation module + (`diskPresentation.ts`) loads with an open agent guest's drawer instead + of joining the WorkloadsSurface chunk. + `WorkloadsSurface.performance.contract.test.tsx` pins the dynamic import. Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`. A current metric may remain visible in a chart legend only when labelled `current`; it must never be expanded into synthetic timestamps or a flat @@ -1749,22 +1753,16 @@ without contention or cross-delivery. 2. Update this contract when a new protected hot path is adopted 3. Route runtime changes through the explicit performance proof policies in `registry.json`; default fallback proof routing is not allowed 4. Record the evidence source for any claimed performance improvement -5. Keep wide-desktop infrastructure table layout proof on the shared owner. - Changes to `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` - that affect host, PBS, or PMG desktop column widths must ship with shared - model verification plus desktop Playwright proof that full-width shells - distribute surplus width across peer columns instead of stretching only the - `Resource` column. -6. Keep entitlement and runtime capability lookups on shared router/settings +5. Keep entitlement and runtime capability lookups on shared router/settings hot paths bounded and request-local. AI control-level clamping in `internal/api/router.go` may consult the already-wired runtime entitlement service, but it must not add broad persistence scans, metrics fan-out, or external network calls to protected settings or chat request paths. -7. Initialize and cache each organization RBAC manager once per router +6. Initialize and cache each organization RBAC manager once per router lifecycle. Request handlers may reuse the cached manager but must not reopen or remigrate SQLite on each role or assignment request, and shutdown must close the owned manager set. -8. Guest Overview filesystem provenance is local presentation over the already +7. Guest Overview filesystem provenance is local presentation over the already supplied snapshot: no additional query, timer, poll, History fetch or guest command may be added to establish it. Its existing linear filesystem mapping may label retained values and omit current utilization bars; it must preserve @@ -1823,8 +1821,8 @@ definitions with four concurrent command or REST executions. Group/subgroup ordering and numeric, boolean, or timestamp rendering remain one bounded sort-and-map over that collection and stay outside table-row and virtualized viewport hot paths. -`UnifiedResourceTable.performance.contract.test.tsx` pins the resource-local -projection and no-fetch boundary. +`resourceDetailMappers.test.ts` pins the resource-local projection and no-fetch +boundary. ### Workload refreshes retain one coherent paged generation @@ -1948,9 +1946,17 @@ internal persistent signal so the toggle and the hosts-table render stay in lockstep. The history range remains inline in both display modes because the default bars mode uses it for the intent-driven guest-row history lens. Embedded sibling tables that opt into sparklines -re-instantiate `useWorkloadTableMetricHistory`; the cache key matches -the workloads-table reader so both readers dedupe their fetches and -the canonical Workloads hot-path budget is preserved. Standalone +re-instantiate `useWorkloadTableMetricHistory` and name the rows they draw +through its required `series` option. The Proxmox nodes table passes +`'nodes'` and polls only the infrastructure summary; the WorkloadsSurface +reader passes `'guests'` and polls only the workloads history. Two readers +on one page do not share reads: each forwards its own abort signal, which +bypasses the summary caches' in-flight dedupe, and retained query values +seed matching mounts and source changes without deduplicating requests. A +reader therefore must not poll a summary its rows do not render. In Trends +the vSphere overview polls only the workloads history, and the Proxmox +overview polls the workloads history and the infrastructure summary once +each. Standalone WorkloadsSurface callers (no override props) keep the original persistent-signal-backed behavior. Every toolbar that controls a `WorkloadsSurface` consumes @@ -1959,6 +1965,15 @@ mode, row-hover mode, history range, and first-use hint on the same state owner for the generic surface and the Proxmox and vSphere platform compositions; provider pages must not recreate a partial metric prop list that can enable prefetching without exposing the matching control or discovery state. +That first-use hint clears while the pointer is over a guest row, because the +first populated preview marks it seen. Clearing it must not unmount it or +change the toolbar height: wherever the hint wraps the action row onto its own +line, removing it moved the table up under the pointer and the history lens +retargeted the next guest. `WorkloadsFilter.tsx` therefore keeps the cleared +hint mounted but `invisible` wherever the desktop History controls render, +for as long as row hover stays in bars + history mode. Leaving that mode or +remounting the filter releases the box. `WorkloadsFilter.test.tsx` pins the +retained node and its release on a switch to Trends. The alert-bridge patrol-trigger callback wired in `internal/api/router.go` now short-circuits before queuing a scoped patrol when a firing alert does not meet @@ -1976,22 +1991,20 @@ query persistence once per resource, or run on the steady-state alert hot path; ancestor lookup remains bounded by canonical hierarchy depth for each active alert actually evaluated. -The embedded WorkloadsSurface exposes a `compactGroupHeaders` prop on -`frontend-modern/src/components/Workloads/useWorkloadsState.ts` that -platform pages owning their own hosts table (Proxmox overview today) set -to strip per-host metric cells out of the `NodeGroupHeader` rows in -grouped mode. The flag is threaded through -`frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`, -`frontend-modern/src/components/Workloads/WorkloadsTable.tsx`, and -`frontend-modern/src/components/Workloads/WorkloadPanel.tsx`; when set, -`WorkloadPanel` calls `NodeGroupHeader` without `columns` / -`renderColumnCell` so the existing colspan layout renders just the -status dot, linked node name, cluster badge, and agent badge — the -duplicate CPU / Memory / Disk / uptime / temperature / version stats -the top-of-page hosts table already owns are dropped. New embedded -platform-page consumers that render their own hosts summary must set -this flag; standalone Workloads surfaces (no top hosts table) keep the -original verbose group rows by default. +Grouped host rows in the embedded WorkloadsSurface are identity-only +dividers. `frontend-modern/src/components/Workloads/WorkloadPanel.tsx` +renders each grouped node through `NodeGroupHeader` as one cell spanning +the table: status dot, linked node name, cluster badge, and agent badge. +Per-host CPU, memory, disk, network and disk I/O, uptime, temperature, +and version stats belong to the platform page's own hosts table (the +Proxmox nodes table today), so the workloads table must not render them +per column or inline in the group row. The former `compactGroupHeaders` +option and the per-column node metric cells it switched off were removed +because both remaining production mounts (Proxmox and vSphere overview) +set it unconditionally, so the non-compact path no longer rendered +anywhere. A new embedded consumer that needs host +stats beside its workloads must render its own hosts table rather than +re-growing metric cells in the shared group row. WorkloadsSurface stays monitoring-first. It must not render a persistent aggregate banner just because running VMs or system containers lack an @@ -2005,8 +2018,10 @@ Trend-mode and row-history-lens cells own their vertical scale through `getMetricMiniSparklineScale` in `frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts`. Percent series are zero-floored and scaled to their own observed peak with a -5% floor ceiling and a 100% cap, the same shape rule the network and disk I/O -series already follow. A fixed 0-100 window is not the contract, because inside +5% floor ceiling and a 100% cap. Network and disk I/O series follow the same +shape rule with a 100 KiB/s floor ceiling, so an idle guest's background +chatter stays near the axis instead of drawing the full-height spike a busy +guest draws. A fixed 0-100 window is not the contract, because inside a 16px table cell it draws low-domain series such as a guest's share of host memory or an idle guest's CPU on top of the axis rule, where a flat line is indistinguishable from missing history. Absolute level stays readable through @@ -2015,20 +2030,29 @@ work must not remove those. The scale is derived from the points already fetched for that cell and must not add history reads or a second pass over the Workloads hot path. Pointer scrubbing may synchronize one normalized cursor position across the active guest row, but it must remain row-local and operate -only on the already-fetched series. +only on the already-fetched series. `GuestRow` builds each chart from accessors, +so a live snapshot updates the mounted lens or Trends chart in place instead of +remounting it, and an open scrub tooltip re-reads the current series at its +pointer position rather than holding the values from the last mouse move. The Workloads table metric display mode is part of the protected Workloads hot path. Default bar mode keeps compact current-value bars at rest and swaps only the active fine-pointer or keyboard-focused guest row to the original sparklines; persistent Trends mode keeps sparklines visible for every rendered -row. Bar mode does not hydrate estate history at rest: the selected range is -local state until a row is active, then one centralized query reads only that -guest's canonical metrics target from `/api/metrics-store/history`, capped at -36 points. Equivalent live guest snapshots must not restart that request, and -leaving the row disables it; per-row query owners, fan-out, and polling remain -forbidden. Persistent Trends retains the shared, cache-keyed -`fetchWorkloadsSummaryAndCache` / `fetchInfrastructureSummaryAndCache` reader -for all rendered rows. Range-sensitive readers clear prior-range data when no +row. Bar mode does not hydrate estate history at rest. With the history lens +on, one centralized queue warms a bounded window instead: up to the first six +guests of the mounted table window at rest, then the active guest first plus +its next four and previous one, with at most four reads in flight and 18 cached rows. Each read +targets one guest's canonical metrics target on `/api/metrics-store/history`, +is capped at 36 points, and is never polled. Equivalent live guest snapshots +must not restart a read. A range change or leaving the lens mode cancels +pending and in-flight reads, while leaving a row lets its read settle into the +bounded cache. Per-row query owners, estate-wide fan-out, and polling remain +forbidden. Persistent Trends retains one cache-keyed +`fetchWorkloadsSummaryAndCache` reader for all rendered guest rows. Grouped +host rows render no metric cells, so the WorkloadsSurface reader never polls +`fetchInfrastructureSummaryAndCache`; node series belong to the page's hosts +table reader. Range-sensitive readers clear prior-range data when no exact cache entry exists and forward cancellation so superseded range work does not continue occupying browser connections. Sparkline ranges must stay bounded to the governed compact table windows. Expanded history belongs in the existing guest drawer chart @@ -2067,6 +2091,18 @@ render those sensor values already present on the selected resource payload, but it must not add host powercap reads, sensor-specific history reads, per-row polling, browser-side command assumptions, or table-wide aggregation work. +Host-agent SMART disk rows in `resourceDetailMappers.ts` mark a retained +temperature "(last known)", with its collection reason as the title, from the +payload's own `collection.temperature` state, through `getPhysicalDiskTemperaturePresentation` +in `frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts`. +That module stays separate from `diskPresentation.ts` so the drawer and +Machines chunks take the decision without the Storage presenter, and the rows +add no reads, polling or aggregation work. +A silent host agent's other Thermals rows (pressure, limits, GPU, temperatures, +additional sensors, fans, power) take the same "(last known)" suffix and reason +title. `buildTemperatureRows` gets them through its `lastKnownReason` option, +which the drawer sets from the resource's own `agent.stale`, so the drawer +still reads nothing extra. The Proxmox node drawer overview should follow the existing guest drawer compact detail-section pattern and expose node-specific context such as platform, kernel, hardware, raw capacity, telemetry, and thermal facts rather than @@ -2717,15 +2753,6 @@ re-accumulating discovery heuristics back into the performance hot-path mapper. That mapper may add simple host-agent thermals rows from already-loaded sensor metadata, including `thermalState`, but it must not perform additional resource lookups, history reads, or polling work to decorate the drawer. -The PBS service-table hot path now follows that same split: raw job arrays may -cross the transport boundary, but -`frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` -must collapse them into one shared `Activity` presentation via the canonical -service-model helpers, while -`frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` -stays a thin render shell. Future running-task visibility changes must extend -through the shared model and accepted performance proof file instead of adding -per-row PBS status parsing or inline job scans in the table render path. The Workloads disk list now follows the same pattern: the shell stays in `frontend-modern/src/components/Workloads/DiskList.tsx`, while disk-row presentation derivations and fallback tooltip/runtime wiring live in @@ -2889,48 +2916,11 @@ precedence, reinterpret ratios above an arbitrary threshold, or replace an established provider history series; source authority and canonical identity convergence belong upstream in the unified-resource and monitoring contracts. -The unified resource table hot path is now also governed as explicit -performance-owned runtime, with shared ownership against the unified-resource -consumer boundary. The remaining performance work is no longer top-level -ownership ambiguity on the main Infrastructure or Workloads tables. -The table's reactive runtime, grouping, and row-windowing owner now lives in -`frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`, -while pure table-state derivation, service sorting, sort-cycle policy, and -responsive column layout now live in -`frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`, -and viewport-sync plus selected-row reveal behavior now live in -`frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`, -so future hot-path table-state changes must not fold selector derivation, -layout policy, and scroll coordination back into one mixed owner or the render -shell. -The same shared layout policy now treats the phone branch as a zero-overflow -fixed-width table rather than preserving a 640-pixel intrinsic floor. The -shared state model must select one identity track fixed at 30 percent plus five to seven -source-relevant health or activity tracks, normalize those widths to the -available container, and keep the document and table shell at scroll-width -parity. That denser phone projection remains pure model/class work: it must not -add row-time measurement, duplicate tables, viewport listeners, or per-row -responsive signals to the infrastructure hot path. -That same hot-path boundary now also owns CSP-safe table sizing. Infrastructure -host, PBS, and PMG table shells must take their layout and column sizing from -the shared presentation owner in -`frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` -and apply those values through classes and width/height attributes, not inline -`style=` maps on the live table DOM. Future hot-path table work must not -reintroduce inline width, min/max width, or row-height styles into the render -shell just to land a local layout tweak. -That same shared sizing contract now also owns wide-desktop width -distribution. In full-width shells, host, PBS, and PMG tables must keep an -explicit desktop `Resource` column width in the shared presentation owner so -surplus width is redistributed across peer metric, source, uptime, and action -columns instead of being dumped into the first column and wasting operator -visible table density. -That hot-path contract now includes policy badge rendering on resource rows. -Policy-rich table rows must only surface a single inline summary chip for -blocking `local-only`/`restricted` posture; non-blocking `sensitive` + +Resource table rows that surface policy posture must show at most a single +inline summary chip for blocking `local-only`/`restricted` posture; non-blocking `sensitive` + `local-first` and redaction-only metadata belongs in Data Handling, detail, and AI/governance surfaces instead of spending default row visual budget. -Agentless availability evidence belongs on that same bounded row path. +Agentless availability evidence belongs on the bounded row path. Any resource row carrying an `AvailabilityData` facet—whether a standalone `network-endpoint` or a known guest that inherited the facet through explicit link or IP correlation—may replace otherwise empty host metric slots with one @@ -2960,40 +2950,28 @@ The summary API feeding that hot path must also normalize mixed-resolution history into equal-time summary buckets before it reaches the shell/runtime owners, so long-range cards do not bunch recent higher-resolution samples at the right edge. -It now also includes compact resource-facet summary chips rendered next to -policy metadata, and table-row uses must set an explicit visible chip limit -with overflow disclosure instead of allowing mock-rich resource rows to wrap -an unbounded badge list. Those chips must stay within the same bounded -windowing and mounted-row budget proved by -`UnifiedResourceTable.performance.contract.test.tsx`. -The same facet summary contract applies to the service-resource rows inside -the unified table as well, so PBS and PMG entries must keep the same bounded -presentation and verification surface as the primary fleet rows. The shared -`ResourceFacetSummary` component now owns that chip rendering path, so any -future summary changes must preserve the same bounded row budget instead of -forking separate table-only presentation logic. That component now also +Compact resource-facet summary chips render through the shared +`ResourceFacetSummary` component, whose only production consumer is the +detail drawer's change history, so it renders every chip. A table-row use must +first give that shared component a visible chip limit with overflow disclosure +instead of letting mock-rich rows wrap an unbounded badge list, and must stay +within the table's bounded windowing and mounted-row budget rather than forking +separate table-only presentation logic. That component now also consumes the shared `frontend-modern/src/utils/resourceChangePresentation.ts` label helper for canonical change kinds, source types, and adapter provenance so the chip wording stays consistent without adding extra hot-path branching. -Host, PBS, and PMG summary rows also declare themselves as the compact touch -disclosure target through `SummaryRowActionButton`; the shared primitive hides -the redundant visible mobile chevron while preserving accessible disclosure -semantics, so provider sections do not pay a separate cell-width or tap-target -cost for an action already owned by the whole row. The same infrastructure hot path now also depends on the shared `frontend-modern/src/components/shared/ProgressBar.tsx` primitive for metric fill rendering. Performance-sensitive metric bars may vary by value and color, but they must render width through shared attribute-driven progress geometry instead of per-row inline width styles that break the hosted demo CSP. -The default table hot path now scopes those summary chips to timeline and -change-provenance badges only. Generic capability and relationship badges are -removed from the default row surface entirely until the underlying data is -proven populated, which preserves the fleet-table scan path and avoids -spending hot-path visual budget on model nouns that do not yet clear the -product bar. -Row summaries now also prefer canonical `facetCounts` on each resource when -they are available, so the hot path can stay within the same budget while -still reading totals from the shared resource contract. The drawer history +Those summary chips are scoped to timeline and change-provenance badges only. +Generic capability and relationship badges stay off the default surface until +the underlying data is proven populated, so no surface spends visual budget on +model nouns that do not yet clear the product bar. +Facet summaries fall back to canonical `facetCounts` on each resource when no +fetched facet counts are available, reading totals from the shared resource +contract. The drawer history surface reuses the same governed resource route helpers for relationship and related-resource links, so cross-resource navigation stays within the existing infrastructure surface rather than branching into custom detail-only routing. @@ -3005,37 +2983,21 @@ history overview down to timeline counts and timeline-summary chips, so the performance-sensitive shared presentation path stays aligned with the investigation-first product contract instead of rendering low-signal generic facet sections by default. -Governance metadata such as sensitivity and routing scope may be visible in -the table, but it must remain on the same bounded row-windowing and mounted-row -budget proved by `UnifiedResourceTable.performance.contract.test.tsx` rather +Governance metadata such as sensitivity and routing scope shown in a table +must remain on the same bounded row-windowing and mounted-row budget rather than creating a separate unbounded rendering path for policy-rich fleets. -Infrastructure cluster-header presentation must stay inside that same bounded -row model. `unifiedResourceTableStateModel.ts` owns whether the compact -`Cluster` chip is rendered, including suppressing it when the visible group -name already ends in `Cluster`, so label cleanup does not add a second -row-rendering path or per-page DOM workaround. -The shared table and detail drawer now also render governed resource labels -through the shared identity/display contract, which routes policy-aware -resources through the canonical policy-aware helper and suppresses the raw -alternate name when policy requires governed handling. That keeps the -policy-aware label path inside the same hot-row rendering budget instead of -adding a second display branch for redacted fleets, and the proof for that -behavior lives in `UnifiedResourceTable.performance.contract.test.tsx`. -The shared table now also passes the same canonical resource-label resolver -into the detail drawer so related-resource chips in the timeline/history path -can resolve through the canonical catalog without adding a separate +The detail drawer's governance summary resolves governed resources through the +canonical policy-aware `getResourcePolicyGovernedSummary(...)` helper. That +keeps the policy-aware label path inside the same rendering budget instead of +adding a second display branch for redacted fleets. +Platform tables pass the canonical resource-label resolver into the detail +drawer through `PlatformResourceDetailTableRow` so related-resource chips in +the timeline/history path can resolve through the canonical catalog without adding a separate detail-only lookup branch to the hot-row path. The same detail drawer also uses that resolver for correlation dependency and -dependent chips, so the investigation path does not fall back to raw IDs in -the drawer while the AI page keeps its broader no-catalog fallback. -The shared infrastructure selector search path now also routes through that -same preferred resource display contract, so governed resources do not -reappear via raw-name search candidates while the selector stays on the same -hot-path budget. -The shared workloads-link helper used by the resource drawer and table now -also routes its Kubernetes-cluster fallback through the same preferred -resource display contract, so navigation context does not leak raw -`displayName` values for governed clusters. +dependent chips, so the investigation path shows catalog labels in the drawer +and keeps the raw ID only when the catalog cannot resolve one, while the AI +page keeps its broader no-catalog fallback. That same workloads-link path and the workload projection now also share the canonical cluster-name helpers in the shared agent-resource layer, so route labels, pod grouping, and cluster-name fetch keys keep using the @@ -3097,17 +3059,6 @@ cards. Future throughput wording or workload-summary hot-path changes must extend these performance-owned surfaces instead of leaving the formatter or summary shell unowned in registry coverage. -Infrastructure selector status ordering must now tolerate arbitrary filter-set -strings without widening the canonical hot-path order tuple. Unknown statuses -must sort after the governed status order instead of forcing the selector path -to abandon the typed canonical order used by the infrastructure table and its -performance proof surface. - -The Infrastructure page now also normalizes source filter keys through the -shared `frontend-modern/src/utils/sourcePlatforms.ts` helper directly, so the -selector boundary keeps using the canonical source-platform contract instead of -maintaining a local source-normalization alias. - Resource detail mappers now also use the shared `frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor labels, so the canonical presentation layer owns that wording instead of the diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 35fe44427d..cb267e319a 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -137,14 +137,6 @@ "frontend-primitives" ] }, - { - "path": "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", - "rationale": "the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, { "path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", "rationale": "resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", @@ -153,62 +145,6 @@ "unified-resources" ] }, - { - "path": "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", - "rationale": "the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, - { - "path": "frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx", - "rationale": "the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, - { - "path": "frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx", - "rationale": "the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, - { - "path": "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", - "rationale": "the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, - { - "path": "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", - "rationale": "the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, - { - "path": "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", - "rationale": "unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, - { - "path": "frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts", - "rationale": "unified resource table state derivation, sort-cycle policy, service sorting, and responsive column layout are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, { "path": "frontend-modern/src/components/Infrastructure/useTableWindowing.ts", "rationale": "the shared bounded table-window controller is both a canonical frontend rendering primitive and a fleet-scale scrolling hot-path boundary", @@ -217,22 +153,6 @@ "performance-and-scalability" ] }, - { - "path": "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", - "rationale": "unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, - { - "path": "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts", - "rationale": "unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary", - "subsystems": [ - "performance-and-scalability", - "unified-resources" - ] - }, { "path": "frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx", "rationale": "the External agents settings panel is the optional settings-shell projection of Pulse MCP onboarding, the AI runtime connected-agent onboarding surface, and a presentation consumer of the shared agent capabilities frontend client", @@ -7115,18 +7035,8 @@ "pkg/metrics/" ], "owned_files": [ - "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", - "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts", "frontend-modern/src/components/Infrastructure/useTableWindowing.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts", "frontend-modern/src/components/Workloads/DiskList.tsx", "frontend-modern/src/components/Workloads/diskListModel.ts", "frontend-modern/src/components/Workloads/EnhancedCPUBar.tsx", @@ -7284,18 +7194,8 @@ "label": "Workloads hot-path proof", "match_prefixes": [], "match_files": [ - "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", - "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts", "frontend-modern/src/components/Infrastructure/useTableWindowing.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts", "frontend-modern/src/components/Workloads/DiskList.tsx", "frontend-modern/src/components/Workloads/diskListModel.ts", "frontend-modern/src/components/Workloads/EnhancedCPUBar.tsx", @@ -7364,9 +7264,7 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ - "frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts", - "frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx", - "frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts", + "frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts", "frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts", "frontend-modern/src/components/Workloads/__tests__/AvailabilityProbeStatusCard.test.tsx", "frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx", @@ -8094,7 +7992,6 @@ "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts", "frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx", "frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx", - "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx", "frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx", @@ -8109,20 +8006,10 @@ "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx", "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx", - "frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts", "frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx", "frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx", "frontend-modern/src/features/docker/DockerAlertsTable.tsx", @@ -8302,7 +8189,6 @@ "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts", "frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx", "frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx", - "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx", "frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx", @@ -8317,20 +8203,10 @@ "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx", "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx", - "frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", - "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", - "frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", - "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts", "frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx", "frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx", "frontend-modern/src/features/docker/DockerAlertsTable.tsx", @@ -8409,7 +8285,6 @@ "frontend-modern/src/components/Discovery/__tests__/AvailabilityProposalCard.test.tsx", "frontend-modern/src/components/Discovery/__tests__/availabilityProposalModel.test.ts", "frontend-modern/src/components/Discovery/__tests__/useDiscoveryFeatureAvailability.test.tsx", - "frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts", "frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx", "frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx", "frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts", @@ -8419,11 +8294,8 @@ "frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.test.ts", "frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts", "frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts", + "frontend-modern/src/components/Infrastructure/__tests__/ResourceFacetSummary.test.tsx", "frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts", - "frontend-modern/src/components/Infrastructure/__tests__/ResourcePolicySummary.test.tsx", - "frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx", - "frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx", - "frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts", "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", "frontend-modern/src/features/docker/__tests__/DockerAlertsTable.test.tsx", "frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts", diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index a2dc2b0c6e..3bd46e4a70 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -3065,3 +3065,11 @@ paused-delivery banner strings in English, German and Spanish. This is alert presentation copy only: no API token name, scope preset, privacy disclosure, telemetry statement or security term changes, and no request, storage or authorization path moves. + +### Retained disk temperatures are not served as live history + +`/api/metrics-store/history` now omits a disk's `smart_temp` live point, and +the mock-mode synthetic series derived from that reading, when the temperature +was not collected by the current observation. This only withholds a value the +caller was already authorized to read; the route, its authentication and scope +checks, tenant resolution and response shape are unchanged. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 626b08f763..66185fb88d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,14 @@ ## Purpose +### Storage usage headline follows the alert phase — issue #2068 + +`describeStorageAlertHeadline` says "Over N% usage limit" only while a usage +alert is breaching. When `metricStatus.phase` is `latched` or `recovering`, +the row says usage is under the limit and names the clear level, so the +headline never contradicts the usage bar beside it. Fill-forecast headlines +are unchanged. + ### Guest-memory read state stays independent of backup disk state The existing guest drawer retains Core's selected memory provenance through @@ -962,6 +970,11 @@ the page supplies it through the Workloads `tableTitle` slot and the shared states. This heading alignment changes neither workload backup scope nor the storage/recovery evidence carried by the adjacent Backup column and Backups tab. +The guest table's grouped node rows are identity-only dividers. Node stats and +the node drawer stay with the Proxmox nodes table above it, so `ProxmoxPageSurface` +passes `groupNodeDrawerMode: 'disabled'` and no host-metric option to the +embedded Workloads state. Those dividers carry no backup or recovery evidence; +the per-guest Backup column remains the overview's protection signal. The overview's node-row action selects the shared workload node scope and reveals the guest section. The title names the selected node and reports the filtered guest count, including zero. Activating the selected node again or @@ -986,15 +999,21 @@ composition, and visible source/state chips must render through `MetadataBadge` instead of restoring local rounded-sm xs badge spans. Backup ages read the frontend-primitives shared relative-time clock: `ProxmoxBackupAgeText` measures the full and compact age text and its -current, aging, or stale band from `useRelativeTimeNow`, never earlier than the -wall clock, so a backup row that stays mounted for days moves from current to -aging without new inventory, and a backup that finished inside the clock's last -tick never bands as a future, unknown age. `ProxmoxPageSurface` reads +current, aging, or stale band from `useRelativeTimeNow`, so a backup row that +stays mounted for days moves from current to aging without new inventory, and +because every clock read returns the wall clock, a backup that finished since +the last tick never bands as a future, unknown age. `ProxmoxPageSurface` reads replication jobs through `createNonSuspendingQuery` with a 30-second background poll, because the Replication table's Last sync and Next sync move on the same -clock: a failed read reports the error but keeps the last jobs and the tab, and -a direct replication link holds until the first read resolves instead of -falling back to Overview. The poll re-reads the existing read-only +clock. Each read forwards the query's abort signal, so a replaced or unmounted +read is cancelled. Whether replication exists is unknown until a read +succeeds, counted from the last reset (an organization switch): a failed read +before then holds a direct replication link on the error and its Retry instead +of falling back to Overview, and once a read has succeeded a failed poll +reports the error but keeps that answer (the jobs and the tab, or the Overview +fallback for an empty answer) rather than reopening the route. An access +denial (401 or 403) withdraws the jobs and the tab, and a direct link holds on +the denial so the operator sees why. The poll re-reads the existing read-only `/api/replication/jobs` snapshot and opens no new storage or recovery path. Proxmox backup-health and recoverable-artifact summary rows follow the same platform-table density contract as every other provider surface. Storage and @@ -1470,6 +1489,10 @@ recovery scope, or a storage/recovery-owned secret source. generated report output when a separate reporting surface exposes it, but workspace logo settings are not backup artifacts, recovery-point metadata, restore evidence, or storage-provider credentials. + The alert rows that request assembly builds now keep an alert's + handover resolution (moved to a Pulse agent, not recovered). That changes + how a report states alert health only; it opens no storage, backup or + recovery path, and report backups still come from the recovery store. Update-plan readiness payloads and apply-route readiness enforcement are adjacent shared API context only. Storage and recovery surfaces may observe the resulting update state if a future settings flow links to recovery @@ -3155,6 +3178,20 @@ pointer isolation are pinned by `TestResourceFromHostPreservesCustomSensorMeta` and `TestCloneResourceIsolatesCustomSensorValues`. +### Source status verdicts change status only + +`SourceStatus` carries an unexported verdict: the status each source reported +for the resource. It replaced the narrower marker for push reporters past their +lease. Status aggregation reads it, so a node a live poll reports offline, a +node the poller expired, an agent past its lease and a stopped guest on a quiet +source are `offline` instead of `online` or `warning`, unless another source +reports otherwise. The verdict is not serialized, persisted or exposed on the +wire, and the public `status`, `lastSeen` and cadence fields are unchanged. A +storage or datastore row that reported itself unavailable likewise stays +`offline` when its only source's poll goes quiet. No storage path, recovery +read or protection judgement depends on that status. +`TestCloneResource_MutateSourceStatusMap` pins that clones keep the verdict. + ### Shared system-settings boundary dropped dead auto-update schedule fields The shared `internal/api` system-settings surface this subsystem consumes @@ -3783,6 +3820,46 @@ values may remain visible as history, but their current collection state must remain explicit. In particular, members behind a shared controller must not inherit aggregate I/O counters, and a disk drawer must hide live-I/O charts when per-member collection is unavailable or unsupported. +On a disk merged from several rows (Proxmox, host agent SMART, Unraid +inventory, TrueNAS), the temperature state these surfaces read is the state of +the reading the disk shows (`pairPhysicalDiskTemperatureState` in +`internal/unifiedresources/registry.go`), so a retained value never arrives +under another row's `available`. The merge keeps each row's reading in an +unexported record on `PhysicalDiskMeta` (`internal/unifiedresources/types.go`) +that is never serialized; storage surfaces read only the presented temperature +and its state. + +A retained disk temperature reads as last known, never as a live reading. The +Physical Disks table, the disk drawer and the pool drawer's linked-disk list +decide through `isPhysicalDiskTemperatureCurrent` and +`getPhysicalDiskTemperaturePresentation` in +`frontend-modern/src/features/storageBackups/diskPresentation.ts`: only an +`available` temperature state, or a source that predates collection state, is +current and takes the per-disk-type threshold colour. Any other state, such as +a disk in standby or a host agent past its reporting lease, renders the value +muted with no hot or warm colour, a dotted underline, a title carrying the +collection reason and screen-reader "last known" text; the drawer card is +labelled "Last known temperature" beneath the existing collection message. +This is presentation only. It does not change the stored value, history writes +or alert evaluation. Verification: `diskPresentation.test.ts`, +`diskDetailPresentation.test.ts`, `storagePoolDetailPresentation.test.ts`, +`DiskList.test.tsx` (a row moving between current, retained and legacy states) +and `DiskDetail.test.tsx`. +The same decision reaches the disks that only a host agent's own SMART rows +describe. `HostDiskSMART.collection` now crosses into the frontend type, and the +helpers live in `frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts` +(re-exported by `diskPresentation.ts`, one implementation) so the Machines +table and the machine drawer can use them without loading the Storage +presenter. The guest drawer's card already loads it for extraction and health. +A silent agent's retained SMART temperature never becomes the Machines row's +value while another disk has a current reading. When the machine has no +positive direct or `temperatureCelsius` reading and no non-standby disk is +current, the cell shows the hottest retained non-standby value as last known, +without the per-disk-type threshold colour. The guest drawer's Physical Disks card marks a +retained value the way the Physical Disks table does. Still presentation only. +Verification: `diskPresentation.test.ts` (one implementation behind both import +paths), `agentMachineTableModel.test.ts`, `AgentsMachinesTable.test.tsx`, +`resourceDetailMappers.test.ts` and `GuestPhysicalDisks.test.tsx`. Direct SATA, SAS, and NVMe device fallback identities remain compatible. Controller-member fallback identities add controller/target scope only where @@ -6696,6 +6773,38 @@ disk/pool fixture `frontend-modern/browser-tests/history-touch.cjs` and mounted `HistoryChart.test.tsx` regressions verify presentation/input behaviour only, not live appliance collection or recovery success. +### Physical-disk heat is not a replacement verdict + +The Physical Disks Health verdict names the action it asks for. A disk whose +current temperature has reached its alert trigger reads `Running Hot` (compact +`Hot`), red, with the reading and the trigger as its summary. `Replace Now` +stays reserved for FAILED health, any critical risk reason, and a critical +level that no listed reason explains. `Needs Attention` keeps the remaining +warning evidence. Heat outranks warning evidence and failure evidence outranks +heat. Sorting follows the verdict (replace, heat, attention), and the health +filter places a hot disk with the critical disks, as its Temp cell reads red. + +Heat has one owner: the alert disk temperature policy (the per-type triggers in +Alerts > Thresholds, `diskTempByType`, else the agent Disk Temp default). +Disk risk never carries heat, because every registry that rebuilds it lacks the +alert configuration. `extractPhysicalDiskPresentationData` takes the alerts +store's `getDiskTemperatureThresholds` resolver, so the Temp cell colour and the +verdict read one thresholds object. The band below the trigger is a Temp cell +colour only, and a retained (not current) reading is never heat. The verdict +judges the current reading, with no memory of an alert. A disk temperature +alert held between its clear value and trigger shows an amber Temp cell, not +`Running Hot`. A switched-off agent Disk Temp default resolves to null, which +leaves the reading uncoloured and never hot, as alerting is off for it. +Per-host Disk Temp overrides do not yet reach Physical Disks. PDF performance +reports colour a disk reading by the same per-type thresholds, which +`reporting.DiskInfo` carries. +`frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts` +pins cell-and-verdict agreement for every type from 40C to 80C under factory and +raised triggers, plus retained readings, failure evidence, sorting and +filtering. `frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx` +covers NVMe 63C, SATA 56C and a raised NVMe trigger loaded through the alerts +store. + ### Pool-to-physical-disk ownership in Storage details `storagePoolDetailPresentation.ts` must scope inferred ZFS device and UnRAID @@ -6739,3 +6848,15 @@ blank header is removed on the next save. Export and import pass through the same normalization. No new file, retention window, backup, migration or recovery authority is introduced; `TestWebhookMaskedValuesResolvePerKey` in `internal/api/alerting/notifications_test.go` covers the write path. + +### Retained disk temperatures open no storage or recovery path + +The metrics-history handler in `internal/api/router.go` serves a disk +temperature live point only when `diskinventory.TemperatureCollected` holds, +and the Unraid physical-disk adapter marks an inventory temperature kept past +the host agent's lease as no longer collected. Both change presentation of +values Pulse already holds: no metrics write, retention window, backup, +migration or recovery authority is added or moved. +The performance report and reporting runtime snapshot handlers apply the same +test before tabulating a disk temperature. That changes only which held value a +report shows, not any storage or recovery path. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5c1dfdb1bb..6f82da2f6d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,14 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Proxmox node temperature cell follows its own open alert — issue #2068 + +`ProxmoxNodesTable` finds the node's own temperature alert through +`hostOverrideIdCandidates` and passes its severity (unless acknowledged) and +the shared live-status copy to `TemperatureGauge`. Other node alerts never +recolour the cell. The Mail Gateway drawer formats its attention rows through +`getAlertAttentionCopy`. + ### Canonical Discovery honours VM guest-read deferrals The canonical resource drawer uses the same fixed PVE VM lock, backup and @@ -825,13 +833,6 @@ Compact Coverage age cells use the existing metadata-size text and reclaim numer 48. `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` 49. `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` 50. `frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx` - 50a. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` -51. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` -52. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` -53. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` -54. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` -55. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` -56. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` 57. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts` 58. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts` 59. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts` @@ -841,8 +842,6 @@ Compact Coverage age cells use the existing metadata-size text and reclaim numer 62. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts` 63. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts` 64. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts` -65. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` -66. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` 67. `frontend-modern/src/components/Discovery/discoveryReadiness.ts` 68. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx` 69. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts` @@ -1111,6 +1110,17 @@ The first reason renders in the desktop Health column on the row's single line, coloured by bucket, with a count for the rest and the full list in the title and screen-reader text. Phones keep the status dot and reach the reason through the row drawer. Health sorts by severity rank, not label text. +Disk risk carries no heat, so the table judges it itself. A disk whose current +reading has reached its type's alert disk temperature trigger is an Attention +row with the reason "Disk temperature is 57°C, at or above its 55°C alert +threshold.". The trigger comes from the alerts store's +`getDiskTemperatureThresholds`, which the table passes to the row build, +filter, counts, sort and issue. `isPhysicalDiskRunningHot` in +`features/storageBackups/diskTemperaturePresentation.ts` makes the same call +for the Physical Disks verdict. The heat reason follows any native TrueNAS +alert text, and a hot disk's status dot turns warning though its source state +stays `online`, because phones show only the dot. +`truenasPageModel.test.ts` and `TrueNASStorageTopologyTable.test.tsx` pin it. The vSphere Datastores and Networks tables follow the same exception-first Health column. `getVmwareDatastoreIssue` and `getVmwareNetworkIssue` return nothing for a green row; otherwise the reasons are, in order, an impaired @@ -1274,9 +1284,14 @@ tooltip, and the Proxmox replication Last sync and Next sync in the row, phone projection and disclosure. A silent agent or a stalled pvesr scheduler is the case whose age matters most, so a replication Next sync turns overdue on the clock without new data. Replication jobs bypass the unified-resource stream, -so `ProxmoxPageSurface` re-reads them in the background every 30 seconds; -moving ages over a snapshot read once at mount would age a job that keeps -syncing and count it overdue. +so `ProxmoxPageSurface` polls them in the background every 30 seconds, +skipping a tick while the previous poll's read is still in flight unless that +read has stalled past `STALLED_QUERY_READ_MS`; moving ages over a snapshot read +once at mount would age a job that keeps syncing and count it overdue. +`fetchReplicationJobs` takes the query's abort signal and passes it to +`apiFetch`, so a replaced or unmounted replication read stops instead of +running on in the background, and it throws `apiErrorFromResponse` errors that +keep the HTTP status, so a 401 or 403 withdraws the jobs as an access failure. Kubernetes name columns hold the chevron, status dot and name inside one track, so their md widths leave the name room for a typical node or service name at a 768px viewport: Nodes 20 percent (with Capacity at 16 so its @@ -1530,7 +1545,10 @@ When every node shares one cluster and none is standalone, the model drops the `cluster` column (it would repeat one value on every row) and `ProxmoxNodesTable` names that cluster in the table header instead, visible during search and independent of the inventory-count preference, so the -cluster identity is never lost. Workload status buckets read the canonical +cluster identity is never lost. Its Trends sparklines read node history +through `useWorkloadTableMetricHistory` with `series: 'nodes'`; guest history +stays with the embedded workloads table below it, so the nodes table adds no +guest-history poll to the page. Workload status buckets read the canonical health too: a running workload whose unified health names an open `warning_alert` or `critical_alert` reason counts under Attention, while `backup_stale` and other non-alert reasons do not. @@ -1704,17 +1722,7 @@ the attached container fields searchable and attention-filterable from the network detail disclosure instead of forcing operators to inspect a full container inventory table. -1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `performance-and-scalability`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `performance-and-scalability`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `performance-and-scalability`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -4. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `performance-and-scalability`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -5. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `performance-and-scalability`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -6. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `performance-and-scalability`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -7. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `performance-and-scalability`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -8. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `performance-and-scalability`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -9. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` shared with `performance-and-scalability`: unified resource table state derivation, sort-cycle policy, service sorting, and responsive column layout are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `performance-and-scalability`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. -11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `performance-and-scalability`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 12. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx` shared with `storage-recovery`: Proxmox backup server table rows are both a storage/recovery backup-health surface and a unified-resource platform-table consumer boundary. The table is composed on the Proxmox Backups tab and must not be duplicated on Overview. The Backups placement keeps the canonical PBS row and drawer @@ -2273,6 +2281,26 @@ and temperature facts must also compose the frontend-primitives `InfoCardKeyValueRow`. Mobile rows retain their condensed endpoint layout; desktop rows use the shared fixed label track so labels and values remain visually adjacent instead of spanning the full drawer width. +A standalone host agent past its reporting lease reaches the Machines table as +`offline`, as described in "Status follows each source's own verdict". The +offline metric fallback blanks its CPU, memory, disk, network, disk I/O, uptime +and temperature cells. The machine drawer drops a silent agent's uptime and +marks its non-disk Thermals rows "(last known)" with the reason as title, +keyed on `agent.stale` because those rows are that agent's own sensors. +Its Disks card reads the agent's retained filesystem usage as last known on the +same signal (`agentReadingsLastKnownReason`): each figure stays, muted and +titled with the reason, without threshold colour or usage bars. +For SMART disk +temperatures on rows that still render, the provenance travels on +`agent.sensors.smart[].collection`: the Machines temperature cell +(`AgentsMachinesTable.tsx`, `agentMachineTableModel.ts`), its tooltip and the +drawer's Thermals rows (`resourceDetailMappers.ts`) read it through +`isPhysicalDiskTemperatureCurrent`, so a retained disk reading never stands for +the machine while another disk has a current one. A positive direct +`temperature` or `temperatureCelsius` reading carries no collection state and +still leads the cell. Without one, and with no current non-standby disk, the +hottest retained non-standby disk reading shows as last known without +threshold colour. The same boundary applies to availability facts, resource change-history metadata, Docker/PBS/PMG service facts, nested PMG queue/mail breakdowns, and Docker container-update management facts. It also covers action-history facts, @@ -2347,23 +2375,13 @@ provider identity or governed safe-summary posture when that context helps an operator, but the rendered labels must stay product-neutral and use `Analysis`, `Analysis Reasoning`, and `Safe Summary` rather than reviving generic `AI` or `AI-Safe` branding inside the resource drawer or discovery -shell. 14. Keep the operator-facing unified resource table width-aware at the table -surface, not just at the browser viewport. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` -must route its root ref through `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`, -and `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` -owns the column-priority breakpoints for host and service infrastructure -rows. When the app shell leaves tablet-sized space during live resize, the -table hides lower-priority metadata first. At phone width, the state model -must remove the old 640-pixel floor, preserve identity at exactly 30 percent -of the table, and allocate the remaining width across the bounded -source-relevant health and activity columns. Both the document and table -shell must remain free of horizontal overflow; desktop and tablet stages -retain their existing complete column contracts. 15. Keep shared policy-posture framing on the unified-resource card owner. -`frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` -may accept caller-owned subtitle or resource-count wording when Patrol or -another shared surface needs to explain how the same governed policy counts -should be read, but those framing lines must extend the shared card API -rather than spawning page-local policy summary shells. 16. Keep platform/runtime top-level route paths on the canonical resource-link +shell. 15. Keep governed policy-posture wording on the unified-resource +presentation owner. `frontend-modern/src/utils/resourcePolicyPresentation.ts` +owns the canonical sensitivity, routing, and redaction labels and their +orderings. No shared card renders policy-posture counts today; if one +returns, caller-owned framing such as subtitle or resource-count wording must +extend that one card's API rather than spawning page-local policy summary +shells. 16. Keep platform/runtime top-level route paths on the canonical resource-link helper. `frontend-modern/src/routing/resourceLinks.ts` owns the `STANDALONE_PATH`, `DOCKER_PATH`, `KUBERNETES_PATH`, `TRUENAS_PATH`, `VMWARE_PATH`, `PATROL_PATH`, `PATROL_CONTROL_ANCHOR`, @@ -2570,72 +2588,20 @@ application resource-provider or WebSocket lifecycle. counts or capacity must use the shared `AnimatedNumber` primitive rather than page-local counter state, so readout motion stays presentation-only and canonical unified-resource identity and scope stay unchanged. -12. Keep infrastructure chart hover non-destructive to the unified-resource - table. If the hovered resource row is already visible in - `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx`, - the row may highlight in place through the shared active-resource id; if it - is off-screen, the page must offer an explicit `Jump to row` affordance - rather than auto-scrolling or collapsing the table on hover. - 12a. Keep infrastructure summary visibility as display preference, not a +12. Keep infrastructure summary visibility as display preference, not a unified-resource filter. Platform/runtime pages and shared infrastructure summary consumers may hide or restore chart sections through shared presentation controls, but those controls must not mutate resource identity, table membership, source scope, or summary-hover state. The retired top-level `/infrastructure` page and its saved-view/route-state machinery must not be reintroduced for this purpose. -13. Keep infrastructure cluster headers as canonical summary scope. Grouped - headers in `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` - must publish cluster scope from the same `ResourceGroup` / unified-resource - ids that power the table rows, and - `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` - must consume that scope through the shared page/group/entity interaction - contract rather than inventing infrastructure-local summary filters or - route-backed cluster hover state. Host and service infrastructure table - card frames must consume the frontend-primitives-owned `TableCard` wrapper; - unified-resource ownership remains on resource identity, grouping, and row - semantics rather than forking a table border/background shell. Deliberate - cluster focus must also stay - on the canonical infrastructure route through the shared `summaryGroup` - query state, so pinned scope is shareable, reversible, and owned by the - same route-backed summary contract as row focus. Infrastructure must stay - row-first here: the pinned cluster header remains the visible scoped - state, and explicit clearing belongs to the shared infrastructure table - card header action plus the shared `Escape` reset path rather than a - search-row fallback widget, page-level scope strip, or a second - scope/pinned pill inside the cluster row chrome. Background whitespace - clearing may remain a convenience, but infrastructure must not rely on it - as the only reversible control. -14. Keep infrastructure row emphasis on the shared frontend presentation - contract. Host, PBS, and PMG table sections may decide whether a resource - is contextually active, but they must expose that state through - `data-summary-row-active` and rely on the shared row presentation owned by - `frontend-modern/src/index.css` instead of provider-specific background - classes that drift across resource tables or hide inline metric bars. - Cluster-member rows must also expose shared preview-versus-pinned group - emphasis through `data-summary-group-member-active`, so the whole cluster - block reads as the active scope without inventing a second infrastructure- - local outline or banner treatment. - Static grouped cluster-header emphasis must route through - `frontend-modern/src/components/shared/groupedTableRowPresentation.ts` and - the shared `.grouped-table-row` CSS contract in `frontend-modern/src/index.css`, - rather than infrastructure-local background or hover-fill classes. - Summary-linked infrastructure rows and cluster headers must also route - pointer preview and focus preview through - `frontend-modern/src/components/shared/summaryInteractionA11y.ts`, while - deliberate expand/scope ownership must route through - `frontend-modern/src/components/shared/SummaryRowActionButton.tsx`, so the - unified-resource table does not fork mouse-only hover logic, focusable-row - button shims, touch-hostile synthetic hover, or provider-specific control - handling across host, PBS, and PMG sections. Those three resource rows are - themselves the compact touch disclosure target and must explicitly enable - the shared mobile-chevron suppression; their accessible row/button state - remains available without spending a visible summary-cell slot on a - duplicate control. -15. Keep infrastructure search aligned with the governed display label. Shared - infrastructure filtering through - `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` - must match the user-visible safe label for governed resources instead of - reintroducing redacted hostnames through search-only fallback candidates. +15. Keep operator-local resource search on the operator's own names. + Resource-policy redaction is a transmission boundary (`docs/PRIVACY.md`), + so local search over unified resources, such as the `ResourcePicker` and + `useResources` search filters, matches the raw name from + `getPreferredInfrastructureDisplayName(...)` plus the resource ID rather + than the governed safe label. Provider-bound labels keep the governed + `getPreferredResourceDisplayName(...)` path. 16. Preserve provider-backed storage backing-pool identity on canonical storage resources. `internal/unifiedresources/types.go`, `internal/unifiedresources/adapters.go`, `internal/unifiedresources/views.go`, @@ -3042,7 +3008,10 @@ canonical WebSocket snapshot, including the public `vmware-vsphere` alias for the raw `vmware` source, rather than degrading a source-scoped page to periodic REST refreshes. VMware Overview passes that same source-scoped snapshot into the embedded Workloads state, so hosts and VMs share one inventory generation -and one explicit refresh path. +and one explicit refresh path. Beyond disabling the grouped host drawer, it +passes no option that adds host metrics to grouped rows: the shared Workloads +group row carries host identity only, and per-host stats stay in the page's +own hosts table. Its page-owned workload toolbar consumes the complete shared `getWorkloadsMetricFilterProps` binding, so vSphere VMs expose the same Bars, Trends, Details, History, range, and first-use discovery contract as every @@ -3222,6 +3191,19 @@ footer after the visible rows. The footer may dim the final row edge and expose the remaining-row count, but expanding or collapsing it only changes the client-side visible slice; it must not refetch, reorder, or redefine the canonical resource collection. +The shared `nodeFromResource` adapter selects a node's temperature record as +before: the platform temperature record, else the Proxmox temperature record, +else the agent facet. When that selection yields no usable reading, it uses +`proxmox.temperatureDetails` if that holds a current CPU reading, ahead of the +bare scalar fallback. That field is +the Proxmox poller's full reading for a node without a host agent, including +the CPU low and record values the poller supplies; `proxmox.temperature` is +only the scalar maximum. The adapter forwards those values; keeping them across +polls is the poller's job (`monitor_previous_state.go` and the temperature carry +in monitoring). A bare scalar reading carries no history, so the adapter leaves +the CPU low and record unset rather than repeating the current value, and the +node drawer omits those rows. `resourceStateAdapters.test.ts` pins these cases +with the API's payload shape. The registry and presentation coalescer also own metric-source freshness. When two source facets contribute the same metric, source priority decides only if both sources have equivalent freshness. A stale source must not hold CPU, @@ -3576,6 +3558,60 @@ state the Proxmox row still carries from the same source past its reporting lease must not stay "collected" through the Proxmox row's copy of the agent's own state, including when its host is down and no disk poll refreshes that copy. +Each merge step then presents the shown temperature under a state that belongs +to it (`pairPhysicalDiskTemperatureState`). The value is still chosen by source +preference and the field states merged as above, so the shown value could +otherwise sit under another row's state: a silent agent's retained reading +under a Proxmox row's current `available`, or a legacy agent's stateless +reading under a Proxmox inventory row's `unsupported`. One merged state per +field cannot say whose reading is shown once a disk has three or more rows, so +the registry keeps each row's own temperature reading and state on the merged +disk (merge-time only, never serialized) and derives the state from the +readings of the shown value. Each counts its own state, except that a state a +non-agent row copied from an agent source gives way to an agent row's own +withdrawal of that source (a legacy agent's sourceless withdrawal matches its +`host_agent` copy through `diskinventory.LegacyHostAgentStatus`, the rule +monitoring stamps the copy with); that withdrawal is the only state taken from +a reading of another value, and it never grants `available`. A collected +reading is presented over one that is not and an agent's own report over a +copy, so while a current reading holds the shown value, the order in which +different rows arrive does not change the state. A second agent reporting the +same disk is not a copy: its withdrawal leaves another agent's own collected +reading collected. A row that reports again replaces its reading. When source +preference keeps showing a value no current reading holds (the row that +reported it went into standby, or reported again without the SMART attributes +that made its value preferred), the value keeps the state it was presented with +and the row that state belonged to, under that row's later withdrawal, and the +agent's withdrawal of its source still supersedes it; a row's earlier report +never competes as current evidence. Such a kept state depends on the history of +reports, as the kept value itself does, and stays `available` when its row now +reports a different collected value. A disk that arrives without the record, or +no longer presents the value and state it was built for (edited), stands as one +reading of unknown rows: an incoming agent row's withdrawal of the source of +its state supersedes it, and when the agent is among its sources its own +withdrawal still supersedes an incoming copy. A disk entering the registry as a +new row is that row alone. Two boundaries lose rows. JSON keeps only the +presented state, so a withdrawal it does not show cannot supersede a copy +merged after a round trip; the production rebuild ingests every row into a +fresh registry and never merges across JSON. A manual link keeps the primary +disk's readings and drops the other disk's, so rows merged after the link +(supplemental records) meet only the primary's. +`registry_merge_policy_test.go` +(`TestPhysicalDiskMergePairsEveryShownTemperatureWithItsRowState`) checks every +ingest order of a catalog of agent, Unraid, Proxmox and TrueNAS rows against +this rule, and `TestPhysicalDiskMergeTemperatureReadingsAcrossIngestBoundaries` +pins the boundaries. +Unraid array-inventory rows carry no per-field provenance, so the adapter +derives the state of a positive temperature taken from one (a row without a +temperature claims no state): `unavailable` from `unraid` +with "host agent stopped reporting" once `State.ExpireHostTelemetry` has marked +the host offline, or with "disk is reported spun down" for a spun-down disk, +and otherwise `available`. The value is kept as last-known context; it is +collected again only when a reporting host sends a positive reading for a disk +that is no longer spun down. The Unraid-native disk row carries that state, and +so does a SMART row that falls back to the inventory reading because it has +none of its own. The adapter's risk assessment of an Unraid row judges no +temperature at all; heat belongs to the alert disk temperature policy. Cross-source correlation compares normalized serial and WWN values across fields without truncation, allowing a PVE bare-hex array-volume serial to join @@ -4015,8 +4051,6 @@ when the opened drawer would otherwise fall below the fold. That reveal must scroll only enough of the infrastructure table to keep the row header plus the start of the detail visible, not leave the drawer clipped and not hard-center the selected row. -`useUnifiedResourceTableViewportSync.ts` must stay viewport-only; it may not -grow a second selected-row reveal path or a resource-local centering rule. That same unified-resource boundary now also owns stored metrics-target continuity for provider-backed resources. When registry rebuild cannot derive a fresh metrics target from raw source facets, `internal/unifiedresources/registry.go` @@ -4123,6 +4157,14 @@ incidents during cross-source merges. A provider alert such as TrueNAS `truenas_smart` is not presentation-only context; it must become a canonical `physicalDisk.risk.reasons` entry so hybrid agent/API disk resources keep one shared disk-health truth after deduplication. +That shared risk contract never judges temperature, collected or retained. +Heat belongs to the alert disk temperature policy, which no registry can see, +so neither the adapter assessment nor the registry's recompute from merged +metadata (`physicalDiskAssessmentFromMeta`) passes a temperature into +`storagehealth`. A 72C disk keeps its reading on the resource, adds no +`temperature_high` reason and stays `online`. Proof: +`TestPhysicalDiskRiskNeverJudgesTemperature` in +`internal/unifiedresources/registry_test.go`. That same canonical disk contract now also owns recent aggregate temperature history. When a provider such as TrueNAS can supply `disk.temperature_agg` min/avg/max readings, it must project those onto @@ -4235,10 +4277,22 @@ An `alert_resolved` change for a close that was not a recovery carries the alert engine's summary as its reason and the reason code as `alert_resolution` metadata (`AlertTimelineChange.ResolutionReason` and `ResolutionSummary`), so incident projection titles the close with where the alert went instead of -"Alert resolved: ". The resource drawer still labels the change -by kind (`Alert resolved`) and prefixes its headline with that label; only the -reason after it says the alert moved. Ordinary recoveries omit the key, and -every other lifecycle kind ignores the fields. +"Alert resolved: ". Frontend readers label the change through +`getResourceChangePresentation(change)` rather than by kind alone: with the +metadata set, the drawer's change history titles the entry `Alert moved` and +the `ResourceChangeSummary` badge reads `Alert moved` in the neutral blue of +the alert history's "moved to agent" badge (`Alert closed` for a reason code +the build does not know), and +`formatResourceChangeHeadline` uses the summary as the whole headline instead +of prefixing `Alert resolved:`; Patrol's assessment context reuses that +headline. A node's own drawer does not list the move yet: node and guest +alert changes are still recorded under their legacy alert resource IDs +(`MonitorAdapter.RecordChange` maps only Docker IDs), and Proxmox platform +rows open the drawer in the `table-row` presentation, which fetches no +remote history. The facet badges +still count these changes under `alert_resolved`, because the counts carry no +metadata. Ordinary recoveries omit the key, and every other lifecycle kind +ignores the fields. Alert-scoped incident memory may still project those events for one investigation thread, but the durable source of truth for resource-affecting alert lifecycle and @@ -4392,9 +4446,8 @@ raw backup, sync, verify, prune, and garbage job arrays travel through the unified-resource metadata contract in `frontend-modern/src/types/resource.ts` and `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`, `resourceDetailDrawerServiceModel.ts` owns active-task status classification and -shared activity wording, and both -`frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` -and `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx` +shared activity wording, and +`frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx` must render from that shared projection instead of rescanning raw job arrays or inventing local PBS status heuristics, `resourceDetailDrawerIdentityModel.ts` owns the pure identity-card, @@ -4451,15 +4504,14 @@ The same policy presenter now also owns the routing-scope labels used across AI-facing policy surfaces, while the resource detail drawer stays on per-resource policy lines instead of reconstructing a separate `Allowed`/`Blocked` row or `Cloud Summary` decision row locally. -The infrastructure host-table shell now treats the default -`Internal` + `Cloud Summary` posture as canonical policy metadata that should -stay available in the drawer and AI/governance surfaces without being promoted -to always-on row chrome. Inline row badges are reserved for blocking policy -states such as `local-only` routing or `restricted` sensitivity, while -`sensitive` + `local-first` and redaction-only posture remains visible in Data -Handling, detail, and AI/governance surfaces. The table must not imply that -ordinary sensitive resources carry an operator-actionable infrastructure -exception. +The default `Internal` + `Cloud Summary` posture is canonical policy metadata +that stays available in the drawer and AI/governance surfaces without being +promoted to always-on row chrome. A table that shows inline policy badges +reserves them for blocking policy states such as `local-only` routing or +`restricted` sensitivity, while `sensitive` + `local-first` and redaction-only +posture remains visible in Data Handling, detail, and AI/governance surfaces. +A table must not imply that ordinary sensitive resources carry an +operator-actionable infrastructure exception. The resource drawer now applies the same rule to its investigation-context governance block: the default posture remains part of the canonical policy contract, but the drawer only surfaces the governance section when the policy @@ -4562,30 +4614,9 @@ The shared node-state adapter also routes Proxmox cluster labels through that same helper, so infrastructure summary projections keep the same canonical cluster name as the rest of the unified resource model instead of rewriting the label locally. -The unified resource table now routes reactive table-state composition, -grouping, and row-windowing through -`frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`, -while pure table-state derivation, service sorting, sort-cycle policy, and -responsive column layout now route through -`frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`, -and viewport reveal plus scroll synchronization now route through -`frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`, -so the shared consumer model is no longer interleaving selector derivation, -layout policy, and DOM viewport coordination inside one mixed state boundary. -That viewport controller must consume -`frontend-modern/src/components/shared/windowedPageScroll.ts` for scroll- -ancestor selection, wheel normalization, and listener lifecycle. Wheel may -prewarm the bounded row runway, but touch remains compositor-native and updates -the runway only through the passive native page-scroll event; unified-resource -tables must not register their own touch listener. -The mobile shell class from that shared state model now uses `min-w-full` for -the phone stage and restores the existing wider-stage floor above it. Mobile -column weights must sum to the available table width, retain the prioritized -identity and operational set, and avoid both local and document-level -horizontal scrolling. This is a phone projection only; wider stages keep the -established desktop distribution. -The native provider rollout now applies the same projection to Proxmox backup, -coverage, recoverable, Ceph, Mail Gateway, and replication rows; Docker native +Native provider tables keep a phone projection: prioritized identity and +operational columns sized to the available width, with no local or +document-level horizontal scrolling, for Proxmox backup, coverage, recoverable, Ceph, Mail Gateway, and replication rows; Docker native and Swarm inventories; Kubernetes native inventories; TrueNAS systems and workflows; vSphere hosts and workflows; and Standalone machines. The shared `platform-table-phone-hidden` marker removes secondary or duplicate tracks at @@ -4595,17 +4626,6 @@ demotion while promoting identity to a 40-percent track. Docker rows whose full labels, image references, or placement values cannot fit now expose those values in the same touch- and keyboard-operable inline detail pattern used by the other platform tables rather than relying on hover titles. -That same unified-resource consumer contract now also owns CSP-safe table -presentation for infrastructure rows. Host, PBS, and PMG table sections must -consume the shared column presentation owner and render canonical table sizing -through classes plus DOM width/height attributes rather than lane-local inline -style objects, so the same unified-resource dataset can reach the public demo -without transport-specific DOM drift. -That same consumer contract now also owns full-width desktop balance for the -infrastructure tables. The shared column presentation owner must publish an -explicit desktop `Resource` width for host, PBS, and PMG sections so wide -shells redistribute surplus width across the remaining columns instead of -turning the first column into blank filler that hides metric density. The canonical unified-resource change and relationship presenters now also share the same elapsed-time and "ago" wording utilities, so `observed`, `last seen`, and `ago` fragments stay consistent without each formatter @@ -5075,11 +5095,10 @@ The frontend unified-resource hook now trusts backend canonical `policy` and `aiSafeSummary` values directly, so the canonical summary value stays aligned with the same policy-aware contract that governs sensitivity and routing metadata without re-normalizing locally. -The resource detail drawer and unified resource table now also render that -governed display label through the same policy-aware helper, and they suppress -the raw alternate name when policy requires governed handling, so the visible -label stays aligned with the backend redaction boundary instead of -reconstructing a local name fallback. +The resource detail drawer's governance summary now also renders that +governed label through the same policy-aware helper, so it stays aligned with +the backend redaction boundary instead of reconstructing a local name +fallback. The resource detail drawer now also resolves its AI-safe summary through that same helper, so governed resources still present the canonical redacted label when the backend summary is missing instead of dropping the governed summary @@ -5322,34 +5341,26 @@ path-policy coverage, so new unified-resource-owned runtime files must be added to a concrete proof route instead of falling back to subsystem-default verification. -The infrastructure table, selector, and detail-mapper frontend consumers are -now governed as explicit shared boundaries with the performance lane rather -than implicit downstream usage. That means future fleet-scale table changes -must preserve both canonical unified-resource semantics and the table -performance proof route. The shared resource table and resource drawer now -surface compact timeline summary chips, so facet presentation changes must -continue to flow through the same governed resource-row surface rather than -inventing a separate ad hoc summary path. Dense table rows must bound those -chips with an explicit visible limit and overflow label, while row-level -policy chips are limited to blocking `local-only`/`restricted` posture so -mock-rich canonical resources cannot stack or overstate governance badges -inside the resource column. Those row summaries now prefer canonical -`facetCounts` on the +The detail-mapper frontend consumer is governed as an explicit shared +boundary with the performance lane rather than implicit downstream usage, so +future fleet-scale changes there must preserve both canonical unified-resource +semantics and the performance proof route. The resource drawer surfaces +compact timeline summary chips through the shared `ResourceFacetSummary`, so +facet presentation changes must continue to flow through that component rather +than inventing a separate ad hoc summary path. A dense table row that adopts +those chips must first give the shared component a visible limit and overflow +label, and any row-level policy chip stays limited to blocking +`local-only`/`restricted` posture so mock-rich canonical resources cannot stack +or overstate governance badges inside the resource column. Those summaries now +prefer canonical `facetCounts` on the resource object when available, so the backend list/read shapes remain the source of truth instead of forcing the frontend to infer totals only from loaded slices. The drawer now fetches those facets through one backend bundle endpoint, and that shared facet bundle preserves the timeline slice plus recent-change counts so the overview card and history summary can report the loaded history instead of collapsing to the currently -loaded page when the timeline endpoint is paginated. Timeline references in -that drawer now route -through the canonical infrastructure resource filter, so the resource history -remains navigable from the history surface instead of being purely -descriptive text. -The same infrastructure selector pipeline now also uses the policy-aware -display contract for search candidates, so governed resources do not reappear -through raw-name search forks even though the selector stays on the same -hot-path budget. +loaded page when the timeline endpoint is paginated. Related resources on +those timeline entries render through the drawer's resource-label resolver. The shared recent-change presentation boundary is also owned here now. `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` is the canonical shared card for recent resource-change timelines, while @@ -5369,17 +5380,14 @@ labels, relationship labels, headline formatting, summary wording, and canonical relationship/correlation ordering. Correlation and relationship badge text must preserve the formatter's human-readable title case instead of forcing visual uppercase over enum-like payloads such as `ALERT → ALERT`. -`frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` -is the canonical shared card for governed policy-posture counts, while `frontend-modern/src/utils/resourcePolicyPresentation.ts` owns the canonical -sensitivity, routing, and redaction labels and aggregate count summaries. -That shared policy card also owns caller-supplied framing lines such as -subtitle and resource-count wording, so Patrol or other shared surfaces may -clarify whether the same governed counts read as policy-covered-resource -context without rebuilding their own policy-posture card shell. New -shared-surface framing needs such as Patrol's `policy-covered resources` -count label or explanatory subtitle should extend that card API instead of -forking a second page-local policy summary. +sensitivity, routing, and redaction labels and their orderings for governed +policy posture. No shared unified-resource card renders policy-posture counts +today: the card that once did lost its last mount on the Patrol page and was +removed together with its aggregate count-summary helpers. If a shared +posture card returns, caller-supplied framing such as a subtitle or a +resource-count label must extend that one card's API instead of forking a +page-local policy summary. Future correlation or policy-posture wording changes should extend those unified-resource owners instead of drifting into page-local loops in AI, Patrol, or infrastructure surfaces. @@ -5674,14 +5682,8 @@ Unknown source types may contribute identity and source-label evidence, but they must not outrank the known canonical primary-type order when a merged host contains a governed connector such as `proxmox-pve` or `agent`. -Infrastructure selector consumers must also preserve the canonical -`KnownSourcePlatform` normalization boundary when collecting source filters and -status facets. The selector layer may accept arbitrary user-visible filter -strings, but it must not widen the canonical unified-resource source/status -contracts that feed the infrastructure table and workload links. - -The same source-filter boundary now also applies to infrastructure source UI -options in Settings and platform/runtime pages. Those surfaces may render +The canonical `KnownSourcePlatform` source-filter boundary applies to +infrastructure source UI options in Settings and platform/runtime pages. Those surfaces may render friendly string keys, but membership checks against available sources must normalize through the shared `frontend-modern/src/utils/sourcePlatforms.ts` helper before consulting `KnownSourcePlatform` sets. @@ -5813,8 +5815,8 @@ projection, so the AI summary and any other backend policy posture surface do not re-sort redaction labels locally. They also own the canonical sensitivity and routing order used to format policy-posture count summaries with human-readable labels, so the AI summary -and frontend policy card both read the same presentation sequence from the -shared resource model. +and any frontend posture presentation read the same presentation sequence +from the shared resource model. Canonical resources now carry first-class relationship, capability, and timeline fields: `Capabilities` (bounded action definitions with approval levels), `Relationships` (typed inter-resource links with direction and @@ -5838,6 +5840,132 @@ action requests, approvals, links or exclusions. Separate monitor, API and Assistant store handles must see current persisted aliases. Missing identity storage is an error, not evidence of empty history. Retention removes an alias only after neither identity has retained journal records. +Other alert source references resolve at `MonitorAdapter.RecordChange` +through `ResourceRegistry.resolveHistoryReference`, which accepts only durable +identities: the canonical ID, a retired era, a source ID (Proxmox node, guest +and storage IDs), a node-scoped Proxmox guest reference after migration, or +`agent:`. Names and hostnames never bind history, unlike the wider +`ResolveReferenceID` used by alert policy, so a resource named like a system +reference cannot capture its events. A reference two resources answer to binds +to neither: its event keeps its own reference and creates or changes no +binding, though an earlier binding still covers that reference on read. Like +any unbound reference it is retried, and once a later generation resolves it +to one resource it binds there, replacing an earlier binding. A +reference no resource +answers to follows its retained binding after inventory removal, or keeps its +own history. Docker container references never take this path. Alert journal references +without a binding (read once per process, plus any written before inventory +named their resource) are retried on each published registry generation for +`legacyHistoryBindWindow`. Bound rows keep their recorded resource ID, so a +change list may show the source reference on rows written before the binding. +Only the full-presentation `ResourceDetailDrawer` reads this history in the UI +(facets and resource intelligence); the `table-row` drawer used by Proxmox node +and Agents rows, and the guest and storage drawers, do not request it. +Assistant and Patrol resource contexts read it from the store. Proof: +`TestHistoryIdentityMonitorAdapterResolvesProxmoxAlertReferences` and +`TestHistoryIdentityBindsLegacyAlertRowsFromRegistryGenerations` in +`internal/unifiedresources/history_identity_test.go`. +Docker host alerts (`docker:`) and Swarm service alerts +(`docker:/service/`) resolve only through the registry's +Docker source identities: the host ID is the Docker host's source ID, and a +service reference names the service with that exact ID in the host's Swarm +cluster, whichever manager reported it. A hostname in the host position is +only ever looked up as a Docker host source ID. The registry keys a service +without an ID by its raw name, so the service entry must also carry exactly +the referenced ID. A container or service reported without an ID alerts under +its name, marked in the ID position (`docker:/name:` and +`docker:/service/name:`, from `alerts.DockerContainerResourceID` +and `alerts.DockerServiceResourceID`). Docker and Swarm IDs never contain a +colon, so such a reference never equals its host's reference or another +service's ID reference, and `dockerHostHistoryReference` treats a service slot +with a colon as a name. These name references, hostless `docker-service:` +references, container names and shortened container IDs never bind and are +never retried against later inventory. Rows journaled before name references +existed keep the reference they were written under: an ID-less container's +rows stay in its host's history, and an ID-less service's rows bind only to a +service in that cluster whose ID equals its normalized name. +Sub-resource alert references name no resource of their own and bind to the +owner whose durable ID they carry (`historySubResourceOwner`): ZFS pool and +device alerts (`/zfs-pool:[/device:]`) to the +storage, the Unraid array alert (`agent:/storage:unraid-array`) to +the array storage with that agent source ID, and host filesystem, SMART disk, +disk temperature, RAID and custom-sensor alerts (`agent:/disk:`, +`/disk_temp:`, `/raid:`, `/custom:`) to the host. The owner reference must +resolve to exactly one resource of the expected type; an ambiguous or +incompatible owner is a conflict, so the event keeps its own reference, +follows no retained binding and is retried. Pool, mount and kernel device labels are names, +and a device name can denote a different disk after a reboot, so these alerts +never bind a physical disk. Any other sub-resource shape stays unbound until +its producer and this list change together. The Docker, storage and platform +host-row drawers do not request per-resource history, so these bindings reach +the facets, timeline and intelligence APIs and Assistant resource context, not +a drawer. Patrol's scoped change feed matches the stored resource ID without +expanding bindings, so it includes events recorded under the owner's +canonical ID but not rows journaled before their binding. +Proof: `TestHistoryIdentityMonitorAdapterResolvesDockerHostAndServiceReferences` +and `TestHistoryIdentityMonitorAdapterResolvesSubResourceReferences` in +`internal/unifiedresources/history_identity_test.go`, and +`TestOwnerAlertTimelinesUseCanonicalHistoryIdentity` in +`internal/monitoring/monitor_alert_handling_test.go` against the real alert +producers. +PVE disk health and wearout alerts (`ProxmoxPhysicalDiskAlertResourceID`, +`::disk:`) never bind, although +`ResolveReferenceID` resolves the same reference to the physical disk at that +path for operator mutes (#2112). A device path is not hardware identity: a +replacement disk in the same slot, or a reboot that reorders devices, takes +over the path, and disk alerts are evaluated before their poll's disks reach +the registry. A binding belongs to the reference string and would carry every +row and every read of the path to whichever disk held it last. Each lifecycle +row is owned on its own instead (`proxmoxDiskAlertOwner` in +`internal/unifiedresources/pve_disk_alert_history.go`). The alert records the +evaluated disk's serial and WWN (`disk_serial`, `disk_wwn`), and +`MonitorAdapter.RecordChange` writes the row under the one physical disk the +registry knows by that hardware identity (`diskinventory.HardwareIdentityMatch`), +wherever it sits now. A differing WWN does not rule a disk out, because the +registry keeps a merged agent observation's WWN, which smartctl frames +differently from PVE; of two matching disks, a WWN only one of them shares +with the row decides. The registry only translates identity into a +canonical ID: the path never breaks a tie, so a generation that still places +another disk at the path never gains a row that disk does not match by +identity, and no check precedes a later write. With no such disk in inventory +(a new disk before its first poll reaches the registry, or a removed one), the +row takes the canonical ID the registry mints for an identity it +knows by nothing else (`MachineIdentityCanonicalID`), unless another resource +holds that ID. A row without usable identity names only the one identity-less +disk at its path. Every other row, missing, unusable or ambiguous, stays under +the reference and is never retried. An owned row records its alert's +reference as `alert_resource_id`. A read by the reference returns the rows +journaled under it plus the rows owned away from it, matched through the +indexed alert identifier (`ProxmoxPhysicalDiskAlertIdentifiers`) and that +recorded reference, and every count uses the same predicate. The Alerts +history Resource action therefore still lists every occurrence raised under +the path, across the disks that held it, and alert-centric reads keep their +occurrences (see the AI runtime contract's incident-history queries). Rows +journaled under the reference before ownership existed stay there, +unrewritten, until the journal's 30-day retention removes them. The disk +drawer requests no change history, so ownership reaches the facets, timeline +and intelligence APIs, Assistant resource context and Patrol's scoped change +feed. History follows inventory where inventory merges: the registry merges +disks that report the same serial, and a disk whose poll reports no serial +keeps the serial last seen at its path (`preserveUnavailablePhysicalDiskEvidence`). +A disk the registry correlates with its agent's observation by path (an +identity-less or SAS agent disk) keeps that observation's ID and its serial +and WWN. When the identifiers PVE records for it share nothing with the +agent's (a SAS address reported as the serial, with no common WWN), or the +alert fired before the PVE observation reached the registry, the row carries +the minted ID: the disk's own history omits it, and reads by the reference +still return it. A disk the registry keys by its agent's WWN alone also +carries the serial PVE reports, so another disk reporting that serial, before +its own first poll reaches the registry, has its row written under the first +disk. Proof: +`TestHistoryIdentityNeverBindsProxmoxDiskAlertReferences`, +`TestProxmoxDiskAlertRowsFollowRecordedHardwareIdentity`, +`TestProxmoxDiskAlertOwnerDecisions` and +`TestProxmoxDiskAlertReferenceReadUsesIndexes` in +`internal/unifiedresources/history_identity_test.go`, and +`TestProxmoxDiskAlertTimelineFollowsRecordedHardware` in +`internal/monitoring/monitor_alert_handling_test.go` against the real alert +manager. That same shared timeline vocabulary now includes the `activity` change kind for provider-read breadcrumbs such as VMware tasks and events, plus the `vmware_adapter` source-adapter token for canonical provenance drill-down. @@ -6033,6 +6161,65 @@ to that boolean. Identity succession continues to rekey the same state row. round-trip persistence, attention suppression, and the retired remediation lock. +### Status follows each source's own verdict + +A sighting's `SourceStatus.Status` describes delivery, not the resource: +`online` means the source delivered recently and `stale` that it has gone +quiet. Ingest records on each sighting the status that source reported for the +resource, as an unexported verdict, and `aggregateStatus` reads it. The stale +pass and manual links both apply that function: + +- While any source with a verdict is current, only the current sources decide, + in the priority order `chooseStatus` uses at merge time. The highest-priority + verdict wins (agent, then Proxmox or vSphere, then Docker, then the rest), and + the best of equal ones. A quiet source drops out of that decision. +- Once every source is quiet, an `offline` verdict survives and any other + verdict reads `warning`. The best of those wins. +- A current facet sighting carries no verdict (the PBS host-agent association, + an availability check projected onto its target). It counts as `online` only + when no current source has a verdict, and reads `warning` once quiet. + +The stale pass used to read delivery as the verdict. A Proxmox node the cluster +reported offline on a live poll came back `online` once its linked agent fell +silent, which is what a powered-off machine's agent does. The Proxmox nodes +table then put an amber dot beside its own "Offline" label, while the drawer +led with a critical "Node is offline" alert. A node `preserveOrExpireNodes` +expired after failed polls read `warning` ("Degraded"), and the stopped guests +on it counted as needing attention. A silent standalone agent past its +reporting lease reached every consumer as `warning`, with its last report +rendered as current readings in the Machines table. + +The push reporters keep their lease meaning. The host agent, Docker and +Kubernetes collectors mark the machine, Docker host or cluster offline only +once its reporting lease runs out. An agent past its lease is `offline` and +takes the existing offline treatment: frontend danger gates, the health +verdict's `offline` reason, offline filters and counts. One that is late but +inside its lease stays `warning`. A Docker host past its shorter lease stays +offline after its sighting crosses the 120-second stale threshold. A live +source still carries a merged row. A Proxmox node whose live poll reports it +online stays `online` when its linked agent stops reporting. A live agent keeps +a node `online` when the Proxmox poll reports it offline, which the Proxmox +nodes table reads with `connectionHealth: error` as a stale provider. When a +guest's or container's only source goes quiet, a running one stays `warning` +and a stopped one stays `offline`. + +The sighting still reads `stale`, so health keeps its `telemetry_stale` reason +and monitored-system reasons are unchanged. In-memory clones keep the verdicts, +including a facet's missing one. A serialized copy carries none, so +`IngestResources` gives each sighting of a copy that lost them all the stored +resource status; a merged row's separate source verdicts do not survive that +round trip. A manual link that joins two resources reported by one source keeps +the fresher sighting. `registry_merge_policy_test.go` pins the node, +poller-expired, guest, round-trip, copy, agent, Docker, Kubernetes, +mixed-source and manual-link cases, and the aggregation table. + +Availability checks still count by delivery. With every source that has a +verdict quiet, a current check keeps its target `online` even when the check +fails, as it did before. Several checks share one sighting whose freshness +comes from whichever check was projected last, so an availability verdict +needs each check's own freshness, including local checks that miss their +cadence, and has to survive manual links. That is left to a follow-up. + ### Canonical object drawer hierarchy The unified resource drawer is the shared operator-facing detail contract for diff --git a/frontend-modern/.bundlesize.json b/frontend-modern/.bundlesize.json index cea9cb20ad..67b9ad8ea6 100644 --- a/frontend-modern/.bundlesize.json +++ b/frontend-modern/.bundlesize.json @@ -7,8 +7,8 @@ "index": { "baselineGzip": 150212 }, - "sharedPlatformPage": { - "baselineGzip": 120645 + "MetricBar": { + "baselineGzip": 120887 }, "Alerts": { "baselineGzip": 120045 @@ -86,7 +86,7 @@ "baselineGzip": 8806 }, "discovery": { - "baselineGzip": 8522 + "baselineGzip": 9810 }, "useUnifiedResources": { "baselineGzip": 7595 diff --git a/frontend-modern/browser-verification/0a3f56b9810b4db5.json b/frontend-modern/browser-verification/0a3f56b9810b4db5.json new file mode 100644 index 0000000000..70d8f76336 --- /dev/null +++ b/frontend-modern/browser-verification/0a3f56b9810b4db5.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "base_sha": "9aaac4d3e502c01913fb3637d0997b0ce03a8b7b", + "verified_at": "2026-10-06T19:14:07Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", + "frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts", + "frontend-modern/src/utils/relativeTimeClock.ts" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "306a0f3a95585efd4d980549f33a41538a00975f62aff39873a75145e4d9bf5f", + "frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "e56e51e972457dbfe34f5695b69423f1ac916df3213a61d028e05cbcecfd12bd", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "33fd51603010d13040faa6fe6b3690d3e4bbe1c941c32ceb1aa0c972ec47370c", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "6780323e7d9055b0839da57b6204fd68bfce9696e622362ea34a02d0907554bd", + "frontend-modern/src/utils/relativeTimeClock.ts": "0ff094e2dc98bf07b3792af478bfa90d3156143ddc5aef0e233de80868ec4dba" + }, + "routes": [ + "/proxmox/replication", + "/proxmox/backups", + "/docker/overview", + "/standalone/machines" + ], + "viewports": [ + { + "width": 1600, + "height": 1000 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from this tree on origin/main 9aaac4d3e5; Playwright drove it with /api/replication/jobs answers shaped per scenario through page.route", + "Direct /proxmox/replication link whose first read returns 500, at 1600px and 390px: stays on the route with \"Could not load replication jobs\" and Refresh (main's code fell back to Overview with no error); Refresh loads all 8 jobs", + "Jobs loaded, then a poll read answers 403: the Replication tab is withdrawn and the link shows the server's \"Insufficient permissions to read replication jobs\" with Refresh (main kept the tab advertised and a generic message)", + "Empty first answer, then a failed poll, then an empty answer: the link stays on Overview throughout and the route never reopens", + "page.clock installed, a polled replication read held open: the next two 30s poll ticks start no new request and abort none; releasing it lets it finish and the following tick starts the next read (main started reads 3 and 4 over the held read and never cancelled it)", + "Leaving the Proxmox page for Docker while a replication read is held aborts it (net::ERR_ABORTED); main let it run", + "Regression: with /ws frames held and keep-alive pongs, Machines Seen cells move from \"just now\" to \"10m ago\" over ten one-minute clock steps on one socket", + "Proxmox Backups at 1600px and 390px after the clock change retired the band's Math.max: 36 age cells each, none Unknown, every band colour matching its Current/Aging/Stale title, no page errors", + "Same-session control: the five runtime files swapped back to main under the running Vite reproduced every failure above, then restored and compared byte for byte" + ], + "interactions": [ + "Clicked Refresh on the replication error state at both widths", + "Clicked the Docker nav link while a replication read was in flight", + "page.clock.runFor poll steps with data-free pong frames sent before each step" + ] +} diff --git a/frontend-modern/browser-verification/17aa647ac2cd3920.json b/frontend-modern/browser-verification/17aa647ac2cd3920.json new file mode 100644 index 0000000000..0938ce7330 --- /dev/null +++ b/frontend-modern/browser-verification/17aa647ac2cd3920.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "base_sha": "e25472feab5b9bcc8acdfea3f946c675475fe7cd", + "verified_at": "2026-10-06T19:31:23Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/types/api.ts" + ], + "content_sha256": { + "frontend-modern/src/types/api.ts": "f050360724c96235f134ed79aa96436a8c414a7d1f96722bb008efa141b2e0fb" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Merged types/api.ts is the union of this branch's Alert.metricStatus/MetricAlertStatus declarations and main's Alert.resolution/AlertResolution declarations; it emits no runtime code", + "vite build with main's types/api.ts and with the merged file produce byte-identical output (diff -rq over every emitted asset)", + "tsc --noEmit passes on the merged tree; alert presentation, guardrail, Proxmox nodes table, guest drawer, assistant handoff, storage headline and temperature suites pass (7 files, 300 tests)", + "Rendered surfaces were verified live earlier on this branch (receipts 4766668c29936861 and 68deecd720b4873b); no runtime bytes changed since" + ], + "interactions": [ + "Built frontend-modern with vite using main's and the merged types/api.ts and compared every output file", + "Ran tsc and the alert-related vitest suites on the merged tree" + ] +} diff --git a/frontend-modern/browser-verification/267c46b38a3f8020.json b/frontend-modern/browser-verification/267c46b38a3f8020.json new file mode 100644 index 0000000000..0ce88d034d --- /dev/null +++ b/frontend-modern/browser-verification/267c46b38a3f8020.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "base_sha": "dcd409f9da0a7a7f35a2a4ad5a4354e0b1d3ee44", + "verified_at": "2026-10-07T10:47:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx", + "frontend-modern/src/utils/resourcePolicyPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx": "deleted", + "frontend-modern/src/utils/resourcePolicyPresentation.ts": "6737afe76a7c7cc10e8d57e133c4a1e08855bf01e762677bba871e91856b7535" + }, + "routes": [ + "/settings/security-data-handling", + "/patrol", + "/truenas/storage" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Embedded production build of this tree (base dcd409f9da, PR 2649 head) served by a private PULSE_MOCK_MODE backend on :7687 with admin bypass and telemetry off; /api/resources/stats posture: 1773 resources, all four sensitivities, all three routing scopes and all five redaction hints", + "Production vite build of this tree is byte-identical to the build of the base commit (same asset names, diff -r clean), so the shipped bundle is unchanged; check-bundle-size.mjs passes with no .bundlesize.json change", + "Settings Resource Privacy (Data Handling) panel, the live consumer of the util's labels and orderings: sensitivity Public/Internal/Sensitive/Restricted, routing Cloud Summary/Local First/Local Only and redaction Hostname/IP Address/Platform ID/Alias/Path render in canonical order with the mock counts (1,773 governed)", + "Patrol page loads and contains no posture card text (Data Governance, governed resources, policy-covered resources)", + "TrueNAS Storage, pool archive (sensitive, local-first) resource drawer: Platform details expanded, Context shown, Governance rows read Sensitivity Sensitive, Routing Local First, Redactions 3, Redaction labels Hostname/Platform ID/Alias plus the governed Safe Summary", + "No page errors; console showed only CSP inline-style refusals that predate this change (the base lacks PR 2642, which moved those static styles to classes on main) and that occur identically on the byte-identical base build" + ], + "interactions": [ + "Direct navigation to /settings/security-data-handling and /patrol at each viewport", + "On /truenas/storage clicked the archive pool row to open the resource drawer, expanded Platform details via its summary, then clicked Show context", + "Repeated the full pass at 1440x900 and 390x844 with Playwright (headless Chromium)" + ] +} diff --git a/frontend-modern/browser-verification/278265400aa5afa5.json b/frontend-modern/browser-verification/278265400aa5afa5.json new file mode 100644 index 0000000000..346b72a358 --- /dev/null +++ b/frontend-modern/browser-verification/278265400aa5afa5.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "base_sha": "8ad0cbbe260f032868b7fdd2132d8c71f804de2c", + "verified_at": "2026-10-07T10:19:14Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts", + "frontend-modern/src/features/alerts/useAlertOverviewState.ts", + "frontend-modern/src/stores/websocket.ts" + ], + "content_sha256": { + "frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts": "9fac405c3a458892dba9d90398b41ccf5165b5882b7d64ecf6b0864c93669698", + "frontend-modern/src/features/alerts/useAlertOverviewState.ts": "f291f66349571e09a70f2061b9503feae62a5c290b7e6a30f02389cdfdf3b60a", + "frontend-modern/src/stores/websocket.ts": "54fdd1c3048ca7bf81f2297c5e05e6c4c465936cbb15fd561e39e57a32ee2888" + }, + "routes": [ + "/alerts/overview" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack built from this tree (backend rebuilt at the base commit, Vite serving the edited sources): Alerts > Overview with 13 active mock alerts, one critical Docker service alert (ops-prometheus) and warnings, including the Acknowledged section and the Unacknowledge control on an acknowledged card.", + "Before the change (origin/main bea0cec8b9, same stack): after a local Acknowledge then a curl unacknowledge, /api/alerts/active reported acknowledged:false while the overview kept the card hidden and read '2 critical, Acknowledged 1' against a nav badge of 3 critical, until reload.", + "After the change at 1280x900 and 375x812: the same card returns to the active list as the first card within 2 seconds of the remote unacknowledge, the header counts agree with the nav badge, and no toast fires.", + "Socket-beats-response race (acknowledge response held 1.5s in the page so the broadcast lands first, then a server-side unacknowledge 200ms after it resolves): the card is back at once and no 'Server did not confirm' toast or REST re-fetch occurs over 18s. With only websocket.ts reverted to main, the card stayed hidden about 16s and then that toast fired.", + "Only console error on the final load is the existing 403 of the acknowledge request's first attempt, which the API client retries with a fresh CSRF token (same on main)." + ], + "interactions": [ + "Clicked Acknowledge on the critical card, then POST /api/alerts/unacknowledge for that alert from curl (another session); repeated at both viewports.", + "Restore direction: curl acknowledge of a warning alert, clicked Unacknowledge in the tab (card returns to active, 'Alert restored' toast), then curl acknowledge again; the card moved back to Acknowledged within 2 seconds.", + "Race run driven from the page with a fetch wrapper delaying the acknowledge response and AlertsAPI.unacknowledge sent after it, watching toasts and /api/alerts/active requests." + ] +} diff --git a/frontend-modern/browser-verification/34bdf8cf77873482.json b/frontend-modern/browser-verification/34bdf8cf77873482.json new file mode 100644 index 0000000000..7879430f2b --- /dev/null +++ b/frontend-modern/browser-verification/34bdf8cf77873482.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "base_sha": "a21e0ef8f819c404338661f76b82b5bea09aedfd", + "verified_at": "2026-10-07T09:20:59Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/components/shared/cards/DisksCard.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "d1c98c016ddceb095a429680939398c15dfaf1e0c9521fd6984f433f76f04cf6", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "6ff3a895c4ba3c0c758bdb7e5c4f1b512f3ae3e2de1b8d03b7f03d4a7f249d51", + "frontend-modern/src/components/shared/cards/DisksCard.tsx": "2b80761565c92409919fe18f0fdf4229abbb397114deca69ec7cfcdb1dec88f1" + }, + "routes": [ + "/standalone/machines" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from a21e0ef8f8 with a local-only go build -overlay of internal/mock/generator.go that holds host-linux-1 (Apollo-114) offline past its lease: LastSeen frozen 14 minutes back, readings retained, root mount filled to 93% and a long /var/lib/docker/volumes/postgres-primary-data mount at 88%. /api/resources reports status offline with agent.stale true", + "Baseline on main (bea0cec8b9, before the rebase): the Machines row blanks every reading and Thermals reads 45°C (last known), but the Disks card shows / at 93% in red with a red aggregate StackedDiskBar and green/red per-mount bars, as if current", + "Patched, silent host: Total Usage reads Last known 75% · 2.29 TB / 3.07 TB, each mount reads Last known N% · used / total, all in text-muted (rgb 71,85,105 light, 148,163,184 dark) with title Last known reading, not current: host agent stopped reporting; no StackedDiskBar and no per-mount bars render", + "Patched, live control host Spectrum-709: unchanged, aggregate bar amber, /backup 80% amber text and bar, other mounts green bars, no Last known text", + "390 wide: before the wrap fix the long mountpoint label kept its full width and squeezed the value into a sliver (Last known / 88% over three lines, byte figures clipped by the card); with the mount row wrapping, the long label takes its own line and the full value sits below it, short mounts stay on one line, no horizontal page scroll at either width", + "Dark scheme at both widths for the silent host: muted values readable, no threshold colour", + "Re-run on the rebased tree after #2607 and #2637 landed; same readings at both widths" + ], + "interactions": [ + "Open /standalone/machines, click the Apollo-114 row (phone: tap its second visible cell) to open the drawer", + "Expand Platform details, then Machine Show details, scroll to the Disks card and read text, computed colours, titles and bar fills", + "Repeat for the live Spectrum-709 row as a control" + ] +} diff --git a/frontend-modern/browser-verification/3b34c585696e9d41.json b/frontend-modern/browser-verification/3b34c585696e9d41.json new file mode 100644 index 0000000000..f9cb8ca29a --- /dev/null +++ b/frontend-modern/browser-verification/3b34c585696e9d41.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "base_sha": "723f3165a1292268634b6054ab93d3015805d990", + "verified_at": "2026-10-06T23:14:41Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/features/docker/DockerHostsTable.tsx", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "f141220216a968f5953b7646035108f015a9f7de3c5ae5a9e3251ec6e7046fa5", + "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts": "07b26eafecbd1ec93fb1dd90d4af6629778cef59a2d9e7450426cb88c90bdfdb", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "70955e370c5b2611557a5de8ba4df44b8f6dce47c1b08c611a154d232c786501", + "frontend-modern/src/features/docker/DockerHostsTable.tsx": "450cce7a4723ce7fe7b01ffe9d10c7d6439c34a1a6dda4fc0af6689ceb4741b5", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "8d63a04f6a475f39de90cb9813b8bd4315525bf63318cfaf958ac195ae07950f" + }, + "routes": [ + "/standalone/machines", + "/docker" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Two private mock stacks (8-node estate) built from this tree: one with this commit's registry.go/types.go, one with origin/main's, both with a local-only go build -overlay of internal/mock/generator.go that holds standalone Linux agent host-linux-1 (Apollo-114) and Docker host edge-proxy-01 past their reporting leases the way evaluateHostAgents/evaluateDockerAgents leave them: status offline, LastSeen frozen 14m/9m before the first tick, last readings kept. Mock mode skips the host health sweep, hence the overlay.", + "Before (origin/main backend and frontend): /api/resources served Apollo-114 as status warning with agent.stale true and edge-proxy-01 as warning with health stale. The Machines row showed an amber dot, CPU 32%, Memory 74%, Disk 80%, Net and Disk I/O rates, Up 29d and Temp 45\u00b0C in threshold green beside 'Seen 14m ago', while the drawer led with the critical 'Host Apollo-114 is offline' alert and read 'Observed state Degraded, Uptime 29d 12h'.", + "After (this commit): both resources serve status offline, the agent/docker sightings still read stale, and health reads critical with reasons offline then telemetry_stale. The Apollo-114 row shows a red dot and '\u2014' in CPU, Memory, Disk, Net I/O, Disk I/O, Up and Temp at 1440 and 390; the status filter counts it under Offline. Spectrum-709 (reporting) keeps every value, Temp 47\u00b0C included.", + "Machine drawer at 1440 and 390: Runtime context reads 'Observed state Offline' and 'Last seen N mins ago' with no Uptime row; Platform details > Machine 'Show details' drops the System card's Uptime and the Thermals card reads 'Cpu.package 45\u00b0C (last known)' with title 'Last known reading, not current: host agent stopped reporting'.", + "Docker page at 1440 and 390: edge-proxy-01 shows a red dot, '\u2014' for CPU, Memory, Disk and Temp (the hold seeds a retained 51\u00b0C), and the existing 'No report for 16m' marker in Uptime; Edge Apps 01 keeps its values, Temp 38.5\u00b0C included.", + "Bundle check: WorkloadsSurface unchanged (70.20 kB), Standalone +0.04 kB, Docker +0.01 kB, InfrastructureWorkspace flat versus this tree without the frontend diff; all chunks within budget.", + "Not covered: a real agent going silent on hardware, light/dark differences (no new colour classes), and the drawer Disks card, which still threshold-colours a silent agent's retained filesystem usage (left for a follow-up)." + ], + "interactions": [ + "Enabled the Network, Disk I/O, Uptime and Temp columns through the Machines column preferences (localStorage init) and read every cell of the offline and reporting rows plus the status dot class at 1440 and 390.", + "Opened the Apollo-114 drawer by row click at both widths, read the Runtime context rows, expanded Platform details and the Machine 'Show details', and read the System and Thermals cards with their value titles.", + "Opened /docker at both widths and read every cell of edge-proxy-01 and Edge Apps 01.", + "Queried /api/resources on both stacks for status, per-source sighting status and health reasons of the held agent and Docker host." + ] +} diff --git a/frontend-modern/browser-verification/3b5ee6cfe34d3e8c.json b/frontend-modern/browser-verification/3b5ee6cfe34d3e8c.json new file mode 100644 index 0000000000..ffc8d4beb4 --- /dev/null +++ b/frontend-modern/browser-verification/3b5ee6cfe34d3e8c.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "base_sha": "8424fd65057af75e1ea3c1fc9fe29528d7c0d68e", + "verified_at": "2026-10-06T21:24:22Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/stores/websocket.ts" + ], + "content_sha256": { + "frontend-modern/src/stores/websocket.ts": "1be5c0f23d3dbaf85a32f7b8b3906c2cb25b20bb02412c31c200ffbad9c6c2d5" + }, + "routes": [ + "/proxmox/overview", + "/alerts/overview" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack (backend + Vite from this tree) with a local-only overlay pinning node standalone2's evaluated CPU temperature and letting mock mode restore persisted alerts across restarts", + "standalone2 temperature alert latched below trigger (breach 95, live 78): node drawer Needs attention reads 'Temperature 78\u00b0C now, back under the 80\u00b0C alert level' and the node table temperature cell title carries the same summary", + "Backend restarted with the reading missing: /api/alerts/active returns the restored alert without metricStatus; the open tab reconnects and the drawer and cell title fall back to 'Node temperature at 95.0\u00b0C' within seconds", + "Reading returns: the next keyed delta adds metricStatus and the drawer reads '78\u00b0C now' again", + "Control on the unchanged store, same run: after the restart the open tab kept 'Temperature 78\u00b0C now' in the drawer and cell title for the full two-minute window", + "Alerts overview card snoozed ('Snoozed until ...', Resume shown), resumed, and acknowledged from the UI with no page errors" + ], + "interactions": [ + "Expanded the standalone2 row details in the Proxmox nodes table (desktop) and after Show all nodes (narrow)", + "Restarted the private backend while the tab stayed open, then changed the pinned reading back to 78", + "Clicked Snooze and the first preset, then Resume, then Acknowledge on the alert card", + "Acknowledged and unacknowledged through the API from another session with the tab open (card left and returned within 2s)" + ] +} diff --git a/frontend-modern/browser-verification/3cc753f21d394de0.json b/frontend-modern/browser-verification/3cc753f21d394de0.json new file mode 100644 index 0000000000..2c2808e61e --- /dev/null +++ b/frontend-modern/browser-verification/3cc753f21d394de0.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "d26a4837315b40b47a73dbf1ff1cb4c8d6075225", + "verified_at": "2026-10-06T15:52:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts", + "frontend-modern/src/utils/resourceChangePresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx": "a7d8728f72de5b9acd73832064f037af97978b78859cb2c69a01e25f0750e6d5", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "f0f868d614ef70165b77e80f220cc5927936bdfcfa4eff3a8f18b728acaf5b52", + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "e172bfead859ece6f7f9998dcd765c6f348bbfc25214089d6ba1a150f30d6fec", + "frontend-modern/src/utils/resourceChangePresentation.ts": "5a8d67301975d89545c866116e1e0e7e633881b3b3ce4da37b825a46cf65265d" + }, + "routes": [ + "/proxmox/overview?agent=Production+West-pve1", + "/standalone/availability?view=fleet" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack (backend :7741 from this branch plus a local-only go build -overlay hook, Vite :5241 from this clone): the real alerts manager raised pve1 node memory and CPU alerts, the hook closed the memory alert through clearAlertWithResolution with a moved_to_agent resolution (successor pve1 (Host Agent)), and threshold restore recovered CPU normally; the monitor recorded both alert_resolved changes, the moved one with the engine summary as reason and alert_resolution metadata", + "Proxmox overview, West Production A inline drawer (table-row presentation): Overview and History tabs render, no change history section, no facets request; table-row drawers skip remote history by design and node alert changes are stored under the legacy node ID mock-cluster-1-pve1, so the move cannot appear in a node drawer today", + "Availability checks Fleet view, Rack UPS network card full drawer, with the two producer-made alert_resolved rows (plus their alert_fired rows) copied from mock-cluster-1-pve1 to this endpoint's canonical ID in unified_resources.db as a fixture: at 1280 the change history lists 'Alert moved' over 'Alert moved to pve1 (Host Agent). This is not a recovery: check the agent for the current reading.' and the ordinary close still reads 'Alert resolved' over 'Alert resolved: Node cpu 5-minute average at 33.7% (current 33.7%)'", + "Same drawer at 375: both entries wrap inside the card, the moved entry is titled Alert moved, no horizontal page scroll; facet chips still read 'Alert resolved 2' because kind counts carry no metadata (documented in unified-resources)", + "Console: only the known private-port websocket rejection (ws://127.0.0.1:5241/ws, ALLOWED_ORIGINS lists 5173/7655), no errors from the drawer" + ], + "interactions": [ + "Expanded West Production A on /proxmox/overview, opened its History tab and checked the DOM and network log for a change history section and a facets request", + "Switched Availability checks to the Fleet view through View preferences and opened the Rack UPS network card tile", + "Scrolled the change history to the Alert moved entry at 1280x900 and read every entry title and reason", + "Reloaded at 375x812, reopened the tile, read the entries and compared document width with the viewport" + ] +} diff --git a/frontend-modern/browser-verification/4766668c29936861.json b/frontend-modern/browser-verification/4766668c29936861.json new file mode 100644 index 0000000000..af31ef6f50 --- /dev/null +++ b/frontend-modern/browser-verification/4766668c29936861.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "base_sha": "92d946d057ddeefcae4d247a87e88f2245abca4a", + "verified_at": "2026-10-06T12:36:53Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts", + "frontend-modern/src/components/shared/DrawerAttentionSection.tsx", + "frontend-modern/src/components/shared/TemperatureGauge.tsx", + "frontend-modern/src/features/alerts/metricAlertPresentation.ts", + "frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", + "frontend-modern/src/features/storageBackups/storageRowAlertPresentation.ts", + "frontend-modern/src/types/api.ts", + "frontend-modern/src/utils/temperature.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts": "3c6f6f01df4a13b9fb3084b398be093473b63cdb71bbc01db1dbf41531e15edd", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "667a9b9e6fd4385a23b950f18be16fb71cbd6eccc2a4aafb57d2c4532b3af6f1", + "frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "5adf11bfc948cae2cca66fde5e2132c66bbb01388efdbc23cbf022b449ae3b0c", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "a922c4c26813a676be12e06376bdb40df2aca8bfe5fc850b76009a504dada663", + "frontend-modern/src/components/shared/DrawerAttentionSection.tsx": "2a4e15e781a6158e253b6b934198d7aed71275416eb12c07ade68d5fa80c0332", + "frontend-modern/src/components/shared/TemperatureGauge.tsx": "612172a594698216b8a5fe247e5fa7361946056e02eaba9e71cfe827d0dc5023", + "frontend-modern/src/features/alerts/metricAlertPresentation.ts": "2c875f102207c36d21d2868fa6bf05be7a5ae3e1cfb0351f54c2e89d448259bc", + "frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx": "c979737f3825f12da80b25f50083b15960f7217525905c01d27edc4b26e1fbc3", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "b3ebf1259281b7d590c4cbd0eee9ed54ad060ed0e6fd1d80f740d44c95cd3107", + "frontend-modern/src/features/storageBackups/storageRowAlertPresentation.ts": "ad5c973c1fbe64a94ad98d8f89d2ac3e51dacad1b1d06af82f77d0d32462ea16", + "frontend-modern/src/types/api.ts": "0db85005fb024e72434e35fdb7eb9ce13d70361569563579365a877fac714f79", + "frontend-modern/src/utils/temperature.ts": "3381a7948f19ae52bf0c72d6fc011ded3691aeac9fdbc9d795dc298af68c4690" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack built from this change (sidecar backend on 7721, Vite on 5241); pve2's CPU temperature driven through 88, 78 and 72 degrees C by a local-only, uncommitted mock overlay", + "Breaching (88C, trigger 80/clear 75): node drawer Needs attention reads 'Temperature 88°C, above the 80°C alert level' with 'Clears once it drops to 75°C or lower and stays there for 5 minutes.'", + "Held (78C): drawer reads 'Temperature 78°C now, back under the 80°C alert level' / 'Stays open until it reaches 75°C or lower and stays there for 5 minutes.'; legacy alert message still 'Node temperature at 88.0°C' in the API; TEMP cell amber with the same explanation as hover text", + "Recovering (72C, below the 75C warning colour): drawer reads 'Temperature 72°C now, recovering' / 'Clears after 5 minutes at 75°C or lower, 1 minute so far.'; TEMP cell 72°C stays amber (text-amber-600) while 68°C on the next node is green", + "Resolved after 5 minutes at 72C: alert closed with 'Resolved: Node temperature at 72.0°C', TEMP cell green with no hover text, Needs attention section gone", + "Narrow 375px: drawer attention copy wraps inside the card, TEMP column hidden as before", + "Guest drawer, Mail Gateway drawer, storage headline and Assistant handoff copy covered by component and model tests, not exercised live" + ], + "interactions": [ + "Expanded the West Production B node row to open its inline drawer at desktop and phone widths", + "Read the TEMP cell colour class and title text through the DOM in each phase", + "Reloaded the page mid-recovery to confirm the cell and drawer render from fresh state", + "Resized between 1440x900 and 375x812" + ] +} diff --git a/frontend-modern/browser-verification/47c60098fc12bf96.json b/frontend-modern/browser-verification/47c60098fc12bf96.json new file mode 100644 index 0000000000..eb0d451e5a --- /dev/null +++ b/frontend-modern/browser-verification/47c60098fc12bf96.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "base_sha": "dcb69cfb020193d05d9e4e668d19b820a0c46d31", + "verified_at": "2026-10-06T22:07:56Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", + "frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx", + "frontend-modern/src/features/standalone/agentMachineTableModel.ts", + "frontend-modern/src/features/storageBackups/diskPresentation.ts", + "frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts", + "frontend-modern/src/types/api.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts": "6cdf797534d6ecba8f8007561195f9245899ff881a225f83c2c6c6293b557f8c", + "frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx": "98e9dc568360b5b6c9552433207e0e2fa78b91ba17da53fa513fb42484373ff5", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "a1dfacc661118c77fea482d26ffbc6d1c6aca0d351d77744f7a2dbbd4770ef6f", + "frontend-modern/src/features/standalone/agentMachineTableModel.ts": "1f9be08ed7100a330142eab8900b04474360785e75c1b95a8a42d4bc35fa7233", + "frontend-modern/src/features/storageBackups/diskPresentation.ts": "debb0d30e95b84d010d5b7478d61ce5fa9487c0bfb43b3eb812e16b4a75acf2e", + "frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts": "c62e4d1e74b9278ef34d93e8101c3d41ab67b17885b6810116a2f96bd112301c", + "frontend-modern/src/types/api.ts": "bb2712595745499cc51ed5c6e54ea24998e00d42ce45c8715b1d6c3eed6afc3e" + }, + "routes": [ + "/standalone", + "/proxmox?q=analytics-batch-01" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from this tree (base dcb69cfb02) plus a local-only go build -overlay of internal/mock/generator.go: three standalone Linux agents get SMART rows; Apollo-114 (CPU sensors) and Meridian-731 (SMART only) are stamped as State.ExpireHostTelemetry does (status offline, temperatures kept, collection.temperature unavailable 'host agent stopped reporting', source kept), Europa-405 keeps reporting. /api/resources returned agent.sensors.smart[].collection end to end, status warning and agent.stale true for the silent agents.", + "Same-session control with the HEAD runtime files under the same Vite: Meridian-731's Temp cell rendered 52°C in threshold green, the tooltip and the drawer Thermals card listed the retained disks as plain current readings, and the guest Physical Disks card showed plain muted 52°C/41°C with no last-known cue.", + "After: Meridian-731's Temp cell renders '52°C' muted (slate-600 light, slate-400 dark) with a dotted underline, no threshold colour and screen-reader ', last known'; its tooltip lists both disks '(last known)' muted. Apollo-114 keeps its CPU package value (no collection state) while its disk tooltip rows read '(last known)'. Europa-405 is unchanged.", + "Machine drawer (Platform details > Machine details > Thermals) at desktop and phone: 'Disk nvme0n1 52°C (last known)' and 'Disk sda 41°C (last known)' with title 'Last known reading, not current: host agent stopped reporting'; cpu.package unchanged.", + "Guest drawer Physical Disks card at desktop and phone: Playwright routes add the agent version a VM-resident agent would carry to analytics-batch-01 and answer its physical_disk child query with Meridian-731's real registry disks (collection state untouched) re-parented to the VM, because mock mode never links an agent inside a VM. Both disks render muted, dotted, with the reason title and ', last known'.", + "Light and dark schemes on the Machines page. Bundle check: Standalone +0.22 kB, shared chunk +0.20 kB, WorkloadsSurface and InfrastructureWorkspace flat versus a same-machine main build, no tracked chunk renamed.", + "Not covered: a real host agent going silent on hardware, an agent genuinely running inside a VM, or stale-row CPU/memory/disk cells (flagged separately)." + ], + "interactions": [ + "Enabled the Temp column from View > column picker on /standalone and read every row's cell text, class, computed colour and text decoration.", + "Hovered the Temp cell of the SMART-only and the CPU-sensor silent agents and read the tooltip rows and their muted state.", + "Opened the Apollo-114 drawer by row click (desktop) and CPU cell tap (phone), expanded Platform details and the Machine Show details, and read the Thermals rows with their titles.", + "Opened the analytics-batch-01 guest drawer from the Proxmox guest table at desktop and phone and read the Physical Disks card summaries, data-temperature-reading, titles and decoration.", + "Swapped the runtime files to HEAD under the running Vite for the same-session control, then restored and re-ran every pass on the final tree." + ] +} diff --git a/frontend-modern/browser-verification/4e061179721ef90a.json b/frontend-modern/browser-verification/4e061179721ef90a.json new file mode 100644 index 0000000000..2c87631f4c --- /dev/null +++ b/frontend-modern/browser-verification/4e061179721ef90a.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "base_sha": "1fe9f86d55271345492945a25ab9398892ec6bf0", + "verified_at": "2026-10-06T22:35:29Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts": "1879e91c0eb901261d6497cc0245e2a72b8d0acc72414cd69d3369d187e59fc5", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "0f710d2544c964ae36e04f5bdb1bb2e3f2db37deb9b1ad804df6909e22256fc9", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "32a8c017790a55dc230585e189bbff5cd3e81ee932a80c4379fc1be3ea61172d" + }, + "routes": ["/proxmox/overview", "/vmware"], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Vite dev server serving this tree (based on PR 2622 head 1fe9f86d55) against a private DEMO_MODE mock backend (50 PVE nodes, 929 guests, 7 ESXi hosts, telemetry off), headless Chromium via Playwright; desktop 1440x900 and phone 390x844.", + "Trends mode, 95 s per page counted per 30 s poll tick, before (base files swapped back under the same server) vs after: /vmware went from 1 /api/charts/infrastructure + 1 /api/charts/workloads read per tick to 0 + 1; /proxmox/overview from 2 + 2 to 1 + 1. Same on both viewports (95 s totals: vmware 4+4 to 0+4, proxmox 8+8 to 4+4).", + "Proxmox nodes table in Trends: every rendered node row draws its CPU, memory and disk sparklines (36 of 36 on desktop, 18 of 18 on phone), same as before; guest table sparklines unchanged (Proxmox 420 desktop / 108 phone, vSphere 54).", + "Bars mode on /proxmox/overview: node table shows no sparklines and the page makes no /api/charts reads.", + "Screenshots of both tables in Trends inspected at both viewports. The only failed request is the read-only demo refusing POST /api/usage/workload-history (403), the Trends activity ping, which this change does not touch." + ], + "interactions": [ + "Desktop: opened the View popover and switched Bars to Trends; phone: opened Filters, then View, and switched Bars to Trends. The Trends button reports aria-pressed=true and the switch triggers exactly one workloads read and one infrastructure read.", + "Navigated between /vmware and /proxmox/overview with Trends persisted and watched three poll cycles on each page.", + "Scrolled the nodes table into view before each capture." + ] +} diff --git a/frontend-modern/browser-verification/541999f4ef0c4619.json b/frontend-modern/browser-verification/541999f4ef0c4619.json new file mode 100644 index 0000000000..c648f4ae89 --- /dev/null +++ b/frontend-modern/browser-verification/541999f4ef0c4619.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "base_sha": "3b290640a51ef0f488f9cd06b4aa644adbdcc6b3", + "verified_at": "2026-10-06T15:25:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/utils/resourceStateAdapters.ts" + ], + "content_sha256": { + "frontend-modern/src/utils/resourceStateAdapters.ts": "5b170b1c0d735afc584b0784403cd3510a8ab12aeb3e387e9baec6b80fcb5853" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from this tree (backend :7681, Vite :5199); /api/resources confirmed the real node payload shape: proxmox.temperature is the scalar maximum and proxmox.temperatureDetails the full reading", + "Proxmox node Analytics A (pve31) as a PVE-only node (agent facet removed from REST and websocket payloads), main's adapter swapped in: drawer Thermals showed CPU low 82\u00b0C and CPU record 82\u00b0C, the current reading repeated as history", + "Same PVE-only node with this change and no low/record in temperatureDetails: CPU low and CPU record rows absent, Temperature and NVMe still shown", + "Same PVE-only node with this change and temperatureDetails carrying cpuMin 38.5 and cpuMaxRecord 91.2: drawer showed CPU low 39\u00b0C and CPU record 91\u00b0C at 1440x900 and 390x844", + "Agent-linked mock node (agent facet present) with this change, with and without an injected record: drawer identical to main (Temperature from the agent facet, no CPU low/record rows), table TEMP column unchanged at 82\u00b0C" + ], + "interactions": [ + "Opened the node drawer from the row's detail toggle (aria-controls proxmox-host-drawer-) on /proxmox at desktop and phone widths", + "Read the Thermals card rows and the row's TEMP cell, and took screenshots, for main's adapter and for this change, via Playwright with REST and websocket payload rewriting" + ] +} diff --git a/frontend-modern/browser-verification/57680ea92a178c44.json b/frontend-modern/browser-verification/57680ea92a178c44.json new file mode 100644 index 0000000000..30970a532a --- /dev/null +++ b/frontend-modern/browser-verification/57680ea92a178c44.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "base_sha": "50b3fd822697e60a9675374e7b17de306da4f625", + "verified_at": "2026-10-06T19:11:04Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/types/api.ts" + ], + "content_sha256": { + "frontend-modern/src/types/api.ts": "e91be59f68e507aa1403e564ff1f9d25e2701cf68173911cf660030e6463323a" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Type-only change: MetricAlertStatus and the Alert.metricStatus field moved to different positions in types/api.ts so they no longer abut main's Alert.resolution insertion", + "vite build of the tree before and after this change produces byte-identical output (diff -rq over every emitted asset), so no rendered surface can differ", + "The rendered surfaces themselves were verified live earlier on this branch (receipts 4766668c29936861 and 68deecd720b4873b): /proxmox node drawer and TEMP cell through breaching, held, recovering and resolved at 1440x900 and 375x812" + ], + "interactions": [ + "Built frontend-modern with vite before and after the move and compared every output file", + "Relies on the earlier live passes for drawer expansion, TEMP cell hover text and narrow layout; no runtime bytes changed since" + ] +} diff --git a/frontend-modern/browser-verification/68deecd720b4873b.json b/frontend-modern/browser-verification/68deecd720b4873b.json new file mode 100644 index 0000000000..cdebbcffa6 --- /dev/null +++ b/frontend-modern/browser-verification/68deecd720b4873b.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "base_sha": "08e785eb8d475a6906bc5135d6379c0ced78054b", + "verified_at": "2026-10-06T13:24:56Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/utils/temperature.ts" + ], + "content_sha256": { + "frontend-modern/src/utils/temperature.ts": "91d17ef07ce5cda8128f1b4bf65feecfd9101526c8ff7900ab9edc0431133838" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack built from this commit; pve2's CPU temperature pinned to 88 then 72 degrees C by a local-only, uncommitted mock overlay (trigger 80, clear 75)", + "Recovering at 72C with the alert still open: pve2 TEMP cell stays text-amber-600 with hover 'Temperature 72°C now, recovering. Clears after 5 minutes at 75°C or lower, 20 seconds so far.'", + "Nodes without an alert (51, 53, 42C) render text-green-600 with no hover text, unchanged", + "Narrow 375px: node table renders without horizontal overflow; TEMP column hidden as before" + ], + "interactions": [ + "Loaded /proxmox at 1440x900 and read each TEMP cell's colour class and title through the DOM", + "Resized to 375x812 and checked the node rows and page overflow" + ] +} diff --git a/frontend-modern/browser-verification/758bbf731f948f33.json b/frontend-modern/browser-verification/758bbf731f948f33.json new file mode 100644 index 0000000000..0aa4ba26b4 --- /dev/null +++ b/frontend-modern/browser-verification/758bbf731f948f33.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "base_sha": "0fb5ccb62b2c8b07637c09547cc7e397e82925a7", + "verified_at": "2026-10-06T21:51:39Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "382e16198535227445f6cf823a1390502bcb24409ed2f12e19e028fa325793bf" + }, + "routes": [ + "/proxmox", + "/vmware" + ], + "viewports": [ + { + "width": 1440, + "height": 1000 + }, + { + "width": 1280, + "height": 1000 + }, + { + "width": 1600, + "height": 1000 + }, + { + "width": 768, + "height": 1000 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (backend + Vite on 7797/5307) built from main 0fb5ccb62b (including the Net and Disk I/O row history lens) plus this change, re-run after rebasing from 3ac5664d95, compared with the same stack on unchanged main; large mock estate (50 nodes, 10 VMs + 8 LXCs each) so the guest list matches the reported repro", + "Fresh browser context so the first-use 'Hover a guest to preview history' hint is unseen; bars display mode with history row hover (the defaults)", + "Before, /proxmox at 1440 and 1280 with the page unscrolled: the hint wraps the filter action row onto its own line (filter bar 138px); after hovering artifact-cache-310 (675) the hint unmounted, the bar shrank to 102px, the row moved up 36px, pointer events went enter 675, leave 675, enter 679, and the history lens ended on auth-service-314 (679)", + "Before, /proxmox at 1600: hint inline on one row, bar 102px both ways, no shift", + "After, /proxmox at 1024, 1280, 1366, 1440, 1600 and 1920, both unscrolled and with a mid-list row scrolled to the centre: row y shift 0, filter bar height unchanged (138 or 102), a single pointerenter on the hovered row, and data-history-lens-active only on the hovered guest; the hint is still mounted with visibility hidden", + "After, /vmware at 1024 (wrapped, 138px) and 1440 (inline): row shift 0 and the lens stays on the hovered VM", + "After, 768: the hint span is display none so it has no box either way; bar 174px unchanged and the lens stays on the hovered row. 390: the mobile toolbar never renders the hint (54px before and after reload)", + "After the first preview, a reload shows no hint and the compact one-line toolbar (102px at 1440)", + "After the first preview, switching View > Metrics to Trends removes the hidden span instead of leaving a gap, and switching back to Bars does not bring it back" + ], + "interactions": [ + "Moved the real mouse onto a guest row's name cell (clear of the expand chevron) and waited for the populated history preview, recording the row bounding box, filter bar height, scroll position, pointerenter/pointerleave events and [data-history-lens-active] rows before and after", + "Repeated the hover with the page unscrolled and with the target row scrolled to the viewport centre", + "Reloaded the page after the first preview to check the next-visit toolbar", + "Opened View and switched Metrics between Trends and Bars after the hint had cleared", + "Captured screenshots of the toolbar and first rows before and after hover on main and on this change" + ] +} diff --git a/frontend-modern/browser-verification/811c71b54c36777b.json b/frontend-modern/browser-verification/811c71b54c36777b.json new file mode 100644 index 0000000000..cfb96ffcd7 --- /dev/null +++ b/frontend-modern/browser-verification/811c71b54c36777b.json @@ -0,0 +1,61 @@ +{ + "version": 1, + "base_sha": "3ca8b9647ab992101adf9153b71b5ef1757ac82f", + "verified_at": "2026-10-06T19:34:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx", + "frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx", + "frontend-modern/src/features/alerts/metricAlertPresentation.ts", + "frontend-modern/src/features/alerts/useAlertHistoryState.ts" + ], + "content_sha256": { + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "bae52439900c395e8c7d33f87296e6c3d4f449e15fcf1dc69a0137111025d0ab", + "frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx": "6c8347cb65f80520711f9f4b66b2c46e09c51121bf67e9ec382bce67ca3468a6", + "frontend-modern/src/features/alerts/metricAlertPresentation.ts": "1b7314213d79cdfbad9f84b854ae8652802ad6c2d51955586c175475a024dbee", + "frontend-modern/src/features/alerts/useAlertHistoryState.ts": "5b9f10978f2d2d7101f92343e826bef9ae5e2677b36ccf431ff0e6dfa9d676a8" + }, + "routes": [ + "/alerts/overview", + "/alerts/history", + "/patrol" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 1024, + "height": 607 + }, + { + "width": 768, + "height": 1024 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack (sidecar build, uncommitted generator overlay pinning pve2 CPU temperature; alert level 80\u00b0C, clear level 75\u00b0C, 5 minute recovery delay)", + "Alerts overview card, breaching at 95\u00b0C: \"Temperature 95\u00b0C, above the 80\u00b0C alert level\", clear rule detail, \"Alert level 80\u00b0C\" and \"Clear level 75\u00b0C\"", + "Alerts overview card, held at 78\u00b0C: \"Temperature 78\u00b0C now, back under the 80\u00b0C alert level\" with hover \"Last reading at or above 80\u00b0C: 95\u00b0C\"; message \"Node temperature at 95.0\u00b0C\" no longer shown", + "Alerts overview card, recovering at 72\u00b0C: \"Temperature 72\u00b0C now, recovering\" and \"Clears after 5 minutes at 75\u00b0C or lower, 2 minutes so far.\" at 1280px and 375px (no horizontal scroll)", + "Fahrenheit preference: held card reads \"Temperature 172\u00b0F now, back under the 176\u00b0F alert level\", \"Alert level 176\u00b0F\", \"Clear level 167\u00b0F\", hover \"Last reading at or above 176\u00b0F: 203\u00b0F\"", + "Mail Gateway queue-age card keeps its message and no longer shows a guessed \"30%\" level; breaching memory card shows \"Memory 85%, above the 85% alert level\" with its levels", + "Card layout at 1024px: text column 256px with actions wrapped to two rows (was 110-160px beside one row); a 66-character resource name truncates with no overflow; 1280px unchanged (one row of actions); 768px text 256px, no overflow; 375px stacked, no horizontal scroll", + "Card removed after the alert resolved", + "History resource incidents panel: the open occurrence whose id and start match the live alert shows the live summary with the breach on hover; a cached open occurrence with an earlier start and closed occurrences keep their recorded messages (incident list extended in-page because mock mode never records open occurrences)", + "Patrol inbox row for pve2 shows the held summary from plainLanguageSummary; API shows recovering summary, breaching pve3 keeps its message, resolved pve2 keeps \"Resolved: Node temperature at 72.0\u00b0C\"" + ], + "interactions": [ + "Walked pve2 temperature 95 -> 78 -> 72 via the control file and reloaded the overview between phases; repeated 95 -> 78 after the final rebuild", + "Read card text and hover titles in each phase", + "Switched the temperature unit to Fahrenheit (localStorage temperatureUnit) and reloaded", + "Measured card column widths and overflow at 1280, 1024, 768 and 375 px, with a long resource name substituted in the DOM", + "Opened the pve2 Resource incidents panel from the History table row and pressed Refresh", + "Opened Patrol inbox and read the pve2 row" + ] +} diff --git a/frontend-modern/browser-verification/8ada2c9fe2438ea7.json b/frontend-modern/browser-verification/8ada2c9fe2438ea7.json new file mode 100644 index 0000000000..4f8e022c8f --- /dev/null +++ b/frontend-modern/browser-verification/8ada2c9fe2438ea7.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "base_sha": "c3adf0c7b9fa7f4283f7fd832a806659b009b51b", + "verified_at": "2026-10-06T21:45:33Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", + "frontend-modern/src/components/Workloads/WorkloadsTable.tsx", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/components/shared/NodeGroupHeader.tsx", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "67c2c51ffceb96893f968dc141dbce54f0affe66fb389ffeeba8c8c94988968c", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "e49032445f566b4e6a4329314f4ada41ed6f7652ba52e9e3fc413047a49f7878", + "frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "a340532f9e3e0e28d83e9a0fb369c8be973acb0dea7b321fd646345ff924db38", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "a29034c5149b7323e86bc2fe795d9b18db805b1ad32e32013fb71547256d61f5", + "frontend-modern/src/components/shared/NodeGroupHeader.tsx": "e435c1e6ab04b85a022459cf19a67645bc8339f15ef986ffcb5b42ba27ce667f", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "80c860be3bee37251c16d21044074267e48991b9cb39b87d335cb2155066ec96", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "6c74063a298a259ce5f60485174529e9bffbe7293ae0710f53dc95d6f3c11265" + }, + "routes": ["/proxmox/overview", "/vmware"], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Production vite build of this tree (rebased on c3adf0c7b9) embedded in a private DEMO_MODE mock backend (50 PVE nodes, 929 guests, 7 ESXi hosts), headless Chromium via Playwright; desktop 1440x900 and phone 390x844.", + "Proxmox Overview guest table, Grouped layout: every grouped node row (8 desktop, 2 phone in the rendered window) is one td whose colspan equals the guest table's visible header count (8 desktop, 5 phone), with status dot, linked node name, cluster badge and Agent badge only; no data-workload-col cells, no PVE version, temperature or uptime text.", + "vSphere Overview VM table: the 7 host group rows render as one spanning cell (colspan 6 desktop, 5 phone) with host name and cluster badge, unchanged shape.", + "Bars and Trends metric modes both inspected: Trends adds four sparklines per guest row (Proxmox 140 to 560 images, vSphere 18 to 72) while each grouped host row keeps exactly its one status-dot image.", + "Screenshots of grouped rows in both modes and of open guest drawers inspected at both viewports; no page errors.", + "This is the shape both pages already rendered on main, where compactGroupHeaders was always set; it is not a check of host drawers, which both pages disable." + ], + "interactions": [ + "Opened the View popover (desktop) and the Filters popover (phone) and toggled Bars to Trends and back.", + "Clicked a grouped host row on each page: no inline node drawer row appears and nothing else changes.", + "Clicked the first guest row under a group on each page: its inline guest drawer opens.", + "Scrolled the first grouped host row to the viewport centre before each capture." + ] +} diff --git a/frontend-modern/browser-verification/916a1b7c49ce40df.json b/frontend-modern/browser-verification/916a1b7c49ce40df.json new file mode 100644 index 0000000000..743bdbd806 --- /dev/null +++ b/frontend-modern/browser-verification/916a1b7c49ce40df.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "base_sha": "a21e0ef8f819c404338661f76b82b5bea09aedfd", + "verified_at": "2026-10-07T10:40:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx", + "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", + "frontend-modern/src/components/Workloads/workloadSelectors.ts", + "frontend-modern/src/utils/resourcePolicyPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx": "fd9ae0f141eadb0058cbf99bae19adf3d36e31008a2a35bb81e2bd02ff20d8df", + "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts": "deleted", + "frontend-modern/src/components/Workloads/workloadSelectors.ts": "3ad9d22fc2e416c1224fc4db64d1dd3d99bac19b1d8d82fa947877af7a56afd7", + "frontend-modern/src/utils/resourcePolicyPresentation.ts": "e9402807382b076d0cab3e84d4f03ef3fab8f81e508a83f8304471788f001992" + }, + "routes": [ + "/proxmox/overview", + "/docker" + ], + "viewports": [ + { + "width": 1920, + "height": 1080 + }, + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Embedded production build of this tree (rebased on a21e0ef8f8) served by a private DEMO_MODE mock backend (50 Proxmox nodes, 929 guests, Docker estate); the same checks also ran on an embedded build of unmodified origin/main for comparison", + "Proxmox overview workloads table at 1920: 161 guest rows, 512 Net I/O and Disk I/O values, 14 flagged as top outliers (4 semibold, 10 medium) and the rest muted, so the moved IO distribution still drives outlier emphasis", + "Proxmox overview workloads table at 390: 45 guest rows render; Net I/O and Disk I/O stay hidden at that width as before", + "Docker containers table, inline resource drawer for branch-portal-3 with its real change history plus facet counts widened to nine badges (REST list response and websocket frames edited in Playwright, since mock list rows carry recentChanges 0)", + "Drawer Change history facet summary renders all nine badges (Timeline, State transition, Restart, Config update, Anomaly, Platform event, Pulse diff, Docker adapter, Proxmox adapter) with no +N overflow chip; one line at 1440, four wrapped lines at 390, no clipped badge, right edge inside the viewport; the origin/main build renders the same nine badges and line counts", + "Console shows only the 401 and inline-style CSP messages that the origin/main build also logs on the same routes" + ], + "interactions": [ + "Demo auto sign-in, then direct navigation to /proxmox/overview and /docker", + "Typed branch-portal-3 into Search containers and clicked its row to open the inline resource drawer, then scrolled the Change history facet summary into view", + "Compared drawer badge count, wrap lines and overflow chip against the origin/main build on a second private backend" + ] +} diff --git a/frontend-modern/browser-verification/a3125d7841e88aa9.json b/frontend-modern/browser-verification/a3125d7841e88aa9.json new file mode 100644 index 0000000000..73bbb599e8 --- /dev/null +++ b/frontend-modern/browser-verification/a3125d7841e88aa9.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "base_sha": "bea0cec8b9c5bcc009b1d7a06c8a02d7458a459d", + "verified_at": "2026-10-07T08:00:14Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/AI/Chat/MessageItem.tsx", + "frontend-modern/src/components/AI/Chat/index.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", + "frontend-modern/src/components/Storage/DiskDetail.tsx", + "frontend-modern/src/components/Storage/StoragePoolDetail.tsx", + "frontend-modern/src/components/Workloads/GuestDrawer.tsx", + "frontend-modern/src/components/Workloads/NodeDrawer.tsx", + "frontend-modern/src/components/Workloads/ThresholdSlider.tsx", + "frontend-modern/src/components/shared/HelpIcon.tsx", + "frontend-modern/src/features/docker/DockerHostDrawer.tsx", + "frontend-modern/src/index.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/AI/Chat/MessageItem.tsx": "65cd98df1d0403de7e11c5a12eaeb919d0dd0af63c2889890bedd7d91a52831a", + "frontend-modern/src/components/AI/Chat/index.tsx": "bda296d1b6618237c1d358cf02974101b37325642fa85f13b8ecf9f8a4158b0c", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "9e7ee3e3cbb5f8da38cf43e0aaecd105f43333d16df7338e849d3ccaf6804d8f", + "frontend-modern/src/components/Storage/DiskDetail.tsx": "0e0c2c3e924f81edb50afc07f35d8c15206d317a2cda73472444b958483fd5ca", + "frontend-modern/src/components/Storage/StoragePoolDetail.tsx": "891929182a286d0082aafe604eb5357e394dca1f33aefc684ffa03fd14eb98f2", + "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "604deee9674aa3d97e45ccf7bc5b8c5dda27017dee2f235c107750911d2981b7", + "frontend-modern/src/components/Workloads/NodeDrawer.tsx": "060b6f94c7a1750219171188dcc016b17cc74b5459192d667c7995a4a9302380", + "frontend-modern/src/components/Workloads/ThresholdSlider.tsx": "1fcc6a5ad0c265a121c3b884744fc2b2925952aba81f99b56c144f91d9f8b046", + "frontend-modern/src/components/shared/HelpIcon.tsx": "dd8a0440ebac1c897fe9f14c3377ba3f0b7a261defc9f11ee4c74812532d4021", + "frontend-modern/src/features/docker/DockerHostDrawer.tsx": "e51a8a54858a5be8f31ed5aecaab1fe651925d29df0c411b76d95241611dee7d", + "frontend-modern/src/index.tsx": "2e05e71fa386dd56cc5d7ae74429e147a29fa79b71fb83e3464f5aab309cd72c" + }, + "routes": [ + "/standalone/machines", + "/docker/images", + "/docker", + "/proxmox", + "/proxmox/storage", + "/proxmox/storage?tab=disks", + "/alerts/thresholds", + "/settings/system-updates" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 1920, + "height": 1080 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Production build of this tree (vite build embedded into go build ./cmd/pulse) on origin/main bea0cec8b9, served by a private PULSE_MOCK_MODE backend without FRONTEND_DEV_SERVER, so every page carried the production CSP style-src 'self' 'nonce-...'; a second backend served the origin/main build as the before case", + "Before (origin/main build): opening a row's inline detail logged 8 style-src-attr CSP violations on /standalone/machines and /docker/images, 3 on a Docker host and 3 on a Proxmox guest, all from the vendor-solid template code; the HelpIcon popover logged 1 and the Assistant typing dots 5", + "After: 0 CSP console errors and 0 securitypolicyviolation events on every one of those opens at 1440x900 and 390x844, on the HelpIcon popover and on both sets of Assistant typing dots", + "Drawer tab panels have no style attribute and still compute overflow-anchor: none from the [overflow-anchor:none] class (8 panels on machines and images, 3 on a Docker host, 2 on a Proxmox guest); the origin/main build already computed none in Chromium despite the violations", + "Proxmox node drawer (opened from the host row chevron, 3 panels), storage pool detail on /proxmox/storage (2 panels) and physical disk detail History tab (1 panel): no style attribute, overflow-anchor none, 0 CSP events at 1440x900 and 390x844", + "Below the fold: rows opened at 785px and 778px of 900 (1440 wide) and 729px of 844 (390 wide) put the drawer 215 to 351px past the fold, and the clicked row did not move (0px drift over 2.5s) in either build", + "Alert threshold slider (desktop thresholds table at 1920 and 2560 wide, after clicking a CPU metric cell): the range inputs carry touch-none and no style attribute, compute touch-action none, and log 0 CSP events; the origin/main build put touch-action:none in a style attribute and logged 1 violation", + "HelpIcon popover on /settings/system-updates computes min-width 200px from the min-w-[200px] class while its dynamic top, left and max-width still apply through CSSOM", + "Assistant typing dots keep their stagger from classes: 0s, 0.12s and 0.24s on the first-token indicator, and 0s, 0.15s and 0.3s with 1s duration on the active-turn status bar", + "Pre-existing and identical in both builds, not changed here: a Docker host drawer opened near the page bottom moves its tab bar 73 to 93px when switching to the shorter History tab, and a Proxmox guest row scrolls 489px on open", + "No other console errors in any run" + ], + "interactions": [ + "Playwright Chromium 1440x900 and 390x844 (mobile emulation) against both backends: on each route, picked the lowest fully visible row in the lower half of the viewport, clicked it, sampled the row's top every 25ms for 2.5s, counted CSP console errors and securitypolicyviolation events, and read computed overflow-anchor on every drawer tab panel", + "Scrolled the drawer tab bar to 140px from the top, clicked History then Overview, and sampled the tab bar position after each click", + "Opened the Proxmox node drawer by its chevron, a storage pool row on /proxmox/storage, and the first physical disk on the Physical Disks tab followed by its History tab, reading each tab panel's style attribute, computed overflow-anchor and CSP events", + "On /alerts/thresholds expanded all sections and clicked a row's CPU metric cell to open the slider editor, then read the range inputs' style attribute, class and computed touch-action", + "Opened the update channel HelpIcon on /settings/system-updates and read the popover's style attribute and computed min-width", + "Opened the Assistant on /proxmox, sent a question, and read the computed animation delay, duration and style attribute of each typing dot while waiting for the first token", + "WebKit probe of the origin/main build: no CSP events, and overflow-anchor is unsupported there" + ] +} diff --git a/frontend-modern/browser-verification/ad11a59541280777.json b/frontend-modern/browser-verification/ad11a59541280777.json new file mode 100644 index 0000000000..f0678d07dd --- /dev/null +++ b/frontend-modern/browser-verification/ad11a59541280777.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "8259a74c9d0503df630b09c12a2c1be906fa3d78", + "verified_at": "2026-10-06T22:20:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/storageBackups/diskPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/features/storageBackups/diskPresentation.ts": "87469b0d62a31a1657a94a5f645041ad34987cc5af55ca238c6cbf49b0c068ee" + }, + "routes": [ + "/proxmox/storage?tab=disks", + "/proxmox/storage?tab=disks&status=attention", + "/proxmox/storage?tab=disks&status=critical", + "/proxmox/storage?tab=disks&status=warning" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (8 nodes) whose generator was overlaid locally, never committed, to pin readings from a control file on pve3: Crucial MX500 2TB /dev/nvme2 (SMART PASSED) at 72C, WD Blue SN570 /dev/sd1 at 63C, and Crucial MX500 2TB /dev/nvme0 at 72C with 2 pending sectors. The API returned risk {critical: [temperature_high]}, {warning: [temperature_high]} and {critical: [pending_sectors, temperature_high]} respectively; the backend binary was rebuilt from base 8259a74c9d (includes #2611)", + "Same-session control with the pre-change diskPresentation.ts (3ac5664d95, whose verdict code is unchanged on base 8259a74c9d) swapped in under the running Vite: the 72C temperature-only disk read 'Replace Now' (phone 'Replace') titled 'Disk temperature is 72C' in the row and in its drawer, and the 63C disk read 'Needs Attention' and sorted above the 7%-life disk", + "After the change at 1440 (full layout): rows sort Replace Now (pending sectors, title 'Pending sectors detected (2)', rgb(185,28,28)), Running Hot (title 'Disk temperature is 72C', rgb(185,28,28)), Needs Attention (970 EVO, 'SSD life remaining is 7%', rgb(180,83,9)), Running Hot (title 'Disk temperature is 63C', rgb(180,83,9)), then Healthy/Unknown disks unchanged", + "After the change at 390 (narrow layout): the same rows show the compact words Replace, Hot, Attention, Hot with the same tones and titles", + "Disk drawer header for the 72C temperature-only disk reads 'Running Hot Disk temperature is 72C' at both widths", + "Health filter from the URL at both widths: status=attention lists the two Replace/Hot critical rows plus Attention and the amber Hot row; status=critical lists only Replace Now and the red Running Hot; status=warning lists Needs Attention and the amber Running Hot", + "Known and unchanged: the 63C NVMe reads Running Hot in amber beside a green Temp cell, because the risk tier is the server's flat 60C while the Temp cell uses the NVMe alert threshold; the same disk read Needs Attention before this change. Threshold reconciliation is separate work", + "Amended content (empty-summary reasons keep their code and severity, an unexplained critical level no longer borrows a weaker reason's text): full pass repeated at 1440 and 390 with identical rows, compact words, titles, colours, drawer header and status=attention/critical/warning results" + ], + "interactions": [ + "Loaded the Physical Disks tab unfiltered at 1440x900 and 390x844 and read every row's visible health word, its title, computed colour and the Temp cell", + "Opened the inline drawer for the 72C temperature-only disk by clicking its row at both widths and read the health header", + "Loaded status=attention, status=critical and status=warning at both widths and read the listed rows", + "Swapped the pre-change diskPresentation.ts in under the running Vite, repeated the reads, restored the change and confirmed it byte-identical with cmp; after rebasing onto 8259a74c9d repeated the full after-change pass with a rebuilt backend" + ] +} diff --git a/frontend-modern/browser-verification/ae27640ea2ed301f.json b/frontend-modern/browser-verification/ae27640ea2ed301f.json new file mode 100644 index 0000000000..8c18857784 --- /dev/null +++ b/frontend-modern/browser-verification/ae27640ea2ed301f.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "base_sha": "60fb6e842b702c35c93ce7c5e8397487c15aab6a", + "verified_at": "2026-10-06T16:05:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/utils/resourceStateAdapters.ts" + ], + "content_sha256": { + "frontend-modern/src/utils/resourceStateAdapters.ts": "744fb071acc493f973c6064e7c7eaaac831c9cef2a88491a8b51e49a08f139be" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from this tree (backend :7681, Vite :5199) after restoring the original record selection and gating temperatureDetails on a current CPU reading", + "Proxmox node Analytics A (pve31) made PVE-only (agent facet removed from REST and websocket payloads), compared on identical data: main's adapter showed CPU low 63\u00b0C and CPU record 63\u00b0C (the current reading repeated); this change showed CPU low 39\u00b0C and CPU record 91\u00b0C from an injected temperatureDetails record (cpuMin 38.5, cpuMaxRecord 91.2) at 1440x900 and 390x844", + "Same PVE-only node without a record in temperatureDetails: CPU low and CPU record rows absent; NVMe row and table TEMP unchanged", + "Operator-context Temperature row: hidden on main and on this change at 63\u00b0C (it appears only in the warning band), so no difference", + "Agent-linked mock node with and without an injected record, desktop and phone width: no CPU low/record rows and Temperature from the agent facet, as on main", + "Unit tests cover the two cases this commit fixes: details with only NVMe do not displace a valid scalar CPU reading, and an unusable legacy record still falls back to the scalar" + ], + "interactions": [ + "Opened the node drawer from the row's detail toggle (aria-controls proxmox-host-drawer-) on /proxmox at desktop and phone widths", + "Read the Thermals and Operator context rows and the row's TEMP cell, and took screenshots, for main's adapter and for this change via Playwright with REST and websocket payload rewriting" + ] +} diff --git a/frontend-modern/browser-verification/b24ffeda6578c122.json b/frontend-modern/browser-verification/b24ffeda6578c122.json new file mode 100644 index 0000000000..8b1f018cc9 --- /dev/null +++ b/frontend-modern/browser-verification/b24ffeda6578c122.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "base_sha": "1b6a9b45d7a85ae2add1ff4213ef80f6b35f7309", + "verified_at": "2026-10-06T22:49:34Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Storage/DiskList.tsx", + "frontend-modern/src/components/Storage/useDiskDetailModel.ts", + "frontend-modern/src/components/Storage/useDiskListModel.ts", + "frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts", + "frontend-modern/src/features/storageBackups/diskPresentation.ts", + "frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx", + "frontend-modern/src/features/truenas/truenasPageModel.ts", + "frontend-modern/src/utils/metricThresholds.ts", + "frontend-modern/src/utils/temperature.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Storage/DiskList.tsx": "9fbcdb4659cfdf443f521c9b15969f485876cbdde8d7503110e12325b1899538", + "frontend-modern/src/components/Storage/useDiskDetailModel.ts": "51dea57998a0c0d88f4a8d6410ff16dc0559674cfecf456b61706bac145321af", + "frontend-modern/src/components/Storage/useDiskListModel.ts": "778db34c9def3a921e4bb6f9a36a79dcaa804962e9fb5bc3c03d908c585d8503", + "frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx": "ef649564bd14366aa71121e9c09ac4309bcd299fd1d388ceff5d5969671ae471", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "dac5b69aa5c603b719946fc3262d810a5a9bdc86469f33c3097542516f73d5cd", + "frontend-modern/src/features/storageBackups/diskPresentation.ts": "4210a0e7d61113f774462571700582d2f32cfe4474f04048e03d728272bd2247", + "frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts": "f983ea771d8f21ab018ca38a802aa7fa9795860c0ff010adcb7507cf9c47678c", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "168af9011efa41fc545dfb198f6eff5e9d5dbb003a0cd330c1238a820979c6b9", + "frontend-modern/src/features/truenas/truenasPageModel.ts": "36497cd8a519e3de9564eefb4b1a245fac6b0478c9f9a44a262c24228fc33dc9", + "frontend-modern/src/utils/metricThresholds.ts": "227994b089456c54489d64c90d3a3111421ec0c1b13adddc2744ab2d3749cfc2", + "frontend-modern/src/utils/temperature.ts": "a67d4ec80af39b6057ed155dc8ce5ce4c7e697345f7b9fadb798dcbd31605f9f" + }, + "routes": ["/proxmox/storage?tab=disks&q=West%20Production", "/truenas/storage"], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from this exact staged content (embedded UI and backend from one tree). A local-only generator overlay, never committed, pinned Proxmox disks from a control file: Samsung 990 PRO NVMe 63C, Crucial MX500 SATA 56C, WD Black SN850X NVMe 72C, Seagate IronWolf SATA 52C, plus TrueNAS mock disk sda (SATA) at 57C. The API returned no physicalDisk.risk and status online for every pinned disk", + "Same-session control on a second private stack built from the base without this change (main plus #2623): NVMe 63C read 'Running Hot' (amber) beside a green 63C cell with risk warning [temperature_high] and status warning; SATA 56C read 'Healthy' beside a red 56C cell; with the NVMe trigger raised to 75 the NVMe 72C still read 'Running Hot' from the flat 70C rule beside an amber cell", + "After, factory thresholds, 1440: Crucial MX500 56C 'Running Hot' red, title 'Disk temperature is 56\u00b0C, at or above its 55\u00b0C alert threshold.', red 56C cell, sorted first; WD Black 72C 'Running Hot' red with its 70C threshold; Samsung 990 PRO 63C 'Healthy' with a green cell; Crucial MX500 51C 'Healthy' with an amber cell (the band below the trigger stays colour only)", + "After, factory thresholds, 390: same rows with compact 'Hot' in red beside red cells, 'Healthy' beside the green 63C NVMe; no horizontal overflow (scrollWidth 390)", + "After, NVMe trigger raised to 75/70 through PUT /api/alerts/config: WD Black NVMe 72C is no longer Running Hot (its mock SMART health is UNKNOWN so it reads 'Unknown') and its cell is amber; SATA 56C still Running Hot", + "After, agent Disk Temp default switched off (trigger 0) through PUT /api/alerts/config: no disk reads Running Hot and every Temp cell is uncoloured (text-base-content); TrueNAS sda reads healthy with an Online dot", + "After, disk drawer for the 56C SATA disk: header 'Running Hot Disk temperature is 56\u00b0C, at or above its 55\u00b0C alert threshold.' and the Temperature card reads 56\u00b0C", + "After, TrueNAS Storage at 1440: sda (57C) is an Attention row with Health 'Disk temperature is 57\u00b0C, at or above its 55\u00b0C alert threshold.'; the Attention status filter lists it; other disks healthy and unchanged", + "After, TrueNAS Storage at 390: the Health column is phone-hidden, and sda's status dot reads Warning (amber) while cool disks stay Online (green)" + ], + "interactions": [ + "Loaded Physical Disks filtered to West Production at 1440x900 and 390x844 and read each row's health word, title, colour and Temp cell class on both the before and after stacks", + "Changed diskTempByType.nvme to 75/70, then agentDefaults.diskTemperature to 0, through GET/PUT /api/alerts/config and reloaded Physical Disks and TrueNAS Storage after each, then restored the factory config", + "Opened the inline drawer of the 56C SATA disk by clicking its row", + "Loaded TrueNAS Storage at 1440 and 390, clicked the Attention status filter, and read sda's Health cell, title and status dot" + ] +} diff --git a/frontend-modern/browser-verification/baa3a6b3d9fabd1a.json b/frontend-modern/browser-verification/baa3a6b3d9fabd1a.json new file mode 100644 index 0000000000..32c74e7a1c --- /dev/null +++ b/frontend-modern/browser-verification/baa3a6b3d9fabd1a.json @@ -0,0 +1,61 @@ +{ + "version": 1, + "base_sha": "9aaac4d3e502c01913fb3637d0997b0ce03a8b7b", + "verified_at": "2026-10-06T19:32:29Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/Workloads/MetricDisplayModeSegmentedControl.tsx", + "frontend-modern/src/components/Workloads/MetricMiniSparkline.tsx", + "frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts", + "frontend-modern/src/utils/format.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "3e7b1583b1c2ff5db07aaaa59006684f2e8c7d71d035f1ba29c03be28ae3bcd9", + "frontend-modern/src/components/Workloads/MetricDisplayModeSegmentedControl.tsx": "fc4af4a98de6ddae1b26033ad102398cb598975bae0619cba134610d8102e2d9", + "frontend-modern/src/components/Workloads/MetricMiniSparkline.tsx": "3afd872c9633430d60f8782b33501d941ccdef8f58f28a65cecf5ca7845c6523", + "frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts": "b2fbcd169b0d7bacb8045ad4c134e6ddb5addc7ce6c4d0ac4a3c6673b85f462f", + "frontend-modern/src/utils/format.ts": "30ce3873607bdf03e34307f56e561c191957304b7e07b780afb2bbb9bd554d98" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1920, + "height": 1000 + }, + { + "width": 1600, + "height": 1000 + }, + { + "width": 1440, + "height": 1000 + }, + { + "width": 768, + "height": 1000 + } + ], + "states": [ + "Private mock stack (backend + Vite on 7735/5253) built from main plus this change; before captures taken on the same stack before the edit, and the 1440 behaviour re-checked with main's versions of these files swapped back in", + "Before, bars mode at 1600 dark: hovering artifact-cache-310 swapped CPU, memory and disk for synchronized sparklines while Net I/O and Disk I/O stayed as static numbers", + "After, bars mode at 1600 dark and 1920 light: the same hover swaps all five cells; the I/O cells show both series with a compact coloured pair (e.g. ↓3.2M ↑2.2M, R1.4M W826K) and the svg labels carry the full rates; row stays 32px tall", + "After, pointer scrub on the CPU chart moves one cursor across all five charts, including both I/O charts", + "After, live update while scrubbing: with the Net I/O tooltip open on artifact-cache-310, a websocket snapshot changed the compact pair (↓2.8M to ↓3.7M) while all five chart nodes stayed mounted and the tooltip stayed open (before this change every live update remounted the lens charts)", + "After, idle I/O (history intercepted to 1-6 KB/s with one 80 KB/s disk write flush): both I/O lines sit within a pixel of the axis and the flush still shows as a bump instead of every blip filling the cell", + "After, Trends (sparklines) mode at 1600 dark: every running guest's I/O cell shows chart plus compact pair (736 charts, 256 rate pairs, 0 clipped), stopped dev-portal-312 keeps its dash", + "After, 768 dark and light: I/O columns are hidden by the layout as before; the lens and Trends still render CPU/memory/disk with 0 clipped cells", + "Pre-existing, not changed here: at 1440 the first-use history hint wraps and its dismissal shifts the table 36px, so the first lens opens on the next row (same on main)" + ], + "interactions": [ + "Hovered guest rows with a fine pointer in bars mode and read data-history-lens-active, chart count and svg aria-labels", + "Moved the pointer across the CPU chart to scrub and confirmed cursor lines in all five charts", + "Tagged the five lens chart nodes, opened the Net I/O tooltip, waited for live updates and compared node tags, compact pair text and tooltip presence", + "Left the row and confirmed bars and I/O numbers returned", + "Switched View to sparklines via the metric display toggle and measured chart, pair and overflow counts", + "Intercepted /api/metrics-store/history for one guest to idle I/O values to inspect the scale floor", + "Traced pointerenter/pointerleave and row y at 1440 to explain the lens opening on the next row" + ] +} diff --git a/frontend-modern/browser-verification/d0dfa366febe8d30.json b/frontend-modern/browser-verification/d0dfa366febe8d30.json new file mode 100644 index 0000000000..f7f5062854 --- /dev/null +++ b/frontend-modern/browser-verification/d0dfa366febe8d30.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "base_sha": "f4191494584f05c731f5c7a9aaa15c3b777a3f13", + "verified_at": "2026-10-06T16:39:54Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Storage/DiskDetail.tsx", + "frontend-modern/src/components/Storage/DiskList.tsx", + "frontend-modern/src/components/Storage/StoragePoolDetail.tsx", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts", + "frontend-modern/src/features/storageBackups/diskPresentation.ts", + "frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Storage/DiskDetail.tsx": "85a1bb30e4914c98b65b820e649322de9bf5412f913d85a7638d3f8400e987e2", + "frontend-modern/src/components/Storage/DiskList.tsx": "6e4d7ce022fe2b1262581cf8482e1ad11bb287de7a3c255d639a6eeac5c5cc31", + "frontend-modern/src/components/Storage/StoragePoolDetail.tsx": "020f64a6044f17619f30e3dd79e49fb863dcc9775de6a65a47f97b8fcca02819", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "7d01c07370a710227a39423670a5f1611bbfa64ba5488aa9a845fbdf2fe5176d", + "frontend-modern/src/features/storageBackups/diskPresentation.ts": "9ae039f89878e5274cbe6fdff436d3175a8656d717ffb51e72e5bb96d8f62d7c", + "frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "f52b9a2ada8e10bc84fb29ac9bc389a0d255fa794d99ff4c237e604e4cd30130" + }, + "routes": [ + "/proxmox/storage?tab=disks&q=West%20Production", + "/proxmox/storage?q=analytics-c-service-pool" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (50 nodes) whose generator was overlaid locally, never committed, to stamp real collector states on disk temperatures: a SATA disk on pve1 retaining 36C with unavailable 'disk is in standby', an NVMe disk on pve2 retaining 72C with unavailable 'host agent stopped reporting', a SATA disk on pve3 reading 72C live (available, host_agent), an NVMe disk on pve3 retaining 41C as missing with no reason, every other Proxmox disk available, TrueNAS disks with no collection state", + "Same-session control with origin/main's files swapped in under Vite: the pve2 silent-agent disk showed a red 72C identical to the live hot pve3 disk, the standby disk a green 36C, and its drawer printed 'Temperature is temporarily unavailable: host agent stopped reporting' directly above a red Temperature 72C card; the pool list showed the retained 71C in red", + "Physical Disks table after the change, light 1440 and dark 390: the three retained readings render muted (rgb(71,85,105) light, rgb(148,163,184) dark) with a dotted underline, titles 'Last known reading, not current: host agent stopped reporting', '... disk is in standby' and 'Last known reading, not current', and screen-reader text ', last known'; the live 72C stays red, 51C amber, the rest green; no clipping in the 41px phone Temp column", + "Disk drawer: silent-agent and standby disks show the existing collection message above a 'Last known temperature' card with a muted value; the live hot disk keeps 'Temperature 72C' in red with no message", + "Pool drawer for analytics-c-service-pool (pve33, ZFS devices sda2 and sdb2 linked to overlay disks): the retained 71C reads muted, dotted, with the reason title; the live 73C beside it stays red", + "Re-run on the rebased tree (origin/main f419149458, none of the changed files differ from the verified copies): identical results, no page errors at either width" + ], + "interactions": [ + "Loaded the Physical Disks tab filtered to West Production and read every Temp cell's text, computed colour, decoration, title and overflow", + "Opened and closed the inline disk drawer for the silent-agent, standby and live hot disks at both viewports and read the collection message and temperature card", + "Opened the analytics-c-service-pool row on the Pools tab and read the linked-disk temperatures in its Physical Disks card", + "Swapped origin/main's versions of the six runtime files in under the running Vite and repeated the same reads, then restored the change and confirmed the diff byte-identical" + ] +} diff --git a/frontend-modern/browser-verification/d494df7afd0adcb5.json b/frontend-modern/browser-verification/d494df7afd0adcb5.json new file mode 100644 index 0000000000..7873545ecb --- /dev/null +++ b/frontend-modern/browser-verification/d494df7afd0adcb5.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "base_sha": "9aaac4d3e502c01913fb3637d0997b0ce03a8b7b", + "verified_at": "2026-10-06T19:05:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ClusterDeployBanner.tsx", + "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", + "frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx", + "frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx", + "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", + "frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx", + "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", + "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", + "frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ClusterDeployBanner.tsx": "deleted", + "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx": "deleted", + "frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx": "deleted", + "frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx": "deleted", + "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx": "deleted", + "frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx": "deleted", + "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx": "deleted", + "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts": "deleted", + "frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts": "deleted", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts": "deleted", + "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts": "deleted", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "3232e48158a03f69e98474731da49ceb5dd050e99fa167972849b7ccb61d8643" + }, + "routes": [ + "/proxmox/overview", + "/proxmox/storage", + "/proxmox/replication", + "/proxmox/backups", + "/proxmox/ceph", + "/proxmox/mail", + "/docker/overview", + "/docker/images", + "/docker/storage", + "/docker/networks", + "/docker/swarm", + "/kubernetes/overview", + "/kubernetes/nodes", + "/kubernetes/workloads", + "/kubernetes/services", + "/kubernetes/storage", + "/kubernetes/configuration", + "/kubernetes/events", + "/truenas/overview", + "/truenas/storage", + "/truenas/services", + "/truenas/apps", + "/truenas/vms", + "/truenas/shares", + "/truenas/protection", + "/vmware/overview", + "/vmware/storage", + "/vmware/networks", + "/vmware/health", + "/vmware/activity", + "/standalone/machines", + "/standalone/availability" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "production vite build embedded in a private mock-mode backend (50 Proxmox nodes, Docker, Swarm, 3 Kubernetes clusters, TrueNAS, vSphere, agents)", + "every platform sub-tab mounts its page root and populated tables at desktop and phone width with zero document horizontal overflow", + "no element with data-testid infrastructure-table-surface renders on any route, before or after the change", + "no-match search empty state on Machines and Docker images", + "Machines and Docker images inline row detail open", + "before/after pair: the same row-detail interaction on the origin/main build shows the identical 8 pre-existing style-src-attr CSP violations from ResourceDetailDrawer overflow-anchor styles, so none come from this change" + ], + "interactions": [ + "signed in with the hot-dev credentials", + "visited every platform sub-tab discovered from each platform nav at 1440x900 and 390x844", + "typed a row-name prefix into the shared platform toolbar search on Machines and Docker images and confirmed the row count narrowed", + "typed a non-matching query and confirmed the no-match empty state, then cleared it and confirmed every row returned", + "clicked the first row to open the inline resource detail" + ] +} diff --git a/frontend-modern/browser-verification/e3956fd0c9818451.json b/frontend-modern/browser-verification/e3956fd0c9818451.json new file mode 100644 index 0000000000..4e012c9ea7 --- /dev/null +++ b/frontend-modern/browser-verification/e3956fd0c9818451.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "base_sha": "396bb5d517f14466795a2ccd2ff438fdba0ab077", + "verified_at": "2026-10-06T23:21:38Z", + "result": "passed", + "changed_paths": ["frontend-modern/src/features/truenas/truenasPageModel.ts"], + "content_sha256": { + "frontend-modern/src/features/truenas/truenasPageModel.ts": "91d7afa5bfb5b47692def18ae0a6e773ca47194d41faf0b57fa5c5c82d57d278" + }, + "routes": ["/truenas/storage"], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from this exact content, with a local-only overlay (never committed) pinning TrueNAS mock disks: sdc (SAS, carries the native incident 'Device /dev/sdc has SMART test failures.') at 67C and sda (SATA, no incident) at 57C, factory disk temperature thresholds (SAS 65C, SATA 55C)", + "1440: sdc's Health cell reads 'Device /dev/sdc has SMART test failures.' with '+1', and its title lists that reason then 'Disk temperature is 67\u00b0C, at or above its 65\u00b0C alert threshold.'; before this change the heat reason was dropped whenever an incident had text. sda still reads 'Disk temperature is 57\u00b0C, at or above its 55\u00b0C alert threshold.'", + "390: the Health column stays phone-hidden; sda's status dot reads Warning and the cool sdd reads Online; no horizontal overflow (scrollWidth 390)" + ], + "interactions": [ + "Loaded TrueNAS Storage at 1440x900 and 390x844 and read the sdc and sda Health cells, their titles and visible reason text, and the status dot titles", + "Moved sdc's pinned reading from 57C (under its SAS trigger: incident only, no heat) to 67C and reloaded to confirm the heat reason joins the incident text" + ] +} diff --git a/frontend-modern/browser-verification/fb12a674d61da775.json b/frontend-modern/browser-verification/fb12a674d61da775.json new file mode 100644 index 0000000000..78a9f2ccca --- /dev/null +++ b/frontend-modern/browser-verification/fb12a674d61da775.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "base_sha": "94b7870855120b2c62fce19102903601b9fc4bfc", + "verified_at": "2026-10-06T22:30:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "6e4003a1508856b944680e35234d9f93ef59a02f870fea6f9f1173f7e641c905" + }, + "routes": [ + "/proxmox/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (8 nodes, local uncommitted generator overlay pinning pve3 /dev/nvme2 at 72C). Mock guests carry no agent facet and no linked physical disks, so Playwright route interception added agent.agentVersion '6.5.0' to redis-cache-01 (vm-3ae77f2c237ff912) in /api/resources responses and answered the guest's type=physical_disk&parent= query with the stack's real 72C Crucial MX500 resource reparented to that guest", + "Production builds: with this commit (base 8259a74c9d plus the verdict commit 94b7870855) check-bundle-size reports WorkloadsSurface 64.31 kB gzip against a 70.23 kB max, every tracked chunk ok and none missing; origin/main d9ffb39ce8 plus 94b7870855 without this commit measured 70.64 kB (over by 0.40) and d9ffb39ce8 alone 70.20 kB. diskPresentation and GuestPhysicalDisks now build as their own chunks and the WorkloadsSurface chunk no longer contains the disk verdict strings", + "Guest drawer at 1440 and 390: the GuestPhysicalDisks module is requested only after the guest row is clicked open (none before), the parent query is sent once for the guest id, and the card reads 'Physical Disks & SMART (1)', 'Crucial MX500 2TB', '/dev/nvme2', 'Running Hot' (title 'Disk temperature is 72C', rgb(185,28,28)), '72\u00b0C', '1.82 TB'" + ], + "interactions": [ + "Loaded /proxmox/overview at 1440x900 and 390x844, recorded module requests, then clicked the redis-cache-01 guest row to open its drawer and waited for the guest physical-disk card", + "Read the card text, the verdict label, its title and computed colour, and clipped a screenshot of the card at both widths", + "Ran vite build and check-bundle-size on d9ffb39ce8, on d9ffb39ce8 plus 94b7870855, and on this branch" + ] +} diff --git a/frontend-modern/browser-verification/fb946589c643778e.json b/frontend-modern/browser-verification/fb946589c643778e.json new file mode 100644 index 0000000000..772fcdb973 --- /dev/null +++ b/frontend-modern/browser-verification/fb946589c643778e.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "9aaac4d3e502c01913fb3637d0997b0ce03a8b7b", + "verified_at": "2026-10-06T19:36:25Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "11fbba8f8a4e3a62aaceaed796ff9d67a09e6248bfe8eb95163a92c3dd85e728" + }, + "routes": [ + "/patrol" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 800, + "height": 600 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (PULSE_MOCK_MODE) built from this tree on origin/main 9aaac4d3e5, Vite on the same checkout; real alert-manager lifecycle, no injected data", + "Reviewed then suppressed issue (operationalRecord.acknowledgement and suppression both set, state suppressed): Lifecycle shows the acknowledgement line, the suppression reason and an End suppression button, and no Return to active attention button (1440, 800 and 390 widths)", + "Suppression form on a Reviewed issue labels its duration End suppression after; on an open issue it still reads Return it to active attention after", + "After ending a reviewed issue's suppression: notice Suppression ended. It stays reviewed. N handled issues remain., list row reads Reviewed, and the attention API reports state acknowledged with acknowledgement kept and the active count unchanged", + "After ending an unreviewed issue's suppression: Return to active attention button, notice Returned to decision inbox. N handled issues remain., API state open (1440 and 390 widths)", + "Stale detail: a reviewed, suppressed issue whose review was removed through POST /api/ai/patrol/attention/{id}/unacknowledge while its detail stayed open; the button still read End suppression, and after clicking it the notice read Returned to decision inbox. 5 handled issues remain., matching the API state open", + "The suppression expiry line still reads Returns to active attention just now on main; fix/future-relative-time changes that line separately, and it was left untouched here", + "No page errors in any run" + ], + "interactions": [ + "Browser pane at 800x600: opened west-d-service-pool (local-zfs) \u00b7 ZFS pool state, Mark reviewed, Reviewed and suppressed (1), reopened it, Suppress temporarily with a typed reason and 24 hours, then End suppression", + "Playwright 1440x900: reviewed case (Mark reviewed, handled view, suppress, End suppression) on mail-gateway-eu and esxi-07.lab.local; unreviewed case (suppress from the inbox, handled view, Return to active attention) on backend-seafile and edge-cold-iscsi; stale-detail case on Utility Network", + "Playwright 390x844: reviewed case on postgres-replica-18 and Edge Stateful, unreviewed case on dev-portal-01 and archive-tier, reopening items through Back to handled issues after the queue advanced", + "Read each item's resulting state through GET /api/ai/patrol/attention/{id} and the attention summary after every lifecycle change" + ] +} diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index e4cb1a9da1..44cb11003f 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -118,15 +118,6 @@ { "path": "src/components/Alerts/AlertResourceTableRow.tsx" }, - { - "path": "src/components/Infrastructure/UnifiedResourceHostTableCard.tsx" - }, - { - "path": "src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx" - }, - { - "path": "src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx" - }, { "path": "src/features/docker/DockerNativeTableShared.tsx" }, @@ -212,9 +203,6 @@ "export": "InlineDetailTableRow" }, "requiredConsumers": [ - { "path": "src/components/Infrastructure/UnifiedResourceHostTableCard.tsx" }, - { "path": "src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx" }, - { "path": "src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx" }, { "path": "src/components/Workloads/WorkloadPanel.tsx" }, { "path": "src/features/docker/DockerAlertsTable.tsx" }, { "path": "src/features/docker/DockerHostsTable.tsx" }, @@ -413,8 +401,6 @@ "export": "TableCard" }, "requiredConsumers": [ - { "path": "src/components/Infrastructure/UnifiedResourceHostTableCard.tsx" }, - { "path": "src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx" }, { "path": "src/components/Storage/StorageContentCard.tsx" }, { "path": "src/components/Workloads/WorkloadsSurface.tsx" }, { "path": "src/components/Workloads/WorkloadsTable.tsx" }, @@ -435,8 +421,6 @@ "export": "TableCardHeader" }, "requiredConsumers": [ - { "path": "src/components/Infrastructure/UnifiedResourceHostTableCard.tsx" }, - { "path": "src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx" }, { "path": "src/components/Storage/StorageContentCard.tsx" }, { "path": "src/features/platformPage/sharedPlatformPage.tsx" } ], diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 0e17f19ab4..e5ca1eb767 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -691,14 +691,8 @@ export const MessageItem: Component = (props) => {
diff --git a/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts b/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts index 9f23bdbe9a..1d9d579301 100644 --- a/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts +++ b/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts @@ -157,4 +157,35 @@ describe('alertAssistantHandoffModel', () => { const briefing = handoff.context.briefing as { detailLines: string[] }; expect(briefing.detailLines).toContain('Current value 92.5% · threshold 80.0%'); }); + + it('hands the assistant the live reading, not the breach an open alert still holds', () => { + const handoff = buildAlertAssistantHandoff({ + alert: makeAlert({ + type: 'temperature', + message: 'Node temperature at 80.0°C', + value: 80, + threshold: 80, + metricStatus: { + phase: 'recovering', + value: 72, + unit: '°C', + observedAt: '2026-05-07T10:04:50.000Z', + trigger: 80, + recovery: 75, + recoveryDelaySeconds: 300, + recoveryElapsedSeconds: 120, + }, + }), + now: new Date('2026-05-07T10:05:00.000Z'), + }); + + const briefing = handoff.context.briefing as { detailLines: string[] }; + expect(briefing.detailLines[0]).toContain('72.0°C'); + expect(briefing.detailLines.join('\n')).not.toContain('Node temperature at 80.0°C'); + expect(handoff.context.handoffContext).toContain('Current Value: 72.0°C'); + expect(handoff.context.handoffContext).toContain('Alert Phase: recovering'); + expect(handoff.context.handoffContext).toContain('Clears At: 75.0°C or lower for 300s'); + expect(handoff.context.handoffContext).toContain('Recovery Progress: 120s of 300s'); + expect(handoff.context.handoffContext).toContain('Last Reading At Or Above Threshold: 80.0°C'); + }); }); diff --git a/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts b/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts index 3a6a08b9c7..4ca70859cb 100644 --- a/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts +++ b/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts @@ -2,6 +2,7 @@ import type { Alert } from '@/types/api'; import type { AIChatContext } from '@/stores/aiChat'; import { DEFAULT_LOCALE, t, type SupportedLocale } from '@/i18n'; import { getCanonicalAlertId } from '@/features/alerts/identity'; +import { getMetricAlertPresentation } from '@/features/alerts/metricAlertPresentation'; import { formatAlertValue } from '@/utils/alertFormatters'; import { isMetricAlertType } from '@/utils/alerts'; import { isPulseSystemAlert } from '@/utils/alertScope'; @@ -35,7 +36,16 @@ export function buildAlertAssistantHandoff({ // to convey what's wrong. const systemScoped = isPulseSystemAlert(alert); const hasMetricValues = !systemScoped && isMetricAlertType(alert.type); - const currentValue = hasMetricValues ? formatAlertValue(alert.value, alert.type) : ''; + // An open threshold alert keeps value/message at its last breach while it + // holds; the live status is what the metric reads now. + const metricStatus = hasMetricValues ? alert.metricStatus : undefined; + const presentation = metricStatus ? getMetricAlertPresentation(alert, now.getTime()) : null; + const currentValue = hasMetricValues + ? formatAlertValue(metricStatus ? metricStatus.value : alert.value, alert.type) + : ''; + const briefingMessage = presentation + ? `${presentation.summary}. ${presentation.detail}` + : alert.message; const thresholdValue = hasMetricValues ? formatAlertValue(alert.threshold, alert.type) : ''; const nodeLabel = !systemScoped && alert.node ? alert.nodeDisplayName || alert.node : ''; const levelLabel = formatAlertLevel(alert.level); @@ -98,8 +108,8 @@ export function buildAlertAssistantHandoff({ }) : undefined, nodeLabel ? t('alerts.assistant.detail.node', { node: nodeLabel }) : undefined, - alert.message - ? t('alerts.assistant.detail.message', { message: alert.message }) + briefingMessage + ? t('alerts.assistant.detail.message', { message: briefingMessage }) : undefined, ].filter((line): line is string => Boolean(line)), actionLabel: t('alerts.assistant.action.investigate', { alertIdentifier }), @@ -149,6 +159,7 @@ function buildAlertAssistantModelContext({ systemScoped ? undefined : formatContextLine('Resource ID', alert.resourceId), formatContextLine('Current Value', currentValue), formatContextLine('Threshold', thresholdValue), + ...metricStatusContextLines(alert), formatContextLine('Duration', durationText), formatContextLine('Node', nodeLabel), formatContextLine('Message', alert.message), @@ -158,6 +169,34 @@ function buildAlertAssistantModelContext({ .join('\n'); } +const METRIC_PHASE_CONTEXT: Record = { + breaching: 'breaching (at or above the threshold)', + latched: 'holding (below the threshold, not yet down to the clear level)', + recovering: 'recovering (at or below the clear level, waiting out the recovery delay)', +}; + +function metricStatusContextLines(alert: Alert): Array { + const status = isMetricAlertType(alert.type) ? alert.metricStatus : undefined; + if (!status) return []; + const delay = status.recoveryDelaySeconds ?? 0; + return [ + formatContextLine('Alert Phase', METRIC_PHASE_CONTEXT[status.phase] ?? status.phase), + formatContextLine( + 'Clears At', + `${formatAlertValue(status.recovery, alert.type)} or lower${delay > 0 ? ` for ${delay}s` : ''}`, + ), + status.phase === 'recovering' && status.recoveryElapsedSeconds + ? formatContextLine('Recovery Progress', `${status.recoveryElapsedSeconds}s of ${delay}s`) + : undefined, + status.phase === 'breaching' + ? undefined + : formatContextLine( + 'Last Reading At Or Above Threshold', + formatAlertValue(alert.value, alert.type), + ), + ]; +} + function formatContextLine(label: string, value?: string | number | null): string | undefined { if (value === undefined || value === null) return undefined; const text = String(value).trim(); diff --git a/frontend-modern/src/components/Infrastructure/ClusterDeployBanner.tsx b/frontend-modern/src/components/Infrastructure/ClusterDeployBanner.tsx deleted file mode 100644 index 6f9df6aa52..0000000000 --- a/frontend-modern/src/components/Infrastructure/ClusterDeployBanner.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Component, Show, createMemo } from 'solid-js'; -import type { ResourceGroup } from '@/components/Infrastructure/infrastructureSelectors'; -import InfoIcon from 'lucide-solid/icons/info'; -import RocketIcon from 'lucide-solid/icons/rocket'; - -interface ClusterDeployBannerProps { - group: ResourceGroup; - onDeploy: (clusterId: string, clusterName: string) => void; -} - -/** - * Compact inline banner shown in cluster group headers when reachable PVE - * nodes lack a Pulse Agent and a source agent is available to deploy from. - */ -export const ClusterDeployBanner: Component = (props) => { - // Visibility conditions: - // 1. Non-empty cluster name (not Standalone) - // 2. At least one resource has platformType === 'proxmox-pve' - // 3. At least one resource has agent?.agentId (source agent exists) - // 4. At least one *reachable* PVE node does NOT have an agent - // - // Offline nodes are excluded: we can't deploy a Pulse Unified Agent to - // a node we can't reach, and a node going temporarily offline is not - // the same situation as a never-onboarded cluster peer. - const deployInfo = createMemo(() => { - const resources = props.group.resources; - const cluster = props.group.cluster; - - if (!cluster) return null; - - // Only consider actual PVE node resources (not VMs/containers). - const pveNodes = resources.filter( - (r) => r.type === 'agent' && r.platformType === 'proxmox-pve', - ); - if (pveNodes.length === 0) return null; - - const hasSourceAgent = pveNodes.some((r) => r.agent?.agentId); - if (!hasSourceAgent) return null; - - const deployableCount = pveNodes.filter( - (r) => !r.agent?.agentId && r.status !== 'offline', - ).length; - if (deployableCount === 0) return null; - - return { cluster, deployableCount }; - }); - - return ( - - {(info) => ( -
-
- - - {info().deployableCount} node{info().deployableCount !== 1 ? 's' : ''} ready for Pulse - Agent - -
- -
- )} -
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx index 05518bf5e9..a05fd6374e 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx @@ -2,7 +2,7 @@ import { type Component, For, Show, createMemo } from 'solid-js'; import type { ResourceChange } from '@/types/resource'; import { formatRelativeTime } from '@/utils/format'; import { - getResourceChangeKindPresentation, + getResourceChangePresentation, getResourceChangeSourceAdapterPresentation, getResourceChangeSourceTypePresentation, formatResourceChangeHeadline, @@ -83,7 +83,7 @@ export const ResourceChangeSummary: Component = (pro const shouldShowReason = reason.length > 0 && reason !== headline; const resourceHref = buildResourceHref(change.resourceId); const relatedResources = (change.relatedResources ?? []).slice(0, 3); - const kindPresentation = getResourceChangeKindPresentation(change.kind); + const kindPresentation = getResourceChangePresentation(change); const sourceTypePresentation = getResourceChangeSourceTypePresentation( change.sourceType, ); diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 243092e9dd..8a37e9c94b 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -187,10 +187,7 @@ const DrawerContent: Component = (props) => { tabs={drawer.tabs().map((tab) => ({ value: tab.id, label: tab.label }))} /> -
+
= (props) => {
{/* Agent Machine Metrics History Tab */} -
+
= (props) => {
-
+
= (props) => {
{/* Discovery Tab */} -
+
= (props) => {
{/* PMG Mail Tab */} -
+
{/* Mount on-demand to avoid background fetching when the tab isn't open. */} = (props) => {
{/* Kubernetes Namespaces Tab */} -
+
{/* Mount on-demand to avoid background fetching when the tab isn't open. */} = (props) => {
{/* Kubernetes Deployments Tab */} -
+
{/* Mount on-demand to avoid background fetching when the tab isn't open. */} = (props) => {
{/* Docker Swarm Tab */} -
+
{/* Mount on-demand to avoid background fetching when the tab isn't open. */} = (props) => { {/* Debug Tab */} -
+
diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index dc927cdc7f..3821dbe757 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -48,6 +48,7 @@ import { RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER, RESOURCE_CHANGE_SOURCE_TYPE_ORDER, getResourceChangeKindPresentation, + getResourceChangePresentation, getResourceChangeSourceAdapterPresentation, getResourceChangeSourceTypePresentation, } from '@/utils/resourceChangePresentation'; @@ -178,7 +179,10 @@ const HostDetailsDisclosure: Component<{ - + @@ -592,7 +596,8 @@ export const ResourceDetailDrawerOverviewTab: Component {(change) => { - const kindPresentation = getResourceChangeKindPresentation(change.kind); + // A close that was not a recovery reads as a move, not "Alert resolved". + const kindPresentation = getResourceChangePresentation(change); const sourceTypePresentation = getResourceChangeSourceTypePresentation( change.sourceType, ); diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index adb1a78581..43975c192f 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -217,9 +217,13 @@ export const InlineResourceSummaryTables: Component { const docker = dockerContainerMeta(props.resource); + // An offline resource has no current uptime: the retained value is from + // its last report, and the Last seen row already says when that was. const uptimeSeconds = docker ? getDockerContainerUptimeSeconds(props.resource) - : props.resource.uptime; + : props.resource.status === 'offline' + ? undefined + : props.resource.uptime; const identityRows = compactDetailRows([ ...props.drawer.primaryIdentityRows().map((row) => makeDetailRow(row.label, row.value)), props.showPlatformId ? makeDetailRow('Platform ID', props.resource.platformId) : null, diff --git a/frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx index a6dd66010c..f03683bd90 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx @@ -19,8 +19,6 @@ export interface ResourceFacetSummaryProps { recentChanges?: readonly ResourceChange[] | null; counts?: ResourceFacetCounts | null; showTimeline?: boolean; - maxVisibleBadges?: number; - class?: string; testId?: string; } @@ -105,46 +103,17 @@ export const ResourceFacetSummary: Component = (props showTimeline: props.showTimeline ?? true, }), ); - const visibleBadgeLimit = createMemo(() => { - const limit = props.maxVisibleBadges; - return typeof limit === 'number' && Number.isFinite(limit) && limit >= 0 - ? Math.floor(limit) - : null; - }); - const visibleBadges = createMemo(() => { - const limit = visibleBadgeLimit(); - return limit === null ? badges() : badges().slice(0, limit); - }); - const hiddenBadges = createMemo(() => badges().slice(visibleBadges().length)); - const hiddenBadgeCount = createMemo(() => hiddenBadges().length); - const hiddenBadgeTitle = createMemo(() => - hiddenBadges() - .map((badge) => badge.title) - .join(', '), - ); return ( 0}> -
- +
+ {(badge) => ( {badge.label} )} - 0}> - - +{hiddenBadgeCount()} - -
); diff --git a/frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx b/frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx deleted file mode 100644 index 93e03a566f..0000000000 --- a/frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { For, Show, type Component } from 'solid-js'; -import type { IntelligencePolicyPostureSummary } from '@/types/aiIntelligence'; -import { - getResourcePolicyRedactionSummaries, - getResourcePolicyRoutingSummaries, - getResourcePolicySensitivitySummaries, -} from '@/utils/resourcePolicyPresentation'; - -interface ResourcePolicySummaryProps { - posture?: IntelligencePolicyPostureSummary | null; - title?: string; - subtitle?: string; - resourceCountLabel?: string; - class?: string; -} - -export const ResourcePolicySummary: Component = (props) => { - const posture = () => props.posture; - const className = () => props.class?.trim() ?? ''; - const sensitivitySummaries = () => getResourcePolicySensitivitySummaries(posture()); - const routingSummaries = () => getResourcePolicyRoutingSummaries(posture()); - const redactionSummaries = () => getResourcePolicyRedactionSummaries(posture()); - - return ( - - {(value) => ( -
-
-
-

- {props.title ?? 'Data Governance'} -

- - {(subtitle) =>

{subtitle()}

} -
-

- {value().total_resources} {props.resourceCountLabel ?? 'governed resources'} -

-
-
- -
- - {(item) => ( -
-
{item.label}
-
{item.count}
-
- )} -
- - {(item) => ( -
-
{item.label}
-
{item.count}
-
- )} -
-
- - 0}> -
- - {(item) => ( - - {item.label} {item.count} - - )} - -
-
-
- )} -
- ); -}; - -export default ResourcePolicySummary; diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx deleted file mode 100644 index ce2fea9148..0000000000 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx +++ /dev/null @@ -1,799 +0,0 @@ -import { For, Show, createMemo } from 'solid-js'; -import type { Component } from 'solid-js'; -import { - formatBytes, - formatSpeed, - formatObservedSpeed, - formatUptime, - getResourceDiskSummary, - normalizeDiskArray, -} from '@/utils/format'; -import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature'; -import { - GROUPED_TABLE_ROW_BADGE_CLASS, - GROUPED_TABLE_ROW_META_CLASS, - getGroupedTableRowCellClass, - getInteractiveGroupedTableRowClass, -} from '@/components/shared/groupedTableRowPresentation'; -import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow'; -import { TableCardHeader } from '@/components/shared/TableCardHeader'; -import { TableCard } from '@/components/shared/TableCard'; -import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { ResponsiveMetricCell } from '@/components/shared/responsive'; -import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar'; -import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar'; -import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; -import { - getInfrastructureSystemIdentityBadges, - getInfrastructureSystemTitleBadges, - getPlatformBadge, - getSourceBadge, - getUnifiedSourceBadges, -} from '@/utils/resourceBadgePresentation'; -import { getAgentStatusIndicator } from '@/utils/status'; -import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; -import { - getResourcePolicyTableBadges, - shouldShowResourceAlternateName, -} from '@/utils/resourcePolicyPresentation'; -import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePresentation'; -import { ResourceDetailDrawer } from './ResourceDetailDrawer'; -import { getResourceHealthIssuePresentation } from './resourceHealthPresentation'; -import { ClusterDeployBanner } from './ClusterDeployBanner'; -import { ResourceFacetSummary } from './ResourceFacetSummary'; -import { UnifiedResourceSourceBadgeCell } from './UnifiedResourceSourceBadgeCell'; -import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction'; -import { resolveSummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction'; -import { - buildSummaryDisclosureControlsId, - createSummaryInteractiveRowPreviewHandlers, -} from '@/components/shared/summaryInteractionA11y'; -import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton'; -import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink'; -import { - type UnifiedResourceTableProps, - type UnifiedResourceTableState, -} from './useUnifiedResourceTableState'; -import { shouldShowClusterGroupTypeLabel } from './unifiedResourceTableStateModel'; -import { getOutlierEmphasis, isResourceOnline } from './unifiedResourceTableModel'; -import { type Resource, getCpuPercent, getMemoryPercent } from '@/types/resource'; - -interface UnifiedResourceHostTableCardProps { - tableProps: UnifiedResourceTableProps; - table: UnifiedResourceTableState; -} - -type ThermalPressurePresentation = { - label: string; - title: string; - className: string; -}; - -const getThermalPressurePresentation = (resource: Resource): ThermalPressurePresentation | null => { - const thermalState = resource.agent?.sensors?.thermalState; - if (!thermalState) return null; - - const pressure = thermalState?.pressure?.trim().toLowerCase(); - if (!pressure) return null; - - const label = - pressure === 'nominal' ? 'Nominal' : pressure === 'constrained' ? 'Constrained' : 'Unknown'; - const className = - pressure === 'nominal' - ? 'text-emerald-600 dark:text-emerald-400' - : pressure === 'constrained' - ? 'text-amber-600 dark:text-amber-400' - : 'text-slate-500 dark:text-slate-400'; - const limits = Object.entries(thermalState.limitsPercent ?? {}) - .filter(([, value]) => Number.isFinite(value) && value < 100) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key.replace(/_/g, ' ')} ${Math.round(value)}%`); - const source = thermalState.source ? ` via ${thermalState.source}` : ''; - const limitSummary = limits.length > 0 ? ` · limits: ${limits.join(', ')}` : ''; - - return { - label, - className, - title: `Thermal pressure ${label.toLowerCase()}${source}${limitSummary}`, - }; -}; - -export const UnifiedResourceHostTableCard: Component = ( - props, -) => { - const { table, tableProps } = props; - - return ( - - - - - - - table.handleSort('name')} - > - {table.headerLabels().resource} {table.renderSortIndicator('name')} - - table.handleSort('cpu')} - > - {table.headerLabels().cpu} {table.renderSortIndicator('cpu')} - - table.handleSort('memory')} - > - {table.headerLabels().memory} {table.renderSortIndicator('memory')} - - table.handleSort('disk')} - > - {table.headerLabels().disk} {table.renderSortIndicator('disk')} - - table.handleSort('network')} - > - {table.headerLabels().network} {table.renderSortIndicator('network')} - - table.handleSort('diskio')} - > - {table.headerLabels().diskIo} {table.renderSortIndicator('diskio')} - - table.handleSort('source')} - > - {table.headerLabels().source} {table.renderSortIndicator('source')} - - table.handleSort('uptime')} - > - {table.headerLabels().uptime} {table.renderSortIndicator('uptime')} - - table.handleSort('temp')} - > - {table.headerLabels().temp} {table.renderSortIndicator('temp')} - - - - - 0}> - - - - - {(item) => { - if (item.type === 'header') { - const group = item.group; - const groupSummaryScope = createMemo(() => - table.buildHostSummaryGroupScope(group), - ); - const isSummaryGroupHighlighted = createMemo( - () => tableProps.activeSummaryGroupScope?.id === groupSummaryScope()?.id, - ); - const handleGroupHoverChange = (next: SummarySeriesGroupScope | null) => { - tableProps.onGroupHoverChange?.(next); - }; - const handleGroupFocusToggle = () => { - const nextScope = groupSummaryScope(); - tableProps.onGroupFocusChange?.( - nextScope && tableProps.focusedSummaryGroupId === nextScope.id - ? null - : (nextScope?.id ?? null), - ); - }; - const groupRowInteraction = createSummaryInteractiveRowPreviewHandlers({ - onPreview: () => handleGroupHoverChange(groupSummaryScope()), - onPreviewClear: () => handleGroupHoverChange(null), - }); - return ( - - -
- Standalone} - > - {group.cluster} - - Cluster - - - - {group.resources.length}{' '} - {group.resources.length === 1 ? 'resource' : 'resources'} - -
- - - -
-
- ); - } - - const resource = item.resource; - const isExpanded = createMemo(() => tableProps.expandedResourceId === resource.id); - const isHighlighted = createMemo( - () => tableProps.highlightedResourceId === resource.id, - ); - const displayName = createMemo(() => - getPreferredInfrastructureDisplayName(resource), - ); - const summaryGroupMemberState = createMemo(() => - resolveSummaryGroupMemberInteractionState({ - seriesId: resource.id, - hoveredGroupScope: tableProps.hoveredSummaryGroupScope, - focusedGroupScope: tableProps.focusedSummaryGroupScope, - }), - ); - const statusIndicator = createMemo(() => - getAgentStatusIndicator({ status: resource.status }), - ); - const healthIssue = createMemo(() => getResourceHealthIssuePresentation(resource)); - const metricsKey = createMemo(() => buildMetricKeyForUnifiedResource(resource)); - const alertResourceIdCandidates = createMemo(() => - hostOverrideIdCandidates(resource), - ); - const cpuThresholds = createMemo(() => - table.getMetricThresholds('agent', 'cpu', alertResourceIdCandidates()), - ); - const memoryThresholds = createMemo(() => - table.getMetricThresholds('agent', 'memory', alertResourceIdCandidates()), - ); - const diskThresholds = createMemo(() => - table.getMetricThresholds('agent', 'disk', alertResourceIdCandidates()), - ); - const temperatureThresholds = createMemo(() => - table.getMetricThresholds('node', 'temperature', alertResourceIdCandidates()), - ); - const detailControlsId = createMemo(() => - buildSummaryDisclosureControlsId(resource.id), - ); - - const cpuPercentValue = createMemo(() => - resource.cpu ? Math.round(getCpuPercent(resource)) : null, - ); - const memoryPercentValue = createMemo(() => - resource.memory ? Math.round(getMemoryPercent(resource)) : null, - ); - const diskSummary = createMemo(() => getResourceDiskSummary(resource)); - const diskPercentValue = createMemo(() => { - const summary = diskSummary(); - return summary ? Math.round(summary.usage) : null; - }); - - const memorySublabel = createMemo(() => { - if ( - !resource.memory || - resource.memory.used === undefined || - resource.memory.total === undefined - ) - return undefined; - return `${formatBytes(resource.memory.used)}/${formatBytes(resource.memory.total)}`; - }); - - const diskSublabel = createMemo(() => { - const summary = diskSummary(); - if (!summary) return undefined; - return `${formatBytes(summary.used)}/${formatBytes(summary.total)}`; - }); - const networkTotal = createMemo( - () => (resource.network?.rxBytes ?? 0) + (resource.network?.txBytes ?? 0), - ); - const networkEmphasis = createMemo(() => - getOutlierEmphasis(networkTotal(), table.ioScale().network), - ); - const diskIOTotal = createMemo(() => - resource.diskIO?.readRate !== undefined && - resource.diskIO?.writeRate !== undefined - ? resource.diskIO.readRate + resource.diskIO.writeRate - : NaN, - ); - const diskIOEmphasis = createMemo(() => - getOutlierEmphasis(diskIOTotal(), table.ioScale().diskIO), - ); - - const rowClass = createMemo(() => { - const baseHover = - 'cursor-pointer transition-all duration-200 relative group hover:bg-surface-hover'; - - if (isExpanded()) { - return 'cursor-pointer transition-all duration-200 relative z-10 group bg-blue-50 dark:bg-blue-900/25'; - } - - let className = baseHover; - if (!isResourceOnline(resource)) { - className += ' opacity-60'; - } - - return className; - }); - const platformBadge = createMemo(() => getPlatformBadge(resource.platformType)); - const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType)); - const unifiedSources = createMemo(() => table.getUnifiedSources(resource)); - const sourceBadges = createMemo(() => getUnifiedSourceBadges(unifiedSources())); - const systemBadges = createMemo(() => - getInfrastructureSystemIdentityBadges(resource), - ); - const systemTitleBadges = createMemo(() => - getInfrastructureSystemTitleBadges(systemBadges(), sourceBadges()), - ); - const policyBadges = createMemo(() => - getResourcePolicyTableBadges(resource.policy), - ); - const availabilityProbe = createMemo(() => - getAvailabilityProbePresentation(resource), - ); - const thermalPressure = createMemo(() => getThermalPressurePresentation(resource)); - const resourceRowInteraction = createSummaryInteractiveRowPreviewHandlers({ - onPreview: () => tableProps.onHoverChange?.(resource.id), - onPreviewClear: () => tableProps.onHoverChange?.(null), - }); - - return ( - <> - table.toggleExpand(resource.id)} - {...resourceRowInteraction} - > - -
- table.toggleExpand(resource.id)} - onPreviewClear={() => tableProps.onHoverChange?.(null)} - /> - -
-
- - - {(issue) => ( - - )} - - - {(probe) => ( - - {probe().methodLabel} - - )} - - - - - - {(badge) => ( - - {badge.label} - - )} - - - -
-
-
-
- - - - - - } - > - - - - - - - - - } - > -
- -
-
-
- - - - - - } - > -
- -
-
-
- - - - - - } - > - {(probe) => ( -
- - {probe().resultLabel} - - } - > - {(targetLabel) => ( - <> - - {targetLabel()}: - - - {probe().resultLabel} - - - )} - - - {probe().freshnessLabel} - -
- )} -
- } - > -
- ↓ - - {formatSpeed(resource.network!.rxBytes)} - - ↑ - - {formatSpeed(resource.network!.txBytes)} - -
- -
- - - - - - } - > -
- - R - - - {formatObservedSpeed(resource.diskIO!.readRate)} - - - W - - - {formatObservedSpeed(resource.diskIO!.writeRate)} - -
-
-
- - - - - - -
- -
-
- - -
- - } - > - - {formatTemperature(resource.temperature)} - - -
-
-
- - - tableProps.onExpandedResourceChange(null)} - /> - - - - ); - }} -
- - 0}> - - -
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx deleted file mode 100644 index 7e638e9263..0000000000 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { Show, createMemo } from 'solid-js'; -import type { Component } from 'solid-js'; -import { formatUptime } from '@/utils/format'; -import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { - buildSummaryDisclosureControlsId, - createSummaryInteractiveRowPreviewHandlers, -} from '@/components/shared/summaryInteractionA11y'; -import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { - getInfrastructureSystemIdentityBadges, - getInfrastructureSystemTitleBadges, - getPlatformBadge, - getSourceBadge, - getUnifiedSourceBadges, -} from '@/utils/resourceBadgePresentation'; -import { getAgentStatusIndicator } from '@/utils/status'; -import { getServiceHealthSummaryPresentation } from '@/utils/serviceHealthPresentation'; -import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; -import { shouldShowResourceAlternateName } from '@/utils/resourcePolicyPresentation'; -import { ResourceDetailDrawer } from './ResourceDetailDrawer'; -import { ResourceFacetSummary } from './ResourceFacetSummary'; -import { UnifiedResourceSourceBadgeCell } from './UnifiedResourceSourceBadgeCell'; -import { - type UnifiedResourceTableProps, - type UnifiedResourceTableState, -} from './useUnifiedResourceTableState'; -import { getPBSTableRow, isResourceOnline } from './unifiedResourceTableModel'; -import { buildServiceDetailLinks } from './serviceDetailLinks'; -import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink'; -import { PlatformWindowedRows } from '@/features/platformPage/PlatformWindowedRows'; - -interface UnifiedResourcePBSTableSectionProps { - tableProps: UnifiedResourceTableProps; - table: UnifiedResourceTableState; -} - -export const UnifiedResourcePBSTableSection: Component = ( - props, -) => { - const { table, tableProps } = props; - const isServiceMobileColumnVisible = (priority: 'primary' | 'secondary') => - table.isMobile() || table.isServiceVisible(priority); - - return ( - 0}> -
- PBS Services -
- - - - - {table.headerLabels().resource} - - - {table.headerLabels().datastores} - - - {table.headerLabels().activity} - - - {table.headerLabels().health} - - - {table.headerLabels().source} - - - {table.headerLabels().uptime} - - - {table.headerLabels().action} - - - - - - {(resource) => { - const isExpanded = createMemo(() => tableProps.expandedResourceId === resource.id); - const isHighlighted = createMemo( - () => tableProps.highlightedResourceId === resource.id, - ); - const displayName = createMemo(() => getPreferredInfrastructureDisplayName(resource)); - const serviceLink = createMemo(() => buildServiceDetailLinks(resource)[0] ?? null); - const statusIndicator = createMemo(() => - getAgentStatusIndicator({ status: resource.status }), - ); - const pbsRow = createMemo(() => getPBSTableRow(resource)); - const platformBadge = createMemo(() => getPlatformBadge(resource.platformType)); - const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType)); - const unifiedSources = createMemo(() => table.getUnifiedSources(resource)); - const sourceBadges = createMemo(() => getUnifiedSourceBadges(unifiedSources())); - const systemBadges = createMemo(() => - getInfrastructureSystemIdentityBadges(resource), - ); - const systemTitleBadges = createMemo(() => - getInfrastructureSystemTitleBadges(systemBadges(), sourceBadges()), - ); - const healthClass = createMemo( - () => - getServiceHealthSummaryPresentation(resource.status, pbsRow()?.health).textClass, - ); - const activityClass = createMemo(() => - (pbsRow()?.activeTaskCount || 0) > 0 - ? 'text-[11px] font-semibold text-emerald-700 dark:text-emerald-300' - : 'text-xs text-base-content', - ); - - const rowClass = createMemo(() => { - const baseBorder = 'border-b border-border-subtle'; - const baseHover = `cursor-pointer transition-all duration-200 relative hover:shadow-xs group ${baseBorder}`; - - if (isExpanded()) { - return `cursor-pointer transition-all duration-200 relative hover:shadow-xs z-10 group bg-blue-50 dark:bg-blue-900/25 ${baseBorder}`; - } - - let className = baseHover; - if (!isResourceOnline(resource)) { - className += ' opacity-60'; - } - - return className; - }); - const detailControlsId = createMemo(() => - buildSummaryDisclosureControlsId(resource.id), - ); - const resourceRowInteraction = createSummaryInteractiveRowPreviewHandlers({ - onPreview: () => tableProps.onHoverChange?.(resource.id), - onPreviewClear: () => tableProps.onHoverChange?.(null), - }); - - return ( - <> - table.toggleExpand(resource.id)} - {...resourceRowInteraction} - > - -
- table.toggleExpand(resource.id)} - onPreviewClear={() => tableProps.onHoverChange?.(null)} - /> - - - - - -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- - - - - - -
- -
-
- - -
- -
-
-
- - - tableProps.onExpandedResourceChange(null)} - /> - - - - ); - }} -
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx deleted file mode 100644 index 79f38dc613..0000000000 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { Show, createMemo } from 'solid-js'; -import type { Component } from 'solid-js'; -import { formatUptime } from '@/utils/format'; -import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { - buildSummaryDisclosureControlsId, - createSummaryInteractiveRowPreviewHandlers, -} from '@/components/shared/summaryInteractionA11y'; -import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { - getInfrastructureSystemIdentityBadges, - getInfrastructureSystemTitleBadges, - getPlatformBadge, - getSourceBadge, - getUnifiedSourceBadges, -} from '@/utils/resourceBadgePresentation'; -import { getAgentStatusIndicator } from '@/utils/status'; -import { getServiceHealthSummaryPresentation } from '@/utils/serviceHealthPresentation'; -import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; -import { shouldShowResourceAlternateName } from '@/utils/resourcePolicyPresentation'; -import { ResourceDetailDrawer } from './ResourceDetailDrawer'; -import { ResourceFacetSummary } from './ResourceFacetSummary'; -import { UnifiedResourceSourceBadgeCell } from './UnifiedResourceSourceBadgeCell'; -import { - type UnifiedResourceTableProps, - type UnifiedResourceTableState, -} from './useUnifiedResourceTableState'; -import { getPMGTableRow, isResourceOnline } from './unifiedResourceTableModel'; -import { buildServiceDetailLinks } from './serviceDetailLinks'; -import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink'; -import { PlatformWindowedRows } from '@/features/platformPage/PlatformWindowedRows'; - -interface UnifiedResourcePMGTableSectionProps { - tableProps: UnifiedResourceTableProps; - table: UnifiedResourceTableState; -} - -export const UnifiedResourcePMGTableSection: Component = ( - props, -) => { - const { table, tableProps } = props; - const isServiceMobileColumnVisible = (priority: 'primary' | 'secondary') => - table.isMobile() || table.isServiceVisible(priority); - - return ( - 0}> -
- PMG Services -
- - - - - {table.headerLabels().resource} - - - {table.headerLabels().queue} - - - {table.headerLabels().deferred} - - - {table.headerLabels().hold} - - - {table.headerLabels().nodes} - - - {table.headerLabels().health} - - - {table.headerLabels().source} - - - {table.headerLabels().uptime} - - - {table.headerLabels().action} - - - - - - {(resource) => { - const isExpanded = createMemo(() => tableProps.expandedResourceId === resource.id); - const isHighlighted = createMemo( - () => tableProps.highlightedResourceId === resource.id, - ); - const displayName = createMemo(() => getPreferredInfrastructureDisplayName(resource)); - const serviceLink = createMemo(() => buildServiceDetailLinks(resource)[0] ?? null); - const statusIndicator = createMemo(() => - getAgentStatusIndicator({ status: resource.status }), - ); - const pmgRow = createMemo(() => getPMGTableRow(resource)); - const platformBadge = createMemo(() => getPlatformBadge(resource.platformType)); - const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType)); - const unifiedSources = createMemo(() => table.getUnifiedSources(resource)); - const sourceBadges = createMemo(() => getUnifiedSourceBadges(unifiedSources())); - const systemBadges = createMemo(() => - getInfrastructureSystemIdentityBadges(resource), - ); - const systemTitleBadges = createMemo(() => - getInfrastructureSystemTitleBadges(systemBadges(), sourceBadges()), - ); - const healthClass = createMemo( - () => - getServiceHealthSummaryPresentation(resource.status, pmgRow()?.health).textClass, - ); - const queueClass = createMemo(() => - (pmgRow()?.deferred || 0) + (pmgRow()?.hold || 0) > 0 - ? 'text-amber-600 dark:text-amber-400' - : 'text-base-content', - ); - - const rowClass = createMemo(() => { - const baseBorder = 'border-b border-border-subtle'; - const baseHover = `cursor-pointer transition-all duration-200 relative hover:shadow-xs group ${baseBorder}`; - - if (isExpanded()) { - return `cursor-pointer transition-all duration-200 relative hover:shadow-xs z-10 group bg-blue-50 dark:bg-blue-900/25 ${baseBorder}`; - } - - let className = baseHover; - if (!isResourceOnline(resource)) { - className += ' opacity-60'; - } - - return className; - }); - const detailControlsId = createMemo(() => - buildSummaryDisclosureControlsId(resource.id), - ); - const resourceRowInteraction = createSummaryInteractiveRowPreviewHandlers({ - onPreview: () => tableProps.onHoverChange?.(resource.id), - onPreviewClear: () => tableProps.onHoverChange?.(null), - }); - - return ( - <> - table.toggleExpand(resource.id)} - {...resourceRowInteraction} - > - -
- table.toggleExpand(resource.id)} - onPreviewClear={() => tableProps.onHoverChange?.(null)} - /> - - - - - -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- - - - - - -
- -
-
- - -
- -
-
-
- - - tableProps.onExpandedResourceChange(null)} - /> - - - - ); - }} -
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx deleted file mode 100644 index c1a479867b..0000000000 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Show } from 'solid-js'; -import type { Component } from 'solid-js'; -import { TableCardHeader } from '@/components/shared/TableCardHeader'; -import { TableCard } from '@/components/shared/TableCard'; -import { - type UnifiedResourceTableProps, - type UnifiedResourceTableState, -} from './useUnifiedResourceTableState'; -import { UnifiedResourcePBSTableSection } from './UnifiedResourcePBSTableSection'; -import { UnifiedResourcePMGTableSection } from './UnifiedResourcePMGTableSection'; - -interface UnifiedResourceServiceInfrastructureCardProps { - tableProps: UnifiedResourceTableProps; - table: UnifiedResourceTableState; -} - -export const UnifiedResourceServiceInfrastructureCard: Component< - UnifiedResourceServiceInfrastructureCardProps -> = (props) => { - const { table, tableProps } = props; - - return ( - 0 || table.sortedPMGResources().length > 0}> - - - - - - - ); -}; diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx deleted file mode 100644 index 3e72eaa983..0000000000 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { For, Show, createMemo } from 'solid-js'; -import type { Component } from 'solid-js'; -import type { ResourceBadge } from '@/utils/resourceBadgePresentation'; -import type { UnifiedResourceTableLayoutMode } from './unifiedResourceTableStateModel'; - -interface UnifiedResourceSourceBadgeCellProps { - unifiedBadges: ResourceBadge[]; - platformBadge: ResourceBadge | null; - sourceBadge: ResourceBadge | null; - titleBadges?: ResourceBadge[]; - layoutMode: UnifiedResourceTableLayoutMode; -} - -const getVisibleSourceBadgeLimit = (layoutMode: UnifiedResourceTableLayoutMode): number => - layoutMode === 'wide' ? 3 : 2; - -export const UnifiedResourceSourceBadgeCell: Component = ( - props, -) => { - const badges = createMemo(() => { - if (props.unifiedBadges.length > 0) return props.unifiedBadges; - return [props.platformBadge, props.platformBadge ? null : props.sourceBadge].filter( - (badge): badge is ResourceBadge => Boolean(badge), - ); - }); - const titleBadges = createMemo(() => - props.titleBadges && props.titleBadges.length > 0 ? props.titleBadges : badges(), - ); - const visibleBadges = createMemo(() => - badges().slice(0, getVisibleSourceBadgeLimit(props.layoutMode)), - ); - const hiddenBadges = createMemo(() => badges().slice(visibleBadges().length)); - const hiddenBadgeCount = createMemo(() => Math.max(0, badges().length - visibleBadges().length)); - const hiddenBadgeLabel = createMemo(() => - hiddenBadgeCount() === 1 ? `+${hiddenBadges()[0]?.label ?? ''}` : `+${hiddenBadgeCount()}`, - ); - const title = createMemo(() => - titleBadges() - .map((badge) => badge.title ?? badge.label) - .join(', '), - ); - - return ( -
- - {(badge) => ( - - {badge.label} - - )} - - 0}> - badge.title ?? badge.label) - .join(', ')}`} - title={title()} - > - {hiddenBadgeLabel()} - - -
- ); -}; diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx deleted file mode 100644 index 6c5a45ce13..0000000000 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Component } from 'solid-js'; -import { - useUnifiedResourceTableState, - type UnifiedResourceTableProps, -} from './useUnifiedResourceTableState'; -import { UnifiedResourceHostTableCard } from './UnifiedResourceHostTableCard'; -import { UnifiedResourceServiceInfrastructureCard } from './UnifiedResourceServiceInfrastructureCard'; - -export const UnifiedResourceTable: Component = (props) => { - const table = useUnifiedResourceTableState(props); - - return ( -
- - -
- ); -}; - -export default UnifiedResourceTable; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ClusterDeployBanner.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ClusterDeployBanner.test.tsx deleted file mode 100644 index 9530f564a6..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/ClusterDeployBanner.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@solidjs/testing-library'; - -/* ── hoisted mocks ────────────────────────────────────────────── */ -vi.mock('lucide-solid/icons/info', () => ({ - default: () => , -})); - -vi.mock('lucide-solid/icons/rocket', () => ({ - default: () => , -})); - -/* ── component import (after mocks) ──────────────────────────── */ -import { ClusterDeployBanner } from '../ClusterDeployBanner'; -import type { ResourceGroup } from '../infrastructureSelectors'; -import type { Resource } from '@/types/resource'; - -/* ── helpers ──────────────────────────────────────────────────── */ - -/** Minimal PVE node resource with optional agent and overridable status */ -function makePveNode( - id: string, - agentId?: string, - status: Resource['status'] = 'online', -): Resource { - return { - id, - type: 'agent', - name: id, - displayName: id, - platformId: 'pve1', - platformType: 'proxmox-pve', - sourceType: 'api', - status, - agent: agentId ? { agentId } : undefined, - } as Resource; -} - -/** Non-PVE resource (e.g. a VM) */ -function makeVm(id: string): Resource { - return { - id, - type: 'vm', - name: id, - displayName: id, - platformId: 'pve1', - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'running', - } as Resource; -} - -/** Non-PVE platform node (e.g. k8s) with optional agent */ -function makeK8sNode(id: string, agentId?: string): Resource { - return { - id, - type: 'k8s-node', - name: id, - displayName: id, - platformId: 'k8s1', - platformType: 'kubernetes', - sourceType: 'agent', - status: 'online', - agent: agentId ? { agentId } : undefined, - } as Resource; -} - -function makeGroup(cluster: string, resources: Resource[]): ResourceGroup { - return { cluster, resources }; -} - -/* ── lifecycle ────────────────────────────────────────────────── */ -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - cleanup(); -}); - -/* ── tests ────────────────────────────────────────────────────── */ -describe('ClusterDeployBanner', () => { - describe('visibility conditions', () => { - it('renders nothing when group has no cluster name', () => { - const group = makeGroup('', [makePveNode('node1', 'agent-1'), makePveNode('node2')]); - render(() => ); - expect(screen.queryByText('Review & Deploy')).not.toBeInTheDocument(); - }); - - it('renders nothing when there are no PVE nodes', () => { - const group = makeGroup('my-cluster', [makeVm('vm1'), makeK8sNode('k8s-node1')]); - render(() => ); - expect(screen.queryByText('Review & Deploy')).not.toBeInTheDocument(); - }); - - it('renders nothing when no PVE node has a source agent', () => { - const group = makeGroup('my-cluster', [ - makePveNode('node1'), // no agent - makePveNode('node2'), // no agent - ]); - render(() => ); - expect(screen.queryByText('Review & Deploy')).not.toBeInTheDocument(); - }); - - it('renders nothing when all PVE nodes already have agents', () => { - const group = makeGroup('my-cluster', [ - makePveNode('node1', 'agent-1'), - makePveNode('node2', 'agent-2'), - ]); - render(() => ); - expect(screen.queryByText('Review & Deploy')).not.toBeInTheDocument(); - }); - - it('renders when there is a mix of monitored and unmonitored PVE nodes', () => { - const group = makeGroup('my-cluster', [ - makePveNode('node1', 'agent-1'), // source agent - makePveNode('node2'), // unmonitored - ]); - render(() => ); - expect(screen.getByText('1 node ready for Pulse Agent')).toBeInTheDocument(); - expect(screen.getByText('Review & Deploy')).toBeInTheDocument(); - }); - - it('does not count offline no-agent nodes (cannot deploy to an unreachable host)', () => { - const group = makeGroup('my-cluster', [ - makePveNode('node1', 'agent-1'), // source agent - makePveNode('offline-node', undefined, 'offline'), // offline, no agent - ]); - render(() => ); - expect(screen.queryByText(/ready for Pulse Agent/)).not.toBeInTheDocument(); - expect(screen.queryByText('Review & Deploy')).not.toBeInTheDocument(); - }); - - it('still counts online no-agent nodes when other no-agent nodes are offline', () => { - const group = makeGroup('my-cluster', [ - makePveNode('node1', 'agent-1'), - makePveNode('online-no-agent'), - makePveNode('offline-no-agent', undefined, 'offline'), - ]); - render(() => ); - expect(screen.getByText('1 node ready for Pulse Agent')).toBeInTheDocument(); - }); - }); - - describe('deployable count display', () => { - it('shows singular "node" for 1 deployable', () => { - const group = makeGroup('cluster-a', [makePveNode('node1', 'agent-1'), makePveNode('node2')]); - render(() => ); - expect(screen.getByText('1 node ready for Pulse Agent')).toBeInTheDocument(); - }); - - it('shows plural "nodes" for multiple deployable', () => { - const group = makeGroup('cluster-a', [ - makePveNode('node1', 'agent-1'), - makePveNode('node2'), - makePveNode('node3'), - makePveNode('node4'), - ]); - render(() => ); - expect(screen.getByText('3 nodes ready for Pulse Agent')).toBeInTheDocument(); - }); - }); - - describe('deploy button', () => { - it('calls onDeploy with cluster name when clicked', () => { - const onDeploy = vi.fn(); - const group = makeGroup('prod-cluster', [ - makePveNode('node1', 'agent-1'), - makePveNode('node2'), - ]); - render(() => ); - fireEvent.click(screen.getByText('Review & Deploy')); - expect(onDeploy).toHaveBeenCalledTimes(1); - expect(onDeploy).toHaveBeenCalledWith('prod-cluster', 'prod-cluster'); - }); - - it('stops event propagation on click', () => { - const parentHandler = vi.fn(); - const group = makeGroup('my-cluster', [ - makePveNode('node1', 'agent-1'), - makePveNode('node2'), - ]); - render(() => ( -
- -
- )); - fireEvent.click(screen.getByText('Review & Deploy')); - expect(parentHandler).not.toHaveBeenCalled(); - }); - }); - - describe('edge cases', () => { - it('ignores VMs when computing PVE node counts', () => { - const group = makeGroup('my-cluster', [ - makePveNode('node1', 'agent-1'), - makeVm('vm1'), - makePveNode('node2'), - ]); - render(() => ); - expect(screen.getByText('1 node ready for Pulse Agent')).toBeInTheDocument(); - }); - - it('ignores non-PVE platform nodes', () => { - const group = makeGroup('my-cluster', [ - makePveNode('pve-node1', 'agent-1'), - makeK8sNode('k8s-node1'), - makePveNode('pve-node2'), - ]); - render(() => ); - expect(screen.getByText('1 node ready for Pulse Agent')).toBeInTheDocument(); - }); - - it('does not treat non-PVE agent as a PVE source agent', () => { - // A k8s node with an agent should NOT satisfy the source-agent requirement for PVE - const group = makeGroup('my-cluster', [ - makePveNode('pve-node1'), // PVE node, no agent - makePveNode('pve-node2'), // PVE node, no agent - makeK8sNode('k8s-node1', 'k8s-agent-1'), // non-PVE node with agent - ]); - render(() => ); - expect(screen.queryByText('Review & Deploy')).not.toBeInTheDocument(); - }); - - it('renders icons', () => { - const group = makeGroup('my-cluster', [ - makePveNode('node1', 'agent-1'), - makePveNode('node2'), - ]); - render(() => ); - expect(screen.getByTestId('info-icon')).toBeInTheDocument(); - expect(screen.getByTestId('rocket-icon')).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 800c3f0b7f..f5e53ea7de 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -916,6 +916,66 @@ describe('ResourceDetailDrawer change history section', () => { expect(panel.queryByText('Runs on')).toBeNull(); }); + it('labels an alert that moved to its Pulse agent as a move, not a recovery', async () => { + const summary = + 'Alert moved to pve1 (Host Agent). This is not a recovery: check the agent for the current reading.'; + facetBundleMock.getFacetBundle.mockResolvedValueOnce({ + recentChanges: [ + { + id: 'change-moved', + observedAt: '2026-10-06T15:00:00Z', + occurredAt: '2026-10-06T15:00:00Z', + resourceId: 'node:pve1', + kind: 'alert_resolved', + sourceType: 'heuristic', + confidence: 'high', + reason: summary, + metadata: { + alert_identifier: 'pve1-memory', + alert_type: 'memory', + alert_resolution: 'moved_to_agent', + }, + }, + { + id: 'change-recovered', + observedAt: '2026-10-06T14:00:00Z', + resourceId: 'node:pve1', + kind: 'alert_resolved', + sourceType: 'heuristic', + confidence: 'high', + reason: 'Alert resolved: Node CPU at 91%', + metadata: { alert_identifier: 'pve1-cpu', alert_type: 'cpu' }, + }, + ], + counts: { recentChanges: 2, recentChangeKinds: { alert_resolved: 2 } }, + }); + + render(() => ( + + )); + + await screen.findByText('Change history'); + const panel = within(screen.getByTestId('resource-change-history-section')); + const movedReason = await panel.findByText(summary); + const movedEntry = movedReason.parentElement as HTMLElement; + expect(within(movedEntry).getByText('Alert moved')).toBeInTheDocument(); + expect(within(movedEntry).queryByText('Alert resolved')).toBeNull(); + + // An ordinary recovery keeps its kind label. + const recoveredEntry = panel.getByText('Alert resolved: Node CPU at 91%') + .parentElement as HTMLElement; + expect(within(recoveredEntry).getByText('Alert resolved')).toBeInTheDocument(); + }); + it('surfaces resource-scoped action history from the canonical action audit API', async () => { actionAuditMock.listActionAudits.mockResolvedValueOnce({ available: true, diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index ec278c40f8..7109df0030 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -354,8 +354,51 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(getByText('Network')).toBeInTheDocument(); expect(getByText('Disks')).toBeInTheDocument(); expect(getByText('eth0')).toBeInTheDocument(); + expect(getByTestId('disks-card-total')).not.toHaveTextContent('Last known'); }); + // A Proxmox node keeps its row online while its linked agent is silent. + it.each(['offline', 'online'] as const)( + "shows a silent agent's retained disk usage and sensors as last known on a %s row", + async (status) => { + const GiB = 1024 * 1024 * 1024; + const resource = baseResource({ + status, + platformData: { sources: ['agent'] }, + agent: { + agentId: 'agent-1', + agentVersion: '1.2.3', + hostname: 'host-1', + platform: 'linux', + stale: true, + disks: [ + { mountpoint: '/', total: 100 * GiB, used: 93 * GiB }, + { mountpoint: '/data', total: 100 * GiB, used: 41 * GiB }, + ], + sensors: { temperatureCelsius: { cpu_package: 45 } }, + }, + }); + + const { getByRole, getByTestId, getByText, queryAllByTestId } = render(() => ( + + )); + fireEvent.click(getByRole('button', { name: 'Show details' })); + + await waitFor(() => { + expect(getByTestId('disks-card-total')).toBeInTheDocument(); + }); + expect(getByTestId('disks-card-total')).toHaveTextContent('Last known 67% · 134 GB / 200 GB'); + const root = getByText('Last known 93%'); + expect(root).toHaveClass('text-muted'); + expect(root.closest('[title]')).toHaveAttribute( + 'title', + 'Last known reading, not current: host agent stopped reporting', + ); + expect(queryAllByTestId('disks-card-mount-bar')).toHaveLength(0); + expect(getByText('45°C (last known)')).toBeInTheDocument(); + }, + ); + it('shows PVE API network inventory when no linked agent supplies interfaces', async () => { const resource = baseResource({ type: 'agent', diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.offline.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.offline.test.tsx new file mode 100644 index 0000000000..530048ca63 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.offline.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, screen, within } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; +import { InlineResourceSummaryTables } from '../ResourceDetailSummary'; +import type { UseResourceDetailDrawerStateResult } from '../useResourceDetailDrawerState'; + +const drawerState = { + primaryIdentityRows: () => [], + identityIpValues: () => [], + identityAliasValues: () => [], + aliasPreviewValues: () => [], + hasAliasOverflow: () => false, + identityCardHasRichData: () => true, + sourceSummary: () => null, + lastSeen: () => '14 mins ago', + lastSeenAbsolute: () => '', +} as unknown as UseResourceDetailDrawerStateResult; + +const machine = (status: Resource['status']): Resource => ({ + id: 'agent-apollo', + name: 'Apollo-114', + displayName: 'Apollo-114', + platformId: 'host-linux-1', + platformType: 'agent', + sourceType: 'agent', + type: 'agent', + status, + uptime: 2_500_000, + lastSeen: Date.parse('2026-10-06T22:00:00Z'), +}); + +afterEach(cleanup); + +describe('resource drawer runtime context for an offline resource', () => { + it('drops the uptime of an offline machine and keeps when it was last seen', () => { + render(() => ( + + )); + + const section = screen.getByTestId('resource-runtime-context-section'); + expect(within(section).getByText('offline')).toBeInTheDocument(); + expect(within(section).queryByText('Uptime')).toBeNull(); + expect(within(section).getByText('Last seen')).toBeInTheDocument(); + expect(within(section).getByText('14 mins ago')).toBeInTheDocument(); + }); + + it('keeps the uptime of a machine that is up', () => { + render(() => ( + + )); + + const section = screen.getByTestId('resource-runtime-context-section'); + expect(within(section).getByText('Uptime')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.relativeAge.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.relativeAge.test.tsx index bf17540823..786914a867 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.relativeAge.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.relativeAge.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; +import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock'; import { InlineResourceSummaryTables } from '../ResourceDetailSummary'; import { useResourceDetailDrawerDerivedState } from '../useResourceDetailDrawerDerivedState'; import type { UseResourceDetailDrawerStateResult } from '../useResourceDetailDrawerState'; @@ -47,6 +48,12 @@ afterEach(() => { vi.useRealTimers(); }); +// Time passes with no new data; the next clock tick re-reads the ages. +const hoursLater = (hours: number) => { + vi.setSystemTime(Date.now() + hours * 60 * 60 * 1000); + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); +}; + describe('resource drawer summary relative ages', () => { it('keeps an open Kubernetes controller summary age moving without new data', () => { vi.useFakeTimers({ now: new Date('2026-05-24T13:31:00Z') }); @@ -73,7 +80,7 @@ describe('resource drawer summary relative ages', () => { within(section()).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`), ).toBeInTheDocument(); - vi.advanceTimersByTime(2 * 60 * 60 * 1000); + hoursLater(2); // The absolute time is unchanged; only the age after it moves. expect( @@ -95,6 +102,7 @@ describe('resource drawer summary relative ages', () => { containerState: 'running', createdAt: '2026-05-24T11:31:00Z', startedAt: '2026-05-24T13:21:00Z', + finishedAt: '2026-05-24T12:31:00Z', }, })} drawer={drawerState} @@ -108,14 +116,16 @@ describe('resource drawer summary relative ages', () => { .closest('tr'); expect(row('Created')).toHaveTextContent('2 hours ago'); expect(row('Started')).toHaveTextContent('10 mins ago'); + expect(row('Finished')).toHaveTextContent('1 hour ago'); - vi.advanceTimersByTime(3 * 60 * 60 * 1000); + hoursLater(3); expect(row('Created')).toHaveTextContent('5 hours ago'); expect(row('Started')).toHaveTextContent('3 hours ago'); + expect(row('Finished')).toHaveTextContent('4 hours ago'); }); - it('keeps an open drawer Last seen moving for a resource that stops reporting', () => { + it('keeps an open drawer Last seen and platform freshness ages moving without new data', () => { vi.useFakeTimers({ now: new Date('2026-05-24T13:31:00Z') }); const { state, dispose } = createRoot((dispose) => ({ @@ -124,7 +134,11 @@ describe('resource drawer summary relative ages', () => { id: 'node-1', type: 'agent', lastSeen: Date.parse('2026-05-24T13:30:00Z'), - }), + platformData: { + docker: { updatesLastCheckedAt: '2026-05-24T13:01:00Z' }, + pmg: { lastUpdated: '2026-05-24T13:21:00Z' }, + }, + } as Partial & Pick), debugEnabled: () => false, discoveryFeatureEnabled: () => false, resourceIntelligence: () => null, @@ -133,11 +147,15 @@ describe('resource drawer summary relative ages', () => { })); expect(state.lastSeen()).toBe('1 min ago'); + expect(state.dockerUpdatesCheckedRelative()).toBe('30 mins ago'); + expect(state.pmgUpdatedRelative()).toBe('10 mins ago'); - // The resource stays silent: its lastSeen never changes again. - vi.advanceTimersByTime(2 * 60 * 60 * 1000); + // The resource stays silent: none of its timestamps change again. + hoursLater(2); expect(state.lastSeen()).toBe('2 hours ago'); + expect(state.dockerUpdatesCheckedRelative()).toBe('2 hours ago'); + expect(state.pmgUpdatedRelative()).toBe('2 hours ago'); dispose(); }); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceFacetSummary.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceFacetSummary.test.tsx new file mode 100644 index 0000000000..8585bbfa54 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceFacetSummary.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@solidjs/testing-library'; +import { ResourceFacetSummary } from '@/components/Infrastructure/ResourceFacetSummary'; + +describe('ResourceFacetSummary', () => { + it('renders timeline-only badges for canonical resource counts', () => { + const { getByText, queryByText } = render(() => ( + + )); + + expect(getByText('Timeline 3')).toBeInTheDocument(); + expect(getByText('Restart 2')).toBeInTheDocument(); + expect(getByText('Config update 1')).toBeInTheDocument(); + expect(getByText('Anomaly 1')).toBeInTheDocument(); + expect(getByText('Platform event 1')).toBeInTheDocument(); + expect(getByText('Pulse diff 2')).toBeInTheDocument(); + expect(getByText('Docker adapter 2')).toBeInTheDocument(); + expect(getByText('Proxmox adapter 1')).toBeInTheDocument(); + expect(queryByText('Capabilities 1')).toBeNull(); + expect(queryByText('Relationships 1')).toBeNull(); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourcePolicySummary.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourcePolicySummary.test.tsx deleted file mode 100644 index 7b7eaefe57..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourcePolicySummary.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { render, screen } from '@solidjs/testing-library'; -import { describe, expect, it } from 'vitest'; - -import { ResourcePolicySummary } from '../ResourcePolicySummary'; - -describe('ResourcePolicySummary', () => { - it('renders governed resource posture counts and redaction hints', () => { - render(() => ( - - )); - - expect(screen.getByText('Data Governance')).toBeInTheDocument(); - expect(screen.getByText('5 governed resources')).toBeInTheDocument(); - expect(screen.getByText('Public')).toBeInTheDocument(); - expect(screen.getByText('Local Only')).toBeInTheDocument(); - expect(screen.getByText('Hostname 3')).toBeInTheDocument(); - expect(screen.getByText('IP Address 1')).toBeInTheDocument(); - }); - - it('supports caller-owned subtitle and resource count wording', () => { - render(() => ( - - )); - - expect(screen.getByText('Policy coverage')).toBeInTheDocument(); - expect(screen.getByText('Coverage posture for policy-covered resources.')).toBeInTheDocument(); - expect(screen.getByText('4 policy-covered resources')).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx deleted file mode 100644 index 9231ffcd19..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx +++ /dev/null @@ -1,1341 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, waitFor, within } from '@solidjs/testing-library'; -import type { Resource } from '@/types/resource'; -import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; -import { ResourceFacetSummary } from '@/components/Infrastructure/ResourceFacetSummary'; -import { - buildCustomSensorRows, - buildTemperatureRows, - formatSensorName, -} from '@/components/Infrastructure/resourceDetailMappers'; -import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; -import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResourceTable.tsx?raw'; -import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnifiedResourceTableState.ts?raw'; -import unifiedResourceTableViewportSyncSource from '@/components/Infrastructure/useUnifiedResourceTableViewportSync.ts?raw'; -import unifiedResourceTableModelSource from '@/components/Infrastructure/unifiedResourceTableModel.ts?raw'; -import unifiedResourceTableStateModelSource from '@/components/Infrastructure/unifiedResourceTableStateModel.ts?raw'; -import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw'; -import unifiedResourceServiceInfrastructureCardSource from '@/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx?raw'; -import unifiedResourcePBSTableSectionSource from '@/components/Infrastructure/UnifiedResourcePBSTableSection.tsx?raw'; -import unifiedResourcePMGTableSectionSource from '@/components/Infrastructure/UnifiedResourcePMGTableSection.tsx?raw'; -import summaryRowActionButtonSource from '@/components/shared/SummaryRowActionButton.tsx?raw'; -import summaryInteractionA11ySource from '@/components/shared/summaryInteractionA11y.ts?raw'; -import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw'; -import resourceDetailDiscoveryModelSource from '@/components/Infrastructure/resourceDetailDiscoveryModel.ts?raw'; -import { - buildStatusOptions, - filterResources, - matchesSearch, - sortResources, - groupResources, - splitPrimaryAndServiceResources, - computeIOScale, -} from '@/components/Infrastructure/infrastructureSelectors'; -const resourceDetailDrawerMock = vi.hoisted(() => vi.fn()); -const frontendIndexCssSource = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8'); -const resizeObserverCallbacks: ResizeObserverCallback[] = []; -globalThis.ResizeObserver = class ResizeObserver { - constructor(callback: ResizeObserverCallback) { - resizeObserverCallbacks.push(callback); - } - observe() {} - unobserve() {} - disconnect() {} -} as unknown as typeof ResizeObserver; -const emitResizeObserverWidth = (width: number) => { - for (const callback of resizeObserverCallbacks) { - callback([{ contentRect: { width } } as ResizeObserverEntry], {} as ResizeObserver); - } -}; -if (typeof Element.prototype.scrollIntoView !== 'function') { - Element.prototype.scrollIntoView = vi.fn(); -} - -vi.mock('@/hooks/useBreakpoint', () => ({ - useBreakpoint: () => ({ - isMobile: () => false, - isVisible: () => true, - }), -})); - -vi.mock('@/components/shared/responsive', () => ({ - ResponsiveMetricCell: () =>
metric
, -})); - -vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({ - ResourceDetailDrawer: (props: Record) => { - resourceDetailDrawerMock(props); - return
drawer
; - }, -})); - -const makeResource = (i: number, overrides?: Partial): Resource => ({ - id: `resource-${i}`, - type: i % 3 === 0 ? 'docker-host' : 'agent', - name: `host-${i}`, - displayName: `Host ${i}`, - platformId: `platform-${i}`, - platformType: 'proxmox-pve', - sourceType: 'api', - status: i % 10 === 0 ? 'offline' : 'online', - cpu: { current: (i % 100) / 100 }, - memory: { - current: (i % 80) / 100, - total: 16 * 1024 * 1024 * 1024, - used: ((i % 80) / 100) * 16 * 1024 * 1024 * 1024, - }, - disk: { current: 0.3 }, - lastSeen: Date.now(), - platformData: { sources: ['proxmox'] }, - ...overrides, -}); - -const PROFILES = { - S: 250, - M: 1000, - L: 3000, -} as const; - -const makeResources = (count: number, overrides?: (i: number) => Partial): Resource[] => - Array.from({ length: count }, (_, i) => makeResource(i, overrides?.(i))); - -const getTypeDistribution = (resources: Resource[]) => - resources.reduce( - (acc, resource) => { - if (resource.type === 'agent') acc.agent += 1; - if (resource.type === 'docker-host') acc.dockerHost += 1; - return acc; - }, - { agent: 0, dockerHost: 0 }, - ); - -const expectedTypeDistribution = (count: number) => { - const dockerHost = Math.floor((count - 1) / 3) + 1; - const agent = count - dockerHost; - return { agent, dockerHost }; -}; - -const getBodyRowCount = (container: HTMLElement) => container.querySelectorAll('tbody tr').length; - -describe('UnifiedResourceTable performance contract', () => { - beforeEach(() => { - vi.clearAllMocks(); - resizeObserverCallbacks.length = 0; - localStorage.clear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('Fixture profile validation', () => { - for (const [profile, count] of Object.entries(PROFILES)) { - it(`builds profile ${profile} with stable size and type distribution`, () => { - const resources = makeResources(count); - const distribution = getTypeDistribution(resources); - const expectedDistribution = expectedTypeDistribution(count); - - expect(resources).toHaveLength(count); - expect(distribution).toEqual(expectedDistribution); - }); - } - }); - - describe('Baseline structural contracts', () => { - it('renders Profile S table structure within the mounted-row budget', async () => { - const resources = makeResources(PROFILES.S); - const { container } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(getBodyRowCount(container)).toBeGreaterThan(0); - }); - await waitFor(() => { - expect(getBodyRowCount(container)).toBeGreaterThan(0); - expect(getBodyRowCount(container)).toBeLessThanOrEqual(140); - }); - }); - - it('keeps the infrastructure table shell CSP-safe without inline style attributes', async () => { - const resources = [ - makeResource(1), - makeResource(2, { type: 'pbs', platformType: 'proxmox-pbs' }), - makeResource(3, { type: 'pmg', platformType: 'proxmox-pmg' }), - ]; - - const { container } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - - expect(container.querySelector('[style]')).toBeNull(); - }); - - it('renders thermal pressure in the temperature column when Celsius is unavailable', async () => { - const resources = [ - makeResource(1, { - type: 'agent', - platformType: 'agent', - sourceType: 'agent', - temperature: undefined, - agent: { - platform: 'macos', - sensors: { - thermalState: { - source: 'pmset', - pressure: 'nominal', - }, - }, - }, - }), - ]; - - const { getByText } = render(() => ( - - )); - - await waitFor(() => { - expect(getByText('Nominal')).toBeInTheDocument(); - }); - expect(getByText('Nominal')).toHaveAttribute('title', 'Thermal pressure nominal via pmset'); - }); - - it('renders reported host identity instead of container runtime as the primary system badge', async () => { - const resources = [ - makeResource(1, { - type: 'docker-host', - displayName: 'tower', - platformType: 'docker', - sourceType: 'hybrid', - platformData: { - sources: ['agent', 'docker'], - agent: { - platform: 'linux', - hostProfile: 'unraid', - osName: 'Unraid', - osVersion: '7.1.0', - }, - }, - }), - ]; - const { getByText, queryByText } = render(() => ( - - )); - - await waitFor(() => { - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('Unraid 7.1.0')).toBeInTheDocument(); - }); - expect(queryByText('Docker')).toBeNull(); - }); - - it('keeps versioned platform identity from repeating as a plain source in system titles', async () => { - const resources = [ - makeResource(1, { - type: 'agent', - displayName: 'pi', - platformType: 'proxmox-pve', - sourceType: 'hybrid', - sources: ['proxmox', 'agent'], - platformData: { - sources: ['proxmox', 'agent'], - proxmox: { - pveVersion: '', - }, - agent: { - platform: 'raspbian', - osName: 'Proxmox VE', - osVersion: '8.3.3', - }, - }, - }), - ]; - - const { container, getByText } = render(() => ( - - )); - - await waitFor(() => { - expect(getByText('PVE 8.3.3')).toBeInTheDocument(); - }); - expect(container.querySelector('[aria-label="System: PVE 8.3.3, Agent"]')).toBeTruthy(); - expect(container.querySelector('[aria-label="System: PVE 8.3.3, PVE, Agent"]')).toBeNull(); - }); - - it('renders compact canonical health reasons without replacing host identity', async () => { - const resources = [ - makeResource(1, { - type: 'agent', - displayName: 'Tower', - status: 'degraded', - agent: { - storagePostureSummary: 'Unraid array is running without parity protection', - rebuildSummary: 'Unraid array is running check', - }, - }), - ]; - - const { getByText } = render(() => ( - - )); - - await waitFor(() => { - expect(getByText('Tower')).toBeInTheDocument(); - expect(getByText('No parity')).toBeInTheDocument(); - }); - }); - - it('surfaces availability probe evidence directly in network endpoint rows', async () => { - vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-05-06T13:00:20Z').getTime()); - const resources = [ - makeResource(1, { - type: 'network-endpoint', - name: 'MQTT power meter', - displayName: 'MQTT power meter', - platformId: 'mock-availability-mqtt-meter', - platformType: 'availability', - sourceType: 'api', - status: 'online', - cpu: undefined, - memory: undefined, - disk: undefined, - platformData: { - sources: ['availability'], - availability: { - protocol: 'tcp', - port: 1883, - available: true, - latencyMillis: 7, - lastChecked: '2026-05-06T13:00:06Z', - }, - }, - }), - ]; - - const { getByText, getAllByText, queryByText } = render(() => ( - - )); - - await waitFor(() => { - expect(getByText('1883:')).toBeInTheDocument(); - expect(getByText('7 ms')).toBeInTheDocument(); - }); - expect(queryByText('TCP 1883:')).toBeNull(); - expect(queryByText('TCP 1883 - 7 ms - checked 14s ago')).toBeNull(); - expect(getAllByText('7 ms')).toHaveLength(1); - }); - - it('renders availability badge for agent resources with ICMP protocol', async () => { - const resources = [ - makeResource(1, { - type: 'agent', - platformType: 'agent', - sourceType: 'agent', - platformData: { - sources: ['agent'], - availability: { - protocol: 'icmp', - available: true, - latencyMillis: 5, - lastChecked: new Date().toISOString(), - }, - }, - }), - ]; - - const { container } = render(() => ( - - )); - - await waitFor(() => { - expect(container.textContent).toContain('ICMP'); - }); - }); - - it('adapts host columns from the measured table surface width instead of the window width', async () => { - const resources = [ - makeResource(1, { - network: { rxBytes: 1200, txBytes: 3400 }, - diskIO: { readRate: 2000, writeRate: 800 }, - uptime: 86400, - temperature: 42, - }), - ]; - const { getByText } = render(() => ( - - )); - - await waitFor(() => { - expect(getByText('Net I/O')).toBeInTheDocument(); - }); - - emitResizeObserverWidth(760); - - await waitFor(() => { - expect(getByText('Net').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Sys').closest('th')).not.toHaveClass('hidden'); - expect(getByText('I/O').closest('th')).toHaveClass('hidden'); - expect(getByText('Up').closest('th')).toHaveClass('hidden'); - }); - expect(getByText('CPU').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Mem')).toBeInTheDocument(); - expect(getByText('Disk').closest('th')).not.toHaveClass('hidden'); - - emitResizeObserverWidth(820); - - await waitFor(() => { - expect(getByText('Net').closest('th')).not.toHaveClass('hidden'); - expect(getByText('I/O').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Sys').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Up').closest('th')).toHaveClass('hidden'); - }); - - emitResizeObserverWidth(980); - - await waitFor(() => { - expect(getByText('Net I/O').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Disk I/O').closest('th')).not.toHaveClass('hidden'); - expect(getByText('System').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Up').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Temp').closest('th')).not.toHaveClass('hidden'); - }); - - emitResizeObserverWidth(1200); - - await waitFor(() => { - expect(getByText('Memory')).toBeInTheDocument(); - expect(getByText('System').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Uptime').closest('th')).not.toHaveClass('hidden'); - }); - }); - - it('keeps service columns denser than host columns before hiding them', async () => { - const resources = [ - makeResource(2, { - type: 'pbs', - name: 'pbs-docker', - displayName: 'pbs-docker', - platformType: 'proxmox-pbs', - sourceType: 'hybrid', - platformData: { sources: ['proxmox-pbs', 'agent'] }, - uptime: 86400, - }), - ]; - const { container, getByText, queryByText } = render(() => ( - - )); - - await waitFor(() => { - expect(getByText('Stores')).toBeInTheDocument(); - }); - - emitResizeObserverWidth(600); - - await waitFor(() => { - expect(getByText('Store').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Jobs').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Sys').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Up').closest('th')).toHaveClass('hidden'); - }); - expect(getByText('PBS')).toBeInTheDocument(); - expect(queryByText('Agent')).toBeNull(); - expect(queryByText('PBS+Agent')).toBeNull(); - expect(queryByText('+1')).toBeNull(); - expect( - container.querySelectorAll('[aria-label^="System:"] .h-1\\.5.w-1\\.5.rounded-full'), - ).toHaveLength(0); - - emitResizeObserverWidth(660); - - await waitFor(() => { - expect(getByText('Up').closest('th')).not.toHaveClass('hidden'); - }); - }); - - it('renders the shared facet summary component in timeline-only mode for canonical resource counts', async () => { - const { getByText, queryByText } = render(() => ( - - )); - - expect(getByText('Timeline 3')).toBeInTheDocument(); - expect(getByText('Restart 2')).toBeInTheDocument(); - expect(getByText('Config update 1')).toBeInTheDocument(); - expect(getByText('Anomaly 1')).toBeInTheDocument(); - expect(getByText('Platform event 1')).toBeInTheDocument(); - expect(getByText('Pulse diff 2')).toBeInTheDocument(); - expect(getByText('Docker adapter 2')).toBeInTheDocument(); - expect(getByText('Proxmox adapter 1')).toBeInTheDocument(); - expect(queryByText('Capabilities 1')).toBeNull(); - expect(queryByText('Relationships 1')).toBeNull(); - }); - - it('collapses resource facet badges behind an overflow chip when a table row sets a visible limit', async () => { - const { getByText, queryByText } = render(() => ( - - )); - - expect(getByText('Timeline 3')).toBeInTheDocument(); - expect(getByText('+7')).toBeInTheDocument(); - expect(queryByText('Config update 1')).toBeNull(); - }); - - it('formats sensor labels through the shared resource detail mapper helper', () => { - expect(formatSensorName('fan1_cpu_temp')).toBe('Cpu Temp'); - expect(formatSensorName('psu_temp')).toBe('Temp'); - expect( - buildTemperatureRows({ - temperatureCelsius: {}, - thermalState: { - source: 'pmset', - pressure: 'constrained', - limitsPercent: { cpu_speed_limit: 72, scheduler_limit: 100 }, - }, - }), - ).toEqual([ - { label: 'Thermal pressure', value: 'Constrained', valueTitle: 'Constrained via pmset' }, - { label: 'Speed Limit', value: '72%', valueTitle: 'Speed Limit 72%' }, - ]); - expect(resourceDetailMappersSource).toContain('titleCaseDelimitedLabel'); - expect(resourceDetailMappersSource).not.toContain('export const toDiscoveryConfig'); - expect(resourceDetailDiscoveryModelSource).toContain('export const toDiscoveryConfig'); - }); - - it('keeps typed GPU detail rows bounded to the selected resource payload', () => { - expect( - buildTemperatureRows({ - temperatureCelsius: { - cpu_package: 48, - gpu_nvidia_0: 63, - }, - gpu: [ - { - id: '0', - name: 'NVIDIA RTX A6000', - temperatureCelsius: 63, - utilizationPercent: 0, - memoryUsedBytes: 2 * 1024 * 1024 * 1024, - memoryTotalBytes: 48 * 1024 * 1024 * 1024, - }, - ], - }), - ).toEqual([ - { - label: 'GPU 0', - value: 'NVIDIA RTX A6000 · 63°C · 0% · 2.00 GB / 48.0 GB', - valueTitle: 'NVIDIA RTX A6000 · 63°C · 0% · 2.00 GB / 48.0 GB', - }, - { - label: 'Package', - value: '48°C', - valueTitle: '48.0°C', - }, - ]); - expect(resourceDetailMappersSource).not.toContain('nvidia-smi'); - expect(resourceDetailMappersSource).not.toContain('fetch('); - }); - - it('keeps custom metric formatting bounded to the selected resource payload', () => { - expect( - buildCustomSensorRows({ - custom: [ - { - id: 'queue_depth', - name: 'Queue depth', - unit: 'items', - value: 12.5, - status: 'warning', - observedAt: '2026-07-30T19:00:00Z', - }, - { - id: 'backup_age', - name: 'Last backup', - group: 'Main server', - subgroup: 'Backup', - kind: 'timestamp', - value: 7200, - status: 'warning', - observedAt: '2026-07-30T20:00:00Z', - }, - { - id: 'service_online', - name: 'Checkout', - group: 'Main server', - subgroup: 'Services', - kind: 'boolean', - value: 1, - status: 'ok', - observedAt: '2026-07-30T20:00:00Z', - }, - ], - }), - ).toEqual([ - { - label: 'Main server / Backup / Last backup', - value: '2h ago', - valueTitle: '2h ago · Warning', - }, - { - label: 'Main server / Services / Checkout', - value: 'Online', - valueTitle: 'Online · OK', - }, - { - label: 'Queue depth', - value: '12.5 items', - valueTitle: '12.5 items · Warning', - }, - ]); - expect(resourceDetailMappersSource).not.toContain('fetch('); - }); - - it('keeps power and fan detail rows bounded to the selected resource payload', () => { - expect( - buildTemperatureRows({ - additional: { vrm_temp: 55.6 }, - fanRpm: { chassis_fan: 1199.6 }, - powerWatts: { cpu_package: 82.4 }, - }), - ).toEqual([ - { - label: 'VRM Temp', - value: '56°C', - valueTitle: '55.6°C', - }, - { - label: 'Chassis Fan', - value: '1,200 RPM', - valueTitle: 'Chassis Fan 1,200 RPM', - }, - { - label: 'CPU Package Power', - value: '82.4 W', - valueTitle: 'CPU Package Power 82.4 W', - }, - ]); - expect(resourceDetailMappersSource).not.toContain('powercap'); - expect(resourceDetailMappersSource).not.toContain('fetch('); - }); - - it('keeps hot-path table state and windowing in the shared table state owner', () => { - expect(unifiedResourceTableSource).toContain('useUnifiedResourceTableState'); - expect(unifiedResourceTableSource).toContain('UnifiedResourceHostTableCard'); - expect(unifiedResourceTableSource).toContain('UnifiedResourceServiceInfrastructureCard'); - expect(unifiedResourceTableSource).toContain('data-summary-clear-surface'); - expect(unifiedResourceTableSource).not.toContain( - 'const sortedPBSResources = createMemo(() =>', - ); - expect(unifiedResourceTableSource).not.toContain('const getOutlierEmphasis ='); - expect(unifiedResourceTableSource).not.toContain('const getPBSTableRow ='); - expect(unifiedResourceTableStateSource).toContain("from './unifiedResourceTableStateModel'"); - expect(unifiedResourceTableStateSource).toContain('buildHostTableItems'); - expect(unifiedResourceTableStateSource).toContain( - 'getUnifiedResourceTableColumnPresentations', - ); - expect(unifiedResourceTableStateSource).toContain('getUnifiedResourceTableHeaderLabels'); - expect(unifiedResourceTableStateSource).toContain('getUnifiedResourceTableLayoutMode'); - expect(unifiedResourceTableStateSource).toContain('isUnifiedResourceServiceColumnVisible'); - expect(unifiedResourceTableStateSource).toContain('getUnifiedResourceTableShellClass'); - expect(unifiedResourceTableStateSource).toContain('useTableWindowing'); - expect(unifiedResourceTableStateSource).toContain('useUnifiedResourceTableViewportSync'); - expect(unifiedResourceTableStateSource).toContain('clearPinnedSummaryScope?: () => void;'); - expect(unifiedResourceTableStateSource).toContain('showHostClearAction'); - expect(unifiedResourceTableStateSource).toContain('showServiceClearAction'); - expect(unifiedResourceTableStateSource).toContain('useAlertsActivation'); - expect(unifiedResourceTableStateSource).toContain('getMetricThresholds'); - expect(unifiedResourceTableStateSource).not.toContain( - 'const resourceColumnStyle = createMemo(() =>', - ); - expect(unifiedResourceHostTableCardSource).toContain( - "table.getMetricThresholds('agent', 'cpu', alertResourceIdCandidates())", - ); - expect(unifiedResourceHostTableCardSource).toContain( - "table.getMetricThresholds('agent', 'memory', alertResourceIdCandidates())", - ); - expect(unifiedResourceHostTableCardSource).toContain( - "table.getMetricThresholds('agent', 'disk', alertResourceIdCandidates())", - ); - expect(unifiedResourceHostTableCardSource).toContain('thresholds={cpuThresholds()}'); - expect(unifiedResourceHostTableCardSource).toContain('thresholds={memoryThresholds()}'); - expect(unifiedResourceHostTableCardSource).toContain('thresholds={diskThresholds()}'); - expect(unifiedResourceHostTableCardSource).not.toContain('style={'); - expect(unifiedResourcePBSTableSectionSource).not.toContain('style={'); - expect(unifiedResourcePMGTableSectionSource).not.toContain('style={'); - expect(unifiedResourceTableStateSource).not.toContain( - "const showGroupHeaders = props.groupingMode === 'grouped'", - ); - expect(unifiedResourceTableStateSource).not.toContain('const items: HostTableItem[] = [];'); - expect(unifiedResourceTableStateSource).not.toContain('window.addEventListener'); - expect(unifiedResourceTableStateSource).not.toContain('getBoundingClientRect'); - expect(unifiedResourceTableViewportSyncSource).toContain('bindWindowedPageScrollEvents'); - expect(unifiedResourceTableViewportSyncSource).toContain('getBoundingClientRect'); - expect(unifiedResourceTableViewportSyncSource).not.toContain('scrollIntoView'); - expect(unifiedResourceTableViewportSyncSource).not.toContain("addEventListener('touch"); - expect(unifiedResourceTableViewportSyncSource).toContain('hostWindowing.onScroll'); - expect(unifiedResourceTableModelSource).toContain('export const getPBSTableRow'); - expect(unifiedResourceTableModelSource).toContain('export const getPMGTableRow'); - expect(unifiedResourceTableModelSource).toContain('export const getOutlierEmphasis'); - expect(unifiedResourceTableStateModelSource).toContain('export const buildHostTableItems'); - expect(unifiedResourceTableStateModelSource).toContain( - 'export const getUnifiedResourceTableColumnPresentations', - ); - expect(unifiedResourceTableStateModelSource).toContain( - 'export const getUnifiedResourceTableHeaderLabels', - ); - expect(unifiedResourceTableStateModelSource).toContain( - 'export const getUnifiedResourceTableLayoutMode', - ); - expect(unifiedResourceTableStateModelSource).toContain( - 'export const isUnifiedResourceServiceColumnVisible', - ); - expect(unifiedResourceTableStateModelSource).toContain( - 'export const getUnifiedResourceTableShellClass', - ); - expect(unifiedResourceTableStateModelSource).toContain( - 'export const getNextUnifiedResourceTableSortState', - ); - }); - - it('keeps PBS activity projection in the shared table-model owner', () => { - expect(unifiedResourceTableModelSource).toContain('getPbsActivitySummary'); - expect(unifiedResourcePBSTableSectionSource).toContain('headerLabels().activity'); - expect(unifiedResourcePBSTableSectionSource).not.toContain('backupJobs'); - expect(unifiedResourcePBSTableSectionSource).not.toContain('syncJobs'); - expect(unifiedResourcePBSTableSectionSource).not.toContain('getPbsActivitySummary'); - }); - - it('keeps contextual resource-row emphasis on the shared active-row presentation contract', () => { - expect(frontendIndexCssSource).toContain("tr[data-summary-row-active='true'] > td"); - expect(frontendIndexCssSource).toContain('--color-summary-row-bg'); - expect(frontendIndexCssSource).toContain('--color-summary-row-accent'); - expect(frontendIndexCssSource).toContain('tr.grouped-table-row > td'); - expect(frontendIndexCssSource).toContain('--color-grouped-table-row-bg'); - expect(summaryInteractionA11ySource).toContain('createSummaryInteractiveRowPreviewHandlers'); - expect(summaryInteractionA11ySource).toContain( - 'createSummaryInteractiveActionKeydownHandler', - ); - expect(summaryRowActionButtonSource).toContain('SummaryRowActionButton'); - expect(summaryRowActionButtonSource).toContain('aria-controls'); - expect(summaryRowActionButtonSource).toContain('aria-expanded'); - expect(summaryRowActionButtonSource).toContain('aria-pressed'); - - for (const source of [ - unifiedResourceHostTableCardSource, - unifiedResourcePBSTableSectionSource, - unifiedResourcePMGTableSectionSource, - ]) { - expect(source).toContain('data-summary-row-active'); - expect(source).toContain('createSummaryInteractiveRowPreviewHandlers'); - expect(source).toContain('SummaryRowActionButton'); - expect(source).toContain('hideWhenRowTappableOnMobile'); - expect(source).not.toContain('bg-blue-50 dark:bg-blue-900/25 ring-1 ring-blue-300'); - expect(source).not.toContain('bg-blue-100 dark:bg-blue-800'); - } - - expect(unifiedResourceHostTableCardSource).not.toContain('kind="scope"'); - expect(unifiedResourceHostTableCardSource).toContain('getInteractiveGroupedTableRowClass'); - expect(unifiedResourceHostTableCardSource).toContain('getGroupedTableRowCellClass'); - expect(unifiedResourceHostTableCardSource).not.toContain('cursor-pointer bg-surface-alt'); - expect(unifiedResourceHostTableCardSource).toContain('TableCard'); - expect(unifiedResourceHostTableCardSource).toContain('TableCardHeader'); - expect(unifiedResourceHostTableCardSource).toContain( - 'onClear={tableProps.clearPinnedSummaryScope}', - ); - for (const source of [ - unifiedResourceHostTableCardSource, - unifiedResourcePBSTableSectionSource, - unifiedResourcePMGTableSectionSource, - ]) { - expect(source).toMatch(/)/); - expect(source).not.toContain('
'); - expect(source).not.toContain('
'); - } - expect(unifiedResourceServiceInfrastructureCardSource).toContain('TableCard'); - expect(unifiedResourceServiceInfrastructureCardSource).toContain('TableCardHeader'); - expect(unifiedResourceServiceInfrastructureCardSource).toContain('showClearAction'); - expect(unifiedResourceServiceInfrastructureCardSource).toContain( - 'onClear={tableProps.clearPinnedSummaryScope}', - ); - }); - - it('keeps source filtering on the shared canonical source-platform helper', () => { - const resources = [ - makeResource(0, { platformData: { sources: ['proxmox'] } }), - makeResource(1, { platformData: { sources: ['docker'] } }), - ]; - - const filtered = filterResources(resources, new Set(['proxmox-pve']), new Set(), []); - - expect(filtered).toHaveLength(1); - expect(filtered[0]?.platformData?.sources).toEqual(['proxmox']); - }); - - it('searches policy-redacted resources by their raw infrastructure display name', () => { - // Local search; redaction is a transmission-boundary policy (docs/PRIVACY.md). - const governedResource = makeResource(9, { - name: 'secret-host-9', - displayName: 'secret-host-9', - policy: { - sensitivity: 'restricted', - routing: { scope: 'local-only', redact: ['hostname'] }, - }, - aiSafeSummary: 'Production Host', - }); - - expect(matchesSearch(governedResource, 'secret-host-9')).toBe(true); - expect(matchesSearch(governedResource, 'Production')).toBe(false); - }); - - it('suppresses non-blocking policy posture in host-table rows while preserving blocking policy badges', async () => { - const resources = [ - makeResource(0, { - name: 'default-policy-host', - displayName: 'Default Policy Host', - policy: { - sensitivity: 'internal', - routing: { scope: 'cloud-summary' }, - }, - }), - makeResource(1, { - name: 'governed-host', - displayName: 'Governed Host', - policy: { - sensitivity: 'sensitive', - routing: { scope: 'local-first', redact: ['hostname'] }, - }, - }), - makeResource(2, { - name: 'local-only-host', - displayName: 'Local Only Host', - policy: { - sensitivity: 'restricted', - routing: { scope: 'local-only', redact: ['hostname'] }, - }, - }), - ]; - - const { container } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - - const body = container.querySelector('tbody'); - expect(body).not.toBeNull(); - const bodyQueries = within(body as HTMLElement); - - expect(bodyQueries.queryByText('Internal')).not.toBeInTheDocument(); - expect(bodyQueries.queryByText('Cloud Summary')).not.toBeInTheDocument(); - expect(bodyQueries.queryByText('Sensitive')).not.toBeInTheDocument(); - expect(bodyQueries.queryByText('Local First')).not.toBeInTheDocument(); - const policyBadge = bodyQueries.getByText('Local Only'); - expect(policyBadge).toBeInTheDocument(); - expect(policyBadge.closest('[title]')?.getAttribute('title')).toContain('Restricted'); - }); - - it('renders facet summary badges without changing the Profile S row budget', async () => { - const resources = makeResources(PROFILES.S, (i) => - i === 1 - ? { - capabilities: [ - { - name: 'restart', - type: 'native', - description: 'Restart the host', - minimumApprovalLevel: 'admin', - }, - ], - relationships: [ - { - sourceId: 'resource-1', - targetId: 'storage-1', - type: 'depends_on', - confidence: 0.91, - active: true, - discoverer: 'proxmox_adapter', - observedAt: new Date().toISOString(), - lastSeenAt: new Date().toISOString(), - }, - ], - facetCounts: { - recentChanges: 3, - recentChangeKinds: { - restart: 2, - config_update: 1, - metric_anomaly: 1, - }, - recentChangeSourceTypes: { - platform_event: 1, - pulse_diff: 2, - }, - recentChangeSourceAdapters: { - docker_adapter: 2, - proxmox_adapter: 1, - }, - }, - recentChanges: [], - } - : {}, - ); - const { container, getByText } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(getByText('Timeline 3')).toBeInTheDocument(); - expect(getByText('+7')).toBeInTheDocument(); - }); - expect(container.querySelector('tbody')?.textContent).not.toContain('Config update 1'); - await waitFor(() => { - expect(getBodyRowCount(container)).toBeGreaterThan(0); - expect(getBodyRowCount(container)).toBeLessThanOrEqual(140); - }); - }); - - it('renders service facet summary badges without changing the dedicated service row budget', async () => { - const resources: Resource[] = [ - makeResource(0, { - id: 'pbs-service', - type: 'pbs', - displayName: 'pbs-service', - name: 'pbs-service', - platformType: 'proxmox-pbs', - sourceType: 'api', - platformData: { - sources: ['pbs'], - pbs: { - datastoreCount: 1, - backupJobCount: 1, - }, - }, - recentChanges: [ - { - id: 'pbs-change-1', - observedAt: new Date().toISOString(), - resourceId: 'pbs-service', - kind: 'state_transition', - sourceType: 'pulse_diff', - confidence: 'high', - }, - ], - }), - makeResource(1, { - id: 'pmg-service', - type: 'pmg', - displayName: 'pmg-service', - name: 'pmg-service', - platformType: 'proxmox-pmg', - sourceType: 'api', - platformData: { - sources: ['pmg'], - pmg: { - queueTotal: 12, - nodeCount: 1, - }, - }, - recentChanges: [ - { - id: 'pmg-change-1', - observedAt: new Date().toISOString(), - resourceId: 'pmg-service', - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'medium', - }, - ], - }), - ]; - const { container, getByText } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(getBodyRowCount(container)).toBe(2); - }); - - const pbsRow = getByText('pbs-service').closest('tr'); - expect(pbsRow).toBeTruthy(); - if (pbsRow) { - const row = within(pbsRow); - expect(row.getByText('Timeline 1')).toBeInTheDocument(); - expect(row.queryByText('Capabilities 1')).toBeNull(); - expect(row.queryByText('Relationships 1')).toBeNull(); - } - - const pmgRow = getByText('pmg-service').closest('tr'); - expect(pmgRow).toBeTruthy(); - if (pmgRow) { - const row = within(pmgRow); - expect(row.getByText('Timeline 1')).toBeInTheDocument(); - expect(row.queryByText('Capabilities 1')).toBeNull(); - expect(row.queryByText('Relationships 1')).toBeNull(); - } - }); - - it('passes the canonical resource-label resolver into the drawer for related-resource chips', async () => { - resourceDetailDrawerMock.mockClear(); - const resources = [ - makeResource(0, { - id: 'resource-0', - displayName: 'Host 0', - name: 'host-0', - }), - ]; - const { container } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(resourceDetailDrawerMock).toHaveBeenCalled(); - }); - - const drawerProps = resourceDetailDrawerMock.mock.calls.at(-1)?.[0] as { - resolveResourceLabel?: (resourceId: string) => string | undefined; - }; - - expect(drawerProps.resolveResourceLabel?.('resource-0')).toBe('Host 0'); - }); - }); - - describe('Row windowing contracts', () => { - it('Profile M: preserves row windowing with policy badge rendering enabled', async () => { - const resources = makeResources(PROFILES.M, (i) => - i === 0 - ? { - displayName: 'Sensitive Host', - name: 'sensitive-host', - aiSafeSummary: 'restricted host summary safe for remote AI consumption', - policy: { - sensitivity: 'restricted', - routing: { - scope: 'local-only', - redact: ['hostname', 'alias'], - }, - }, - } - : { - policy: { - sensitivity: i % 2 === 0 ? 'restricted' : 'internal', - routing: { - scope: i % 3 === 0 ? 'local-only' : 'local-first', - redact: ['hostname', 'alias'], - }, - }, - }, - ); - const { container, getAllByText } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - await waitFor( - () => { - const rowCount = getBodyRowCount(container); - expect(rowCount).toBeGreaterThan(0); - expect(rowCount).toBeLessThanOrEqual(140); - }, - { timeout: 15000 }, - ); - await waitFor( - () => { - expect(getAllByText('Local Only').length).toBeGreaterThan(0); - }, - { timeout: 15000 }, - ); - expect(getPreferredInfrastructureDisplayName(resources[0]!)).toBe('Sensitive Host'); - }, 30000); - - it('Profile M: caps mounted rows when windowing is active', async () => { - const resources = makeResources(PROFILES.M); - const { container } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - await waitFor( - () => { - const rowCount = getBodyRowCount(container); - expect(rowCount).toBeGreaterThan(0); - expect(rowCount).toBeLessThanOrEqual(140); - }, - { timeout: 15000 }, - ); - }, 30000); - - it('Profile L: keeps mounted rows capped under large load', async () => { - const resources = makeResources(PROFILES.L); - const { container } = render(() => ( - - )); - - await waitFor(() => { - expect(container.querySelector('table')).toBeInTheDocument(); - }); - await waitFor( - () => { - const rowCount = getBodyRowCount(container); - expect(rowCount).toBeGreaterThan(0); - expect(rowCount).toBeLessThanOrEqual(140); - }, - { timeout: 15000 }, - ); - }, 20000); - }); - - describe('Grouped vs flat mode contract', () => { - it('renders grouped mode with profile S resources', async () => { - const resources = makeResources(PROFILES.S, (i) => ({ - clusterId: i % 2 === 0 ? 'cluster-a' : 'cluster-b', - })); - const { container, getByText } = render(() => ( - - )); - - await waitFor( - () => { - expect(getByText('cluster-a')).toBeInTheDocument(); - expect(getByText('cluster-b')).toBeInTheDocument(); - }, - { timeout: 15000 }, - ); - await waitFor( - () => { - expect(getBodyRowCount(container)).toBeGreaterThan(0); - expect(getBodyRowCount(container)).toBeLessThanOrEqual(140); - }, - { timeout: 15000 }, - ); - }, 30000); - - it('renders flat mode with profile S resources', async () => { - const resources = makeResources(PROFILES.S, (i) => ({ - clusterId: i % 2 === 0 ? 'cluster-a' : 'cluster-b', - })); - const { container, queryByText } = render(() => ( - - )); - - await waitFor( - () => { - expect(container.querySelector('table')).toBeInTheDocument(); - }, - { timeout: 15000 }, - ); - await waitFor( - () => { - expect(getBodyRowCount(container)).toBeGreaterThan(0); - expect(getBodyRowCount(container)).toBeLessThanOrEqual(140); - }, - { timeout: 15000 }, - ); - expect(queryByText('cluster-a')).not.toBeInTheDocument(); - expect(queryByText('cluster-b')).not.toBeInTheDocument(); - }, 30000); - }); - - describe('Infrastructure derivation contracts', () => { - it('filterResources returns correct count after source filter on Profile S', () => { - const resources = makeResources(PROFILES.S); - const proxmoxOnly = filterResources(resources, new Set(['proxmox-pve']), new Set(), []); - expect(proxmoxOnly.length).toBe(PROFILES.S); - }); - - it('sortResources preserves array length for all sort keys', () => { - const resources = makeResources(PROFILES.M); - for (const key of ['default', 'name', 'cpu', 'memory', 'disk']) { - const sorted = sortResources(resources, key, 'asc'); - expect(sorted).toHaveLength(PROFILES.M); - } - }); - - it('buildStatusOptions keeps canonical statuses ahead of unknown filters', () => { - expect(buildStatusOptions(new Set(['custom-state', 'paused', 'degraded']))).toEqual([ - { key: 'degraded', label: 'Degraded' }, - { key: 'paused', label: 'Paused' }, - { key: 'custom-state', label: 'custom-state' }, - ]); - }); - - it('groupResources produces correct total resource count in grouped mode', () => { - const resources = makeResources(PROFILES.S, (i) => ({ - clusterId: `cluster-${i % 3}`, - })); - const grouped = groupResources(resources, 'grouped'); - const total = grouped.reduce((sum, group) => sum + group.resources.length, 0); - expect(total).toBe(PROFILES.S); - expect(grouped.length).toBe(3); - }); - - it('splitPrimaryAndServiceResources partitions without losing resources', () => { - const resources = makeResources(PROFILES.S); - const { primaryResources, services } = splitPrimaryAndServiceResources(resources); - expect(primaryResources.length + services.length).toBe(PROFILES.S); - }); - - it('computeIOScale produces valid stats for Profile M', () => { - const resources = makeResources(PROFILES.M, (i) => ({ - network: { rxBytes: i * 100, txBytes: i * 50 }, - diskIO: { readRate: i * 10, writeRate: i * 5 }, - })); - const scale = computeIOScale(resources); - expect(scale.network.count).toBeGreaterThan(0); - expect(scale.diskIO.count).toBeGreaterThan(0); - expect(scale.network.median).toBeGreaterThan(0); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.policy-governance.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.policy-governance.test.tsx deleted file mode 100644 index 90a471152d..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.policy-governance.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render } from '@solidjs/testing-library'; -import type { Resource } from '@/types/resource'; -import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; - -if (typeof globalThis.ResizeObserver === 'undefined') { - globalThis.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - } as unknown as typeof ResizeObserver; -} - -vi.mock('@/hooks/useBreakpoint', () => ({ - useBreakpoint: () => ({ - isMobile: () => false, - isVisible: () => true, - }), -})); - -vi.mock('@/components/shared/responsive', () => ({ - ResponsiveMetricCell: () =>
metric
, -})); - -vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({ - ResourceDetailDrawer: () =>
drawer
, -})); - -const resource: Resource = { - id: 'resource-1', - type: 'agent', - name: 'sensitive-host', - displayName: 'Sensitive Host', - platformId: 'platform-1', - platformType: 'agent', - sourceType: 'agent', - status: 'online', - lastSeen: Date.now(), - platformData: { sources: ['agent'] }, - policy: { - sensitivity: 'restricted', - routing: { - scope: 'local-only', - redact: ['hostname', 'alias'], - }, - }, - aiSafeSummary: 'restricted host summary safe for remote AI consumption', - recentChanges: [ - { - id: 'change-1', - observedAt: new Date().toISOString(), - resourceId: 'resource-1', - kind: 'state_transition', - sourceType: 'pulse_diff', - confidence: 'high', - }, - ], -}; - -describe('UnifiedResourceTable governance presentation', () => { - it('surfaces blocking policy posture in the resource row', () => { - const { getByText, queryByText } = render(() => ( - - )); - - expect(getByText('Local Only')).toBeInTheDocument(); - expect(queryByText('Restricted')).toBeNull(); - expect(getByText('Sensitive Host')).toBeInTheDocument(); - expect(queryByText('restricted host summary safe for remote AI consumption')).toBeNull(); - expect(queryByText('(sensitive-host)')).toBeNull(); - }); - - it('surfaces resource facet counts in the resource row', () => { - const facetResource: Resource = { - ...resource, - policy: { - sensitivity: 'sensitive', - routing: { - scope: 'local-first', - redact: ['hostname'], - }, - }, - }; - - const { getByText, queryByText } = render(() => ( - - )); - - expect(getByText('Timeline 1')).toBeInTheDocument(); - expect(queryByText('Capabilities 2')).toBeNull(); - expect(queryByText('Relationships 1')).toBeNull(); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx deleted file mode 100644 index 493b6e0a71..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, within } from '@solidjs/testing-library'; -import type { Resource } from '@/types/resource'; -import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; - -const responsiveMetricCellMock = vi.fn(); - -if (typeof globalThis.ResizeObserver === 'undefined') { - globalThis.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - } as unknown as typeof ResizeObserver; -} - -vi.mock('@/hooks/useBreakpoint', () => ({ - useBreakpoint: () => ({ - isMobile: () => false, - isVisible: () => true, - }), -})); - -vi.mock('@/components/shared/responsive', () => ({ - ResponsiveMetricCell: (props: unknown) => { - responsiveMetricCellMock(props); - return
metric
; - }, -})); - -vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({ - ResourceDetailDrawer: () =>
drawer
, -})); - -const baseResource = (overrides: Partial): Resource => ({ - id: 'resource-1', - type: 'agent', - name: 'pve1', - displayName: 'pve1', - platformId: 'pve1', - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'online', - lastSeen: Date.now(), - platformData: { sources: ['proxmox'] }, - ...overrides, -}); - -describe('UnifiedResourceTable workloads links', () => { - it('passes canonical metrics keys to metric cells for docker-host resources', async () => { - responsiveMetricCellMock.mockClear(); - - render(() => ( - - )); - - expect(responsiveMetricCellMock).toHaveBeenCalled(); - expect( - responsiveMetricCellMock.mock.calls.some( - ([props]) => (props as { resourceId?: string }).resourceId === 'dockerHost:docker-host-1', - ), - ).toBe(true); - expect(screen.getByText('Tower').tagName).toBe('SPAN'); - expect(screen.getByRole('link', { name: 'Open web interface for Tower' })).toHaveAttribute( - 'href', - 'https://tower.internal:9443', - ); - }); - - it('no longer renders host-row workloads cross-jump links in the platform-first layout', () => { - const resources: Resource[] = [ - baseResource({ - id: 'node-1', - type: 'agent', - platformData: { - sources: ['proxmox'], - proxmox: { nodeName: 'pve1' }, - }, - }), - baseResource({ - id: 'k8s-cluster-1', - type: 'k8s-cluster', - name: 'cluster-a', - displayName: 'cluster-a', - clusterId: 'cluster-a', - platformType: 'kubernetes', - sourceType: 'api', - platformData: { - sources: ['kubernetes'], - kubernetes: { - clusterName: 'cluster-a', - }, - }, - }), - ]; - - const { queryAllByRole } = render(() => ( - - )); - - // Legacy cross-jump from a host row to /workloads?... was retired with - // the standalone Workloads page; the platform pages expose workloads as - // adjacent sub-tabs instead. - expect(queryAllByRole('link', { name: /view workloads/i })).toHaveLength(0); - }); - - it('renders PBS and PMG resources in a dedicated service table with service-native columns', async () => { - const onExpandedResourceChange = vi.fn(); - const resources: Resource[] = [ - baseResource({ - id: 'pbs-1', - type: 'pbs', - name: 'pbs-main', - displayName: 'pbs-main', - customUrl: 'https://pbs-main.internal:8007', - platformType: 'proxmox-pbs', - sourceType: 'api', - platformData: { - sources: ['pbs'], - pbs: { - datastoreCount: 2, - backupJobCount: 2, - syncJobCount: 1, - backupJobs: [ - { - id: 'backup-nightly', - store: 'fast', - type: 'vm', - vmid: '100', - lastBackup: '', - nextRun: '', - status: 'running', - error: '', - }, - { - id: 'backup-weekly', - store: 'archive', - type: 'ct', - vmid: '200', - lastBackup: '', - nextRun: '', - status: 'ok', - error: '', - }, - ], - syncJobs: [ - { - id: 'sync-remote', - store: 'fast', - remote: 'offsite', - status: 'queued', - lastSync: '', - nextRun: '', - error: '', - }, - ], - }, - }, - recentChanges: [ - { - id: 'pbs-change-1', - observedAt: new Date().toISOString(), - resourceId: 'pbs-1', - kind: 'state_transition', - sourceType: 'pulse_diff', - confidence: 'high', - }, - ], - }), - baseResource({ - id: 'pmg-1', - type: 'pmg', - name: 'pmg-main', - displayName: 'pmg-main', - customUrl: 'https://pmg-main.internal:8006', - platformType: 'proxmox-pmg', - sourceType: 'api', - platformData: { - sources: ['pmg'], - pmg: { - queueTotal: 519, - nodeCount: 1, - }, - }, - recentChanges: [ - { - id: 'pmg-change-1', - observedAt: new Date().toISOString(), - resourceId: 'pmg-1', - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'medium', - }, - ], - }), - ]; - - const { getByText, getByRole, getAllByText } = render(() => ( - - )); - - expect(getByText('Service Infrastructure')).toBeInTheDocument(); - expect(getByText('PBS Services')).toBeInTheDocument(); - expect(getByText('PMG Services')).toBeInTheDocument(); - // Service Infrastructure dual-table renders the compact column label. - expect(getByText('Stores')).toBeInTheDocument(); - expect(getByText('Activity')).toBeInTheDocument(); - // Compact layout uses 'Action' for the action column header. - expect(getAllByText('Action').length).toBeGreaterThan(0); - expect(getByText('Queue')).toBeInTheDocument(); - // Compact layout abbreviates Deferred to 'Def'. - expect(getByText('Def')).toBeInTheDocument(); - expect(getByText('Hold')).toBeInTheDocument(); - expect(getByText('Nodes')).toBeInTheDocument(); - expect(getByText('pbs-main').tagName).toBe('SPAN'); - expect(getByText('pmg-main').tagName).toBe('SPAN'); - expect(getByRole('link', { name: 'Open web interface for pbs-main' })).toHaveAttribute( - 'href', - 'https://pbs-main.internal:8007', - ); - expect(getByRole('link', { name: 'Open web interface for pmg-main' })).toHaveAttribute( - 'href', - 'https://pmg-main.internal:8006', - ); - - const pbsRow = getByText('pbs-main').closest('tr'); - expect(pbsRow).toBeTruthy(); - if (pbsRow) { - const row = within(pbsRow); - expect(row.getByText('2')).toBeInTheDocument(); - expect(row.getByText('2 active')).toBeInTheDocument(); - expect(row.getByText('3 total')).toBeInTheDocument(); - expect(row.getByText('Timeline 1')).toBeInTheDocument(); - expect(row.queryByText('Capabilities 1')).toBeNull(); - expect(row.queryByText('Relationships 1')).toBeNull(); - } - - const pmgRow = getByText('pmg-main').closest('tr'); - expect(pmgRow).toBeTruthy(); - if (pmgRow) { - const row = within(pmgRow); - expect(row.getByText('519')).toBeInTheDocument(); - expect(row.getByText('1')).toBeInTheDocument(); - expect(row.getByText('Timeline 1')).toBeInTheDocument(); - expect(row.queryByText('Capabilities 1')).toBeNull(); - expect(row.queryByText('Relationships 1')).toBeNull(); - } - - const pmgLink = getByRole('link', { name: /open pmg thresholds/i }); - expect(pmgLink).toHaveTextContent('Thresholds'); - expect(pmgLink).toHaveAttribute('href', '/alerts/thresholds/mail-gateway'); - pmgLink.addEventListener('click', (event) => event.preventDefault()); - await fireEvent.click(pmgLink); - expect(onExpandedResourceChange).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.branchcov.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.branchcov.test.ts deleted file mode 100644 index 6157ef3b2b..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.branchcov.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; -import { - buildInfrastructureSummaryGroupScope, - computeIOScale, - filterResources, - getOutlierEmphasis, - infrastructureHasVisibleSummaryGroupScope, - matchesSearch, - sortResources, -} from '@/components/Infrastructure/infrastructureSelectors'; - -/** - * Branch-coverage suite for the currently-uncovered branches of the target - * functions in infrastructureSelectors.ts. The module-private helpers - * (getSortValue, compareValues, defaultComparison, computeMedian, - * computePercentile) are NOT exported, so they are exercised exclusively - * through the exported entry points (sortResources, computeIOScale) and - * asserted on observable return values — never re-implemented or imported - * directly. The sibling infrastructureSelectors.test.ts already covers the - * happy paths; this file targets only the remaining guard/edge arms. - */ - -const makeResource = (i: number, overrides: Partial = {}): Resource => ({ - id: `resource-${i}`, - type: 'agent', - name: `host-${i}`, - displayName: `Host ${i}`, - platformId: `platform-${i}`, - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'online', - lastSeen: 1_700_000_000_000 + i, - platformData: { sources: ['proxmox'] }, - ...overrides, -}); - -const BASELINE = { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false }; -const MEDIUM = { fontWeight: '500', color: 'text-base-content', showOutlierHint: true }; -const HIGH = { fontWeight: '600', color: 'text-base-content', showOutlierHint: true }; - -describe('getSortValue (observed via sortResources)', () => { - it('sorts by uptime and substitutes 0 when uptime is absent', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', uptime: 100 }), - makeResource(2, { displayName: 'Alpha', uptime: 50 }), - makeResource(3, { displayName: 'Charlie' }), // uptime ?? 0 - ]; - expect(sortResources(resources, 'uptime', 'asc').map((r) => r.id)).toEqual([ - 'resource-3', - 'resource-2', - 'resource-1', - ]); - }); - - it('sorts by memory percent and yields null when memory is absent', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', memory: { current: 80, total: 100, used: 80 } }), - makeResource(2, { displayName: 'Alpha', memory: { current: 20, total: 100, used: 20 } }), - makeResource(3, { displayName: 'Charlie' }), // null → bottom - ]; - expect(sortResources(resources, 'memory', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - 'resource-3', - ]); - }); - - it('sorts by disk percent and yields null when disk is absent', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', disk: { current: 90, total: 100, used: 90 } }), - makeResource(2, { displayName: 'Alpha', disk: { current: 10, total: 100, used: 10 } }), - makeResource(3, { displayName: 'Charlie' }), // null → bottom - ]; - expect(sortResources(resources, 'disk', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - 'resource-3', - ]); - }); - - it('sorts by network byte total and yields null when network is absent', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', network: { rxBytes: 100, txBytes: 50 } }), - makeResource(2, { displayName: 'Alpha', network: { rxBytes: 10, txBytes: 5 } }), - makeResource(3, { displayName: 'Charlie' }), // null → bottom - ]; - expect(sortResources(resources, 'network', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - 'resource-3', - ]); - }); - - it('sorts by disk io rate total and yields null when diskIO is absent', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', diskIO: { readRate: 200, writeRate: 100 } }), - makeResource(2, { displayName: 'Alpha', diskIO: { readRate: 20, writeRate: 10 } }), - makeResource(3, { displayName: 'Charlie' }), // null → bottom - ]; - expect(sortResources(resources, 'diskio', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - 'resource-3', - ]); - }); - - it('sorts by temperature and yields null when temperature is absent', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', temperature: 75 }), - makeResource(2, { displayName: 'Alpha', temperature: 35 }), - makeResource(3, { displayName: 'Charlie' }), // null → bottom - ]; - expect(sortResources(resources, 'temp', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - 'resource-3', - ]); - }); - - it('returns null for an unknown sort key so ordering falls back to default', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', status: 'offline' }), - makeResource(2, { displayName: 'Alpha', status: 'online' }), - ]; - // unknown key → getSortValue null for both → compareValues(null,null)=0 - // → defaultComparison (online first) - expect(sortResources(resources, 'totally-unknown-key', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - ]); - }); -}); - -describe('compareValues (observed via sortResources)', () => { - it('sorts a null metric after a finite metric in asc order (aEmpty branch)', () => { - const resources = [ - makeResource(1, { displayName: 'Alpha' }), // no cpu → null - makeResource(2, { displayName: 'Bravo', cpu: { current: 42 } }), - ]; - expect(sortResources(resources, 'cpu', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - ]); - }); - - it('places a finite metric before a null metric in asc order (bEmpty branch)', () => { - const resources = [ - makeResource(1, { displayName: 'Alpha', cpu: { current: 42 } }), - makeResource(2, { displayName: 'Bravo' }), // no cpu → null - ]; - expect(sortResources(resources, 'cpu', 'asc').map((r) => r.id)).toEqual([ - 'resource-1', - 'resource-2', - ]); - }); - - it('treats two null metric values as equal and defers to the default tie-break', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', status: 'online' }), // no cpu - makeResource(2, { displayName: 'Alpha', status: 'online' }), // no cpu - ]; - // compareValues(null,null) === 0 → defaultComparison → Alpha before Bravo - expect(sortResources(resources, 'cpu', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - ]); - }); -}); - -describe('defaultComparison (observed via sortResources default key)', () => { - it('orders two offline resources by display name via localeCompare', () => { - const resources = [ - makeResource(1, { displayName: 'Zeta', status: 'offline' }), - makeResource(2, { displayName: 'Alpha', status: 'offline' }), - ]; - expect(sortResources(resources, 'default', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - ]); - }); - - it('treats a "stopped" status as offline via the second isResourceOnline guard', () => { - const resources = [ - makeResource(1, { displayName: 'Stopped-One', status: 'stopped' }), - makeResource(2, { displayName: 'Online-One', status: 'online' }), - ]; - // 'stopped' → isResourceOnline false → online resource sorts first - expect(sortResources(resources, 'default', 'asc').map((r) => r.id)).toEqual([ - 'resource-2', - 'resource-1', - ]); - }); -}); - -describe('matchesSearch (branch coverage)', () => { - it('returns true for an empty search term', () => { - expect(matchesSearch(makeResource(1), '')).toBe(true); - }); - - it('matches against identity.hostname', () => { - const resource = makeResource(1, { - displayName: 'Showcase', - identity: { hostname: 'db-prod-01.example.com' }, - }); - expect(matchesSearch(resource, 'db-prod-01')).toBe(true); - expect(matchesSearch(resource, 'EXAMPLE.COM')).toBe(true); - }); - - it('does not crash and still matches by name/id when identity and tags are absent', () => { - const resource = makeResource(1, { identity: undefined }); - expect(matchesSearch(resource, 'resource-1')).toBe(true); - expect(matchesSearch(resource, 'host')).toBe(true); - expect(matchesSearch(resource, 'no-such-term')).toBe(false); - }); -}); - -describe('filterResources (branch coverage)', () => { - it('drops resources that carry no platform sources when a sources filter is active', () => { - const resources = [ - makeResource(1, { platformData: { sources: ['proxmox'] } }), - makeResource(2, { platformData: {} }), // resourceSources.length === 0 → excluded - makeResource(3, { platformData: { sources: ['docker'] } }), - ]; - expect( - filterResources(resources, new Set(['proxmox-pve', 'docker']), new Set(), []).map( - (r) => r.id, - ), - ).toEqual(['resource-1', 'resource-3']); - }); - - it('excludes resources whose sources do not intersect the active source filter', () => { - const resources = [ - makeResource(1, { platformData: { sources: ['proxmox'] } }), - makeResource(2, { platformData: { sources: ['docker'] } }), - ]; - expect( - filterResources(resources, new Set(['proxmox-pve']), new Set(), []).map((r) => r.id), - ).toEqual(['resource-1']); - }); - - it('normalizes a missing status to "unknown" and filters against it', () => { - const resources = [ - makeResource(1, { status: 'online' }), - makeResource(2, { status: '' as unknown as Resource['status'] }), // || 'unknown' - makeResource(3, { status: 'degraded' }), - ]; - expect( - filterResources(resources, new Set(), new Set(['unknown']), []).map((r) => r.id), - ).toEqual(['resource-2']); - }); -}); - -describe('buildInfrastructureSummaryGroupScope (branch coverage)', () => { - it('returns null when the group has no resources', () => { - expect(buildInfrastructureSummaryGroupScope({ cluster: 'alpha', resources: [] })).toBeNull(); - }); - - it('returns null when every resource id is blank or whitespace', () => { - const resources = [makeResource(1, { id: ' ' }), makeResource(2, { id: '' })]; - expect(buildInfrastructureSummaryGroupScope({ cluster: 'alpha', resources })).toBeNull(); - }); - - it('deduplicates series ids and emits a plural cluster label', () => { - const resources = [ - makeResource(1, { id: 'node-a' }), - makeResource(2, { id: 'node-a' }), - makeResource(3, { id: 'node-b' }), - ]; - expect(buildInfrastructureSummaryGroupScope({ cluster: 'beta', resources })).toEqual({ - id: 'cluster:beta', - label: 'beta (3 resources)', - seriesIds: ['node-a', 'node-b'], - }); - }); - - it('emits a standalone label and plural resourceCount for an empty cluster', () => { - const resources = [makeResource(1, { id: 'solo-1' }), makeResource(2, { id: 'solo-2' })]; - expect(buildInfrastructureSummaryGroupScope({ cluster: '', resources })).toEqual({ - id: 'cluster:__standalone__', - label: 'Standalone (2 resources)', - seriesIds: ['solo-1', 'solo-2'], - }); - }); -}); - -describe('infrastructureHasVisibleSummaryGroupScope (branch coverage)', () => { - const resources = [makeResource(1, { id: 'node-a' })]; - - it('returns false when scope is null', () => { - expect(infrastructureHasVisibleSummaryGroupScope(resources, null)).toBe(false); - }); - - it('returns false when scope is undefined', () => { - expect(infrastructureHasVisibleSummaryGroupScope(resources, undefined)).toBe(false); - }); - - it('returns false when resources is empty but scope is present', () => { - expect( - infrastructureHasVisibleSummaryGroupScope([], { - id: 'cluster:alpha', - label: 'alpha (1 resource)', - seriesIds: ['node-a'], - }), - ).toBe(false); - }); - - it('trims series ids before comparing against visible resource ids', () => { - expect( - infrastructureHasVisibleSummaryGroupScope(resources, { - id: 'cluster:alpha', - label: 'alpha (1 resource)', - seriesIds: [' node-a '], - }), - ).toBe(true); - }); -}); - -describe('getOutlierEmphasis (branch coverage)', () => { - const denseStats = { median: 10, mad: 2, max: 30, p97: 20, p99: 25, count: 10 }; - - it('returns baseline for a non-finite value', () => { - expect(getOutlierEmphasis(Number.NaN, denseStats)).toEqual(BASELINE); - }); - - it('returns baseline when value is <= 0', () => { - expect(getOutlierEmphasis(0, denseStats)).toEqual(BASELINE); - expect(getOutlierEmphasis(-5, denseStats)).toEqual(BASELINE); - }); - - it('returns baseline when stats.max <= 0', () => { - expect(getOutlierEmphasis(5, { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 5 })).toEqual( - BASELINE, - ); - }); - - describe('small sample path (count < 4)', () => { - const smallStats = { median: 50, mad: 0, max: 100, p97: 99, p99: 99, count: 3 }; - - it('returns medium emphasis at and above the 0.995 ratio boundary', () => { - expect(getOutlierEmphasis(100, smallStats)).toEqual(MEDIUM); // ratio 1.0 - expect(getOutlierEmphasis(99.5, smallStats)).toEqual(MEDIUM); // ratio 0.995 - }); - - it('returns baseline just below the 0.995 ratio boundary', () => { - expect(getOutlierEmphasis(99.4, smallStats)).toEqual(BASELINE); // ratio 0.994 - expect(getOutlierEmphasis(50, smallStats)).toEqual(BASELINE); - }); - }); - - describe('flat distribution path (mad === 0, count >= 4)', () => { - const flatStats = { median: 50, mad: 0, max: 100, p97: 80, p99: 90, count: 5 }; - - it('returns high emphasis when value >= p99', () => { - expect(getOutlierEmphasis(90, flatStats)).toEqual(HIGH); - expect(getOutlierEmphasis(95, flatStats)).toEqual(HIGH); - }); - - it('returns medium emphasis when value >= p97 but < p99', () => { - expect(getOutlierEmphasis(80, flatStats)).toEqual(MEDIUM); - expect(getOutlierEmphasis(85, flatStats)).toEqual(MEDIUM); - }); - - it('returns baseline when value > 0 but < p97', () => { - expect(getOutlierEmphasis(70, flatStats)).toEqual(BASELINE); - }); - }); - - it('returns baseline when modifiedZ is high but value sits below p97 (both && guards fail)', () => { - // median 10, mad 0.1, value 15 → modifiedZ = 0.6745 * 50 = 33.7 (>= 6.5) - // but 15 < p99(25) and 15 < p97(20) → neither outlier guard passes. - const stats = { median: 10, mad: 0.1, max: 30, p97: 20, p99: 25, count: 10 }; - expect(getOutlierEmphasis(15, stats)).toEqual(BASELINE); - }); -}); - -describe('computeMedian / computePercentile (observed via computeIOScale)', () => { - it('averages the two middle values for an even count of samples', () => { - const resources = [ - makeResource(1, { - network: { rxBytes: 10, txBytes: 0 }, // total 10 - diskIO: { readRate: 1, writeRate: 0 }, // total 1 - }), - makeResource(2, { - network: { rxBytes: 20, txBytes: 0 }, // total 20 - diskIO: { readRate: 3, writeRate: 0 }, // total 3 - }), - ]; - expect(computeIOScale(resources)).toEqual({ - network: { median: 15, mad: 5, max: 20, p97: 20, p99: 20, count: 2 }, - diskIO: { median: 2, mad: 1, max: 3, p97: 3, p99: 3, count: 2 }, - }); - }); - - it('selects the upper percentile element for a larger odd-length sample', () => { - const values = [2, 4, 6, 8, 100]; - const resources = values.map((rxBytes, i) => - makeResource(i + 1, { network: { rxBytes, txBytes: 0 } }), - ); - expect(computeIOScale(resources).network).toEqual({ - median: 6, // sorted[2] - mad: 2, // deviations from 6: 4,2,0,2,94 → sorted 0,2,2,4,94 → median 2 - max: 100, - p97: 100, // index = ceil(0.97 * 5) - 1 = 4 - p99: 100, - count: 5, - }); - }); - - it('reports zeroed diskIO stats when no resource contributes diskIO samples', () => { - const resources = [ - makeResource(1, { network: { rxBytes: 5, txBytes: 5 } }), - makeResource(2, { network: { rxBytes: 15, txBytes: 5 } }), - ]; - expect(computeIOScale(resources).diskIO).toEqual({ - median: 0, - mad: 0, - max: 0, - p97: 0, - p99: 0, - count: 0, - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts deleted file mode 100644 index 23406beda9..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; -import type { IODistributionStats } from '@/components/Infrastructure/infrastructureSelectors'; -import { - buildInfrastructureSummaryGroupId, - buildInfrastructureSummaryGroupScope, - buildStatusOptions, - collectAvailableSources, - collectAvailableStatuses, - computeIOScale, - filterResources, - getOutlierEmphasis, - getResourceSources, - groupResources, - infrastructureHasVisibleSummaryGroupScope, - matchesSearch, - sortResources, - splitPrimaryAndServiceResources, - tokenizeSearch, -} from '@/components/Infrastructure/infrastructureSelectors'; -import { normalizeSourcePlatformKey } from '@/utils/sourcePlatforms'; - -const makeResource = (i: number, overrides: Partial = {}): Resource => ({ - id: `resource-${i}`, - type: 'agent', - name: `host-${i}`, - displayName: `Host ${i}`, - platformId: `platform-${i}`, - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'online', - lastSeen: 1_700_000_000_000 + i, - platformData: { sources: ['proxmox'] }, - ...overrides, -}); - -describe('infrastructureSelectors', () => { - describe('tokenizeSearch', () => { - it('handles empty and whitespace-only queries', () => { - expect(tokenizeSearch('')).toEqual([]); - expect(tokenizeSearch(' ')).toEqual([]); - }); - - it('tokenizes and normalizes single and multi-word input', () => { - expect(tokenizeSearch('Host')).toEqual(['host']); - expect(tokenizeSearch(' Alpha Beta GAMMA ')).toEqual(['alpha', 'beta', 'gamma']); - }); - }); - - describe('matchesSearch', () => { - const resource = makeResource(1, { - id: 'node-123', - name: 'alpha-node', - displayName: 'Alpha Node', - identity: { hostname: 'alpha.local', ips: ['10.0.0.10', '192.168.1.10'] }, - tags: ['edge', 'database'], - }); - - it('matches by name/display/id', () => { - expect(matchesSearch(resource, 'alpha')).toBe(true); - expect(matchesSearch(resource, 'NODE-123')).toBe(true); - }); - - it('matches policy-redacted resources by their raw display name in operator-local search', () => { - // The /infrastructure search box is local UI; redaction is a transmission - // policy (docs/PRIVACY.md). Plus the haystack already includes raw - // hostname/ips, so the displayName must be raw too for consistency. - const governedResource = makeResource(2, { - name: 'secret-node-2', - displayName: 'secret-node-2', - policy: { - sensitivity: 'restricted', - routing: { scope: 'local-only', redact: ['hostname'] }, - }, - aiSafeSummary: 'Production Node', - }); - - expect(matchesSearch(governedResource, 'secret-node-2')).toBe(true); - expect(matchesSearch(governedResource, 'Production')).toBe(false); - }); - - it('matches by ip and tag and returns false when missing', () => { - expect(matchesSearch(resource, '192.168.1.10')).toBe(true); - expect(matchesSearch(resource, 'database')).toBe(true); - expect(matchesSearch(resource, 'not-found')).toBe(false); - }); - }); - - describe('getResourceSources and collectors', () => { - it('normalizes legacy infrastructure source aliases to canonical platform keys', () => { - expect(normalizeSourcePlatformKey('proxmox')).toBe('proxmox-pve'); - expect(normalizeSourcePlatformKey('pbs')).toBe('proxmox-pbs'); - expect(normalizeSourcePlatformKey('pmg')).toBe('proxmox-pmg'); - expect(normalizeSourcePlatformKey('k8s')).toBe('kubernetes'); - }); - - it('normalizes and deduplicates resource sources', () => { - const resource = makeResource(1, { - platformData: { - sources: ['PVE', 'proxmox-pve', 'k8s', 'kubernetes', 'network-endpoint', 'invalid'], - }, - }); - expect(getResourceSources(resource)).toEqual(['proxmox-pve', 'kubernetes', 'availability']); - }); - - it('collects available sources and statuses with deduplication', () => { - const uppercaseStatus = makeResource(3, { - status: 'ONLINE' as unknown as Resource['status'], - }); - const resources = [ - makeResource(1, { status: 'online', platformData: { sources: ['proxmox', 'pve'] } }), - makeResource(2, { status: 'offline', platformData: { sources: ['docker', 'docker'] } }), - makeResource(4, { status: 'online', platformData: { sources: ['availability'] } }), - uppercaseStatus, - ]; - - expect(collectAvailableSources(resources)).toEqual( - new Set(['proxmox-pve', 'docker', 'availability']), - ); - expect(collectAvailableStatuses(resources)).toEqual(new Set(['online', 'offline'])); - }); - }); - - describe('buildStatusOptions', () => { - it('orders by predefined status order and labels known statuses', () => { - const statuses = new Set(['running', 'foo', 'offline', 'online']); - expect(buildStatusOptions(statuses)).toEqual([ - { key: 'online', label: 'Online' }, - { key: 'offline', label: 'Offline' }, - { key: 'running', label: 'Running' }, - { key: 'foo', label: 'foo' }, - ]); - }); - - it('keeps unknown statuses sortable without widening the canonical order contract', () => { - const statuses = new Set(['custom-state', 'paused', 'degraded']); - expect(buildStatusOptions(statuses)).toEqual([ - { key: 'degraded', label: 'Degraded' }, - { key: 'paused', label: 'Paused' }, - { key: 'custom-state', label: 'custom-state' }, - ]); - }); - }); - - describe('filterResources', () => { - const resources = [ - makeResource(1, { - name: 'alpha-node', - status: 'online', - identity: { ips: ['10.0.0.1'] }, - platformData: { sources: ['proxmox'] }, - }), - makeResource(2, { - name: 'beta-node', - status: 'offline', - identity: { ips: ['10.0.0.2'] }, - platformData: { sources: ['docker'] }, - }), - makeResource(3, { - name: 'gamma-node', - displayName: 'Gamma Node', - status: 'degraded', - tags: ['prod', 'db'], - platformData: { sources: ['k8s'] }, - }), - ]; - - it('returns original array when no filters are active', () => { - const result = filterResources(resources, new Set(), new Set(), []); - expect(result).toBe(resources); - }); - - it('filters by sources only', () => { - const result = filterResources(resources, new Set(['docker']), new Set(), []); - expect(result.map((resource) => resource.id)).toEqual(['resource-2']); - }); - - it('filters by statuses only', () => { - const result = filterResources(resources, new Set(), new Set(['degraded']), []); - expect(result.map((resource) => resource.id)).toEqual(['resource-3']); - }); - - it('filters by search terms only', () => { - const result = filterResources(resources, new Set(), new Set(), ['10.0.0.2']); - expect(result.map((resource) => resource.id)).toEqual(['resource-2']); - }); - - it('applies combined filters and requires all terms to match', () => { - const result = filterResources(resources, new Set(['kubernetes']), new Set(['degraded']), [ - 'gamma', - 'db', - ]); - expect(result.map((resource) => resource.id)).toEqual(['resource-3']); - }); - }); - - describe('splitPrimaryAndServiceResources', () => { - it('splits pbs/pmg as services and everything else as primary resources', () => { - const resources = [ - makeResource(1, { type: 'agent' }), - makeResource(2, { type: 'agent' }), - makeResource(3, { type: 'pbs' }), - makeResource(4, { type: 'pmg' }), - ]; - const { primaryResources, services } = splitPrimaryAndServiceResources(resources); - expect(primaryResources.map((resource) => resource.id)).toEqual(['resource-1', 'resource-2']); - expect(services.map((resource) => resource.id)).toEqual(['resource-3', 'resource-4']); - }); - }); - - describe('sortResources', () => { - it('uses default sort: online first, then display name', () => { - const resources = [ - makeResource(1, { status: 'offline', displayName: 'Alpha' }), - makeResource(2, { status: 'online', displayName: 'Beta' }), - makeResource(3, { status: 'online', displayName: 'Alpha' }), - ]; - const sorted = sortResources(resources, 'default', 'asc'); - expect(sorted.map((resource) => resource.id)).toEqual([ - 'resource-3', - 'resource-2', - 'resource-1', - ]); - }); - - it('sorts by name in ascending order', () => { - const resources = [ - makeResource(1, { displayName: 'Charlie' }), - makeResource(2, { displayName: 'alpha' }), - makeResource(3, { displayName: 'Bravo' }), - ]; - const sorted = sortResources(resources, 'name', 'asc'); - expect(sorted.map((resource) => resource.id)).toEqual([ - 'resource-2', - 'resource-3', - 'resource-1', - ]); - }); - - it('sorts the visible system column by displayed infrastructure identity', () => { - const resources = [ - makeResource(1, { - displayName: 'PVE hybrid', - platformType: 'proxmox-pve', - sourceType: 'hybrid', - }), - makeResource(2, { - displayName: 'Docker only', - type: 'docker-host', - platformType: 'docker', - sourceType: 'api', - platformData: { sources: ['docker'] }, - }), - makeResource(3, { - displayName: 'Unraid runtime', - type: 'docker-host', - platformType: 'docker', - sourceType: 'hybrid', - platformData: { - sources: ['agent', 'docker'], - agent: { - platform: 'linux', - hostProfile: 'unraid', - osName: 'Unraid', - }, - }, - }), - ]; - - const sorted = sortResources(resources, 'source', 'asc'); - expect(sorted.map((resource) => resource.id)).toEqual([ - 'resource-2', - 'resource-1', - 'resource-3', - ]); - }); - - it('sorts cpu with direction toggle and keeps default tie-breaker', () => { - const resources = [ - makeResource(1, { displayName: 'Bravo', cpu: { current: 80 } }), - makeResource(2, { displayName: 'Alpha', cpu: { current: 80 } }), - makeResource(3, { displayName: 'Charlie', cpu: { current: 20 } }), - ]; - - const asc = sortResources(resources, 'cpu', 'asc'); - expect(asc.map((resource) => resource.id)).toEqual([ - 'resource-3', - 'resource-2', - 'resource-1', - ]); - - const desc = sortResources(resources, 'cpu', 'desc'); - expect(desc.map((resource) => resource.id)).toEqual([ - 'resource-2', - 'resource-1', - 'resource-3', - ]); - }); - - it('sorts by aggregate agent disks when the canonical disk summary is absent', () => { - const resources = [ - makeResource(1, { - displayName: 'Higher usage', - agent: { disks: [{ total: 100, used: 80, free: 20 }] }, - }), - makeResource(2, { - displayName: 'Lower usage', - agent: { - disks: [ - { total: 100, used: 10, free: 90 }, - { total: 300, used: 30, free: 270 }, - ], - }, - }), - ]; - - expect(sortResources(resources, 'disk', 'asc').map((resource) => resource.id)).toEqual([ - 'resource-2', - 'resource-1', - ]); - }); - }); - - describe('groupResources', () => { - const sortedResources = [ - makeResource(1, { clusterId: 'zeta' }), - makeResource(2, { clusterId: 'alpha' }), - makeResource(3, { clusterId: '' }), - makeResource(4, {}), - ]; - - it('returns single group in flat mode', () => { - expect(groupResources(sortedResources, 'flat')).toEqual([ - { cluster: '', resources: sortedResources }, - ]); - }); - - it('groups by cluster and sorts clusters with empty cluster last', () => { - expect(groupResources(sortedResources, 'grouped')).toEqual([ - { cluster: 'alpha', resources: [sortedResources[1]] }, - { cluster: 'zeta', resources: [sortedResources[0]] }, - { cluster: '', resources: [sortedResources[2], sortedResources[3]] }, - ]); - }); - - it('builds canonical summary group scopes for cluster and standalone headers', () => { - const [alphaGroup, standaloneGroup] = [ - { cluster: 'alpha', resources: [sortedResources[1]] }, - { cluster: '', resources: [sortedResources[2], sortedResources[3]] }, - ]; - - expect(buildInfrastructureSummaryGroupId('alpha')).toBe('cluster:alpha'); - expect(buildInfrastructureSummaryGroupId('')).toBe('cluster:__standalone__'); - expect(buildInfrastructureSummaryGroupScope(alphaGroup)).toEqual({ - id: 'cluster:alpha', - label: 'alpha (1 resource)', - seriesIds: ['resource-2'], - }); - expect(buildInfrastructureSummaryGroupScope(standaloneGroup)).toEqual({ - id: 'cluster:__standalone__', - label: 'Standalone (2 resources)', - seriesIds: ['resource-3', 'resource-4'], - }); - }); - - it('drops stale summary group hover once none of its resources are still visible', () => { - const scope = buildInfrastructureSummaryGroupScope({ - cluster: 'alpha', - resources: [sortedResources[1]], - }); - - expect(infrastructureHasVisibleSummaryGroupScope(sortedResources, scope)).toBe(true); - expect( - infrastructureHasVisibleSummaryGroupScope( - sortedResources.filter((resource) => resource.id !== 'resource-2'), - scope, - ), - ).toBe(false); - }); - }); - - describe('computeIOScale', () => { - it('computes expected stats for network and disk io', () => { - const resources = [ - makeResource(1, { - network: { rxBytes: 1, txBytes: 1 }, - diskIO: { readRate: 1, writeRate: 1 }, - }), - makeResource(2, { - network: { rxBytes: 3, txBytes: 1 }, - diskIO: { readRate: 2, writeRate: 2 }, - }), - makeResource(3, {}), - makeResource(4, { - network: { rxBytes: 10, txBytes: 0 }, - diskIO: { readRate: 8, writeRate: 0 }, - }), - ]; - - expect(computeIOScale(resources)).toEqual({ - network: { median: 4, mad: 2, max: 10, p97: 10, p99: 10, count: 3 }, - diskIO: { median: 4, mad: 2, max: 8, p97: 8, p99: 8, count: 3 }, - }); - }); - - it('handles empty and single-resource inputs', () => { - expect(computeIOScale([])).toEqual({ - network: { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 }, - diskIO: { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 }, - }); - - expect( - computeIOScale([ - makeResource(1, { - network: { rxBytes: 7, txBytes: 3 }, - diskIO: { readRate: 11, writeRate: 9 }, - }), - ]), - ).toEqual({ - network: { median: 10, mad: 0, max: 10, p97: 10, p99: 10, count: 1 }, - diskIO: { median: 20, mad: 0, max: 20, p97: 20, p99: 20, count: 1 }, - }); - }); - }); - - describe('getOutlierEmphasis', () => { - const stats: IODistributionStats = { - median: 10, - mad: 2, - max: 30, - p97: 20, - p99: 25, - count: 10, - }; - - it('returns baseline styling below threshold', () => { - expect(getOutlierEmphasis(12, stats)).toEqual({ - fontWeight: 'normal', - color: 'text-muted', - showOutlierHint: false, - }); - }); - - it('returns medium emphasis for moderate outliers', () => { - expect(getOutlierEmphasis(27, stats)).toEqual({ - fontWeight: '500', - color: 'text-base-content', - showOutlierHint: true, - }); - }); - - it('returns high emphasis for strong outliers', () => { - expect(getOutlierEmphasis(30, stats)).toEqual({ - fontWeight: '600', - color: 'text-base-content', - showOutlierHint: true, - }); - }); - }); -}); - -it('does not compare partial disk observations as complete throughput totals', () => { - const known = [ - makeResource(1, { diskIO: { readRate: 0, writeRate: 0 } }), - makeResource(2, { diskIO: { readRate: 100, writeRate: 200 } }), - ]; - const partial = [ - makeResource(3, { diskIO: { readRate: 10000 } }), - makeResource(4, { diskIO: { writeRate: 0 } }), - makeResource(5, { diskIO: {} }), - ]; - expect(computeIOScale([...known, ...partial]).diskIO).toEqual(computeIOScale(known).diskIO); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts index e46e554bff..9a3f4849f0 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest'; import { buildCustomSensorRows, buildTemperatureRows, + HOST_AGENT_STOPPED_REPORTING_REASON, formatInteger, formatSensorName, formatSourceType, toAgentFromResource, toNodeFromProxmox, } from '@/components/Infrastructure/resourceDetailMappers'; -import type { Resource } from '@/types/resource'; +import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw'; +import resourceDetailDiscoveryModelSource from '@/components/Infrastructure/resourceDetailDiscoveryModel.ts?raw'; +import type { PhysicalDiskCollectionStatus, Resource } from '@/types/resource'; const createHybridHostResource = (): Resource => ({ @@ -201,6 +204,120 @@ describe('resourceDetailMappers', () => { }, ]); }); + + it('marks a silent agent disk temperature as last known and drops disks without one', () => { + const rows = buildTemperatureRows({ + smart: [ + { + device: 'sda', + temperature: 71, + // A host agent past its reporting lease keeps its last reading. + collection: { + temperature: { + state: 'unavailable', + source: 'host_agent', + reason: 'host agent stopped reporting', + }, + }, + }, + { + device: 'sdb', + temperature: 38, + collection: { temperature: { state: 'available', source: 'smartctl' } }, + }, + { + device: 'sdc', + temperature: 0, + collection: { + temperature: { + state: 'unsupported', + source: 'smartctl', + reason: 'device did not expose a temperature reading', + }, + }, + }, + ], + }); + + expect(rows).toEqual([ + { + label: 'Disk sda', + value: '71°C (last known)', + valueTitle: 'Last known reading, not current: host agent stopped reporting', + }, + { label: 'Disk sdb', value: '38°C', valueTitle: '38.0°C' }, + ]); + }); + + it("marks a silent agent's sensor readings last known and leaves disks to their own state", () => { + const rows = buildTemperatureRows( + { + thermalState: { pressure: 'serious' }, + temperatureCelsius: { 'cpu.package': 45 }, + fanRpm: { chassis_fan: 1200 }, + smart: [ + { + device: 'sda', + temperature: 38, + collection: { temperature: { state: 'available', source: 'smartctl' } }, + }, + ], + }, + { lastKnownReason: HOST_AGENT_STOPPED_REPORTING_REASON }, + ); + + const lastKnown = 'Last known reading, not current: host agent stopped reporting'; + expect(rows).toEqual([ + { label: 'Thermal pressure', value: 'Serious (last known)', valueTitle: lastKnown }, + { label: 'Cpu.package', value: '45°C (last known)', valueTitle: lastKnown }, + { label: 'Chassis Fan', value: '1,200 RPM (last known)', valueTitle: lastKnown }, + // A disk row follows its own collection state, which the backend + // withdraws when the lease expires. + { label: 'Disk sda', value: '38°C', valueTitle: '38.0°C' }, + ]); + expect(buildTemperatureRows({ temperatureCelsius: { 'cpu.package': 45 } })).toEqual([ + { label: 'Cpu.package', value: '45°C', valueTitle: '45.0°C' }, + ]); + }); + + it('lists a SMART row only for a positive reading in any state, never a standby disk', () => { + const states: (PhysicalDiskCollectionStatus | undefined)[] = [ + undefined, + { temperature: { state: 'available', source: 'smartctl' } }, + { temperature: { state: 'unavailable', source: 'smartctl' } }, + ]; + for (const collection of states) { + for (const temperature of [0, -3]) { + expect( + buildTemperatureRows({ smart: [{ device: 'sda', temperature, collection }] }), + ).toEqual([]); + } + } + + expect( + buildTemperatureRows({ + smart: [ + { + device: 'sda', + temperature: 44, + standby: true, + collection: { temperature: { state: 'unavailable', source: 'smartctl' } }, + }, + { + device: 'sdb', + temperature: 39, + collection: { temperature: { state: 'missing', source: 'smartctl' } }, + }, + ], + }), + ).toEqual([ + { + label: 'Disk sdb', + value: '39°C (last known)', + valueTitle: 'Last known reading, not current', + }, + ]); + }); }); describe('buildCustomSensorRows', () => { @@ -304,6 +421,41 @@ describe('resourceDetailMappers', () => { }, ]); }); + + it('formats an online boolean metric with an OK status', () => { + expect( + buildCustomSensorRows({ + custom: [ + { + id: 'service_online', + name: 'Checkout', + group: 'Main server', + subgroup: 'Services', + kind: 'boolean', + value: 1, + status: 'ok', + observedAt: '2026-07-30T20:00:00Z', + }, + ], + }), + ).toEqual([ + { + label: 'Main server / Services / Checkout', + value: 'Online', + valueTitle: 'Online · OK', + }, + ]); + }); + }); + + describe('detail row projection boundary', () => { + it('formats temperature, GPU, power, fan and custom rows from the selected resource payload only', () => { + expect(resourceDetailMappersSource).not.toContain('fetch('); + expect(resourceDetailMappersSource).not.toContain('nvidia-smi'); + expect(resourceDetailMappersSource).not.toContain('powercap'); + expect(resourceDetailMappersSource).not.toContain('export const toDiscoveryConfig'); + expect(resourceDetailDiscoveryModelSource).toContain('export const toDiscoveryConfig'); + }); }); describe('toNodeFromProxmox', () => { @@ -322,6 +474,15 @@ describe('resourceDetailMappers', () => { expect(agent?.id).not.toBe('resource:host:hash-1'); }); + it('reports no uptime for a silent agent, whose last report is not how long it has been up', () => { + const base = createHybridHostResource(); + const reporting = { ...base, uptime: 2_500_000 } as Resource; + expect(toAgentFromResource(reporting)?.uptimeSeconds).toBe(2_500_000); + + const silent = { ...reporting, agent: { stale: true } } as Resource; + expect(toAgentFromResource(silent)?.uptimeSeconds).toBe(0); + }); + it('preserves the local infrastructure display name for governed resources', () => { const agent = toAgentFromResource({ ...createHybridHostResource(), diff --git a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableModel.branchcov2.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableModel.branchcov2.test.ts deleted file mode 100644 index e69fea5320..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableModel.branchcov2.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; -import type { IODistributionStats } from '@/components/Infrastructure/infrastructureSelectors'; -import { - getOutlierEmphasis, - getPBSTableRow, - getPMGTableRow, -} from '@/components/Infrastructure/unifiedResourceTableModel'; - -/** - * Branch-coverage suite for the still-uncovered exported functions in - * unifiedResourceTableModel.ts: getPBSTableRow, getPMGTableRow, getOutlierEmphasis. - * Each test drives a specific branch (guard, ternary arm, optional-chain outcome) - * and asserts on the concrete returned shape — never truthiness alone. - */ - -const makeResource = (overrides: Partial & { id: string }): Resource => - ({ - type: 'agent', - name: `name-${overrides.id}`, - displayName: `Display ${overrides.id}`, - platformId: `platform-${overrides.id}`, - platformType: 'proxmox-pve', - sourceType: 'hybrid', - status: 'online', - lastSeen: 1_700_000_000_000, - platformData: {}, - ...overrides, - }) as unknown as Resource; - -const makeStats = (overrides: Partial = {}): IODistributionStats => ({ - median: 0, - mad: 0, - max: 100, - p97: 90, - p99: 95, - count: 10, - ...overrides, -}); - -describe('getPBSTableRow', () => { - it('returns null when resource.type is not pbs (guard)', () => { - expect(getPBSTableRow(makeResource({ id: 'a', type: 'pmg' }))).toBeNull(); - expect(getPBSTableRow(makeResource({ id: 'b', type: 'agent' }))).toBeNull(); - }); - - it('nulls every derived field when platformData has no pbs block (optional-chain miss)', () => { - const row = getPBSTableRow( - makeResource({ id: 'pbs-empty', type: 'pbs', status: 'online', platformData: {} }), - ); - expect(row).toEqual({ - datastores: null, - jobs: null, - activity: null, - activityDetail: null, - activeTaskCount: 0, - health: null, - tone: 'ok', - }); - }); - - it('surfaces a positive datastoreCount and jobs total, trims health, and derives tone from health', () => { - const row = getPBSTableRow( - makeResource({ - id: 'pbs-full', - type: 'pbs', - status: 'degraded', - platformData: { - pbs: { datastoreCount: 3, backupJobCount: 2, connectionHealth: ' Healthy ' }, - }, - }), - ); - expect(row).toEqual({ - datastores: 3, - jobs: 2, - activity: '2 jobs', - activityDetail: null, - activeTaskCount: 0, - health: 'Healthy', - tone: 'ok', - }); - }); - - it('nulls datastores/jobs for zero counts and maps whitespace health + offline status to a warning tone', () => { - const row = getPBSTableRow( - makeResource({ - id: 'pbs-zero', - type: 'pbs', - status: 'offline', - platformData: { pbs: { datastoreCount: 0, connectionHealth: ' ' } }, - }), - ); - expect(row).toEqual({ - datastores: null, - jobs: null, - activity: null, - activityDetail: null, - activeTaskCount: 0, - health: null, - tone: 'warning', - }); - }); -}); - -describe('getPMGTableRow', () => { - it('returns null when resource.type is not pmg (guard)', () => { - expect(getPMGTableRow(makeResource({ id: 'a', type: 'pbs' }))).toBeNull(); - expect(getPMGTableRow(makeResource({ id: 'b', type: 'agent' }))).toBeNull(); - }); - - it('nulls every derived field when platformData has no pmg block (optional-chain miss)', () => { - const row = getPMGTableRow( - makeResource({ id: 'pmg-empty', type: 'pmg', status: 'online', platformData: {} }), - ); - expect(row).toEqual({ - queue: null, - deferred: null, - hold: null, - nodes: null, - health: null, - tone: 'ok', - }); - }); - - it('reports positive queue/deferred/hold/nodes and forces a warning tone when backlog > 0', () => { - const row = getPMGTableRow( - makeResource({ - id: 'pmg-full', - type: 'pmg', - status: 'online', - platformData: { - pmg: { - queueTotal: 12, - queueDeferred: 3, - queueHold: 2, - nodeCount: 4, - connectionHealth: 'degraded', - }, - }, - }), - ); - expect(row).toEqual({ - queue: 12, - deferred: 3, - hold: 2, - nodes: 4, - health: 'degraded', - tone: 'warning', - }); - }); - - it('nulls zero counts, trims whitespace health to null, and derives tone from status when backlog is 0', () => { - const row = getPMGTableRow( - makeResource({ - id: 'pmg-zero', - type: 'pmg', - status: 'warning', - platformData: { - pmg: { - queueTotal: 5, - queueDeferred: 0, - queueHold: 0, - nodeCount: 0, - connectionHealth: ' ', - }, - }, - }), - ); - expect(row).toEqual({ - queue: 5, - deferred: null, - hold: null, - nodes: null, - health: null, - tone: 'warning', - }); - }); - - it('nulls queue when queueTotal is explicitly 0 with a present pmg block', () => { - const row = getPMGTableRow( - makeResource({ - id: 'pmg-no-queue', - type: 'pmg', - status: 'online', - platformData: { pmg: { queueTotal: 0, nodeCount: 1 } }, - }), - ); - expect(row).toEqual({ - queue: null, - deferred: null, - hold: null, - nodes: 1, - health: null, - tone: 'ok', - }); - }); -}); - -describe('getOutlierEmphasis', () => { - describe('guard branch (!isFinite(value) || value <= 0 || stats.max <= 0)', () => { - it('mutes a non-finite value (NaN)', () => { - expect(getOutlierEmphasis(Number.NaN, makeStats({ max: 100 }))).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - - it('mutes a non-finite value (Infinity)', () => { - expect(getOutlierEmphasis(Number.POSITIVE_INFINITY, makeStats({ max: 100 }))).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - - it('mutes a value of exactly 0', () => { - expect(getOutlierEmphasis(0, makeStats({ max: 100 }))).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - - it('mutes a negative value', () => { - expect(getOutlierEmphasis(-5, makeStats({ max: 100 }))).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - - it('mutes when stats.max is 0', () => { - expect(getOutlierEmphasis(50, makeStats({ max: 0 }))).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - - it('mutes when stats.max is negative', () => { - expect(getOutlierEmphasis(50, makeStats({ max: -1 }))).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - }); - - describe('small-sample branch (stats.count < 4)', () => { - it('emphasizes when value/max ratio >= 0.995 (exact boundary)', () => { - expect(getOutlierEmphasis(99.5, makeStats({ max: 100, count: 3 }))).toEqual({ - className: 'text-base-content font-medium', - showOutlierHint: true, - }); - }); - - it('emphasizes a ratio of 1.0', () => { - expect(getOutlierEmphasis(100, makeStats({ max: 100, count: 3 }))).toEqual({ - className: 'text-base-content font-medium', - showOutlierHint: true, - }); - }); - - it('mutes when value/max ratio < 0.995', () => { - expect(getOutlierEmphasis(80, makeStats({ max: 100, count: 3 }))).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - }); - - describe('mad-positive branch (stats.count >= 4, stats.mad > 0)', () => { - it('returns semibold for a severe outlier (modifiedZ >= 6.5 and value >= p99)', () => { - // modifiedZ = 0.6745 * (200 - 50) / 2 = 50.5875 (>= 6.5); value 200 >= p99 180 - const stats = makeStats({ median: 50, mad: 2, max: 200, p97: 150, p99: 180, count: 10 }); - expect(getOutlierEmphasis(200, stats)).toEqual({ - className: 'text-base-content font-semibold', - showOutlierHint: true, - }); - }); - - it('returns medium when modifiedZ is in [5.5, 6.5) and value >= p97 but < p99', () => { - // modifiedZ = 0.6745 * (90 - 0) / 10 = 6.0705 (in [5.5, 6.5)); value 90 >= p97 80, < p99 200 - const stats = makeStats({ median: 0, mad: 10, max: 300, p97: 80, p99: 200, count: 10 }); - expect(getOutlierEmphasis(90, stats)).toEqual({ - className: 'text-base-content font-medium', - showOutlierHint: true, - }); - }); - - it('mutes a non-outlier whose modifiedZ is below 5.5', () => { - // modifiedZ = 0.6745 * (55 - 50) / 10 = 0.337 (< 5.5) - const stats = makeStats({ median: 50, mad: 10, max: 200, p97: 80, p99: 180, count: 10 }); - expect(getOutlierEmphasis(55, stats)).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - - it('falls through to medium when modifiedZ >= 6.5 but value < p99 (skips severe, hits moderate)', () => { - // modifiedZ = 0.6745 * (70 - 50) / 2 = 6.745 (>= 6.5); value 70 >= p99 80 is FALSE; - // value 70 >= p97 60 is TRUE -> moderate path. - const stats = makeStats({ median: 50, mad: 2, max: 200, p97: 60, p99: 80, count: 10 }); - expect(getOutlierEmphasis(70, stats)).toEqual({ - className: 'text-base-content font-medium', - showOutlierHint: true, - }); - }); - }); - - describe('mad-zero fallback branch (stats.count >= 4, stats.mad <= 0)', () => { - it('returns semibold when value >= p99', () => { - const stats = makeStats({ mad: 0, max: 300, p97: 120, p99: 150, count: 10 }); - expect(getOutlierEmphasis(200, stats)).toEqual({ - className: 'text-base-content font-semibold', - showOutlierHint: true, - }); - }); - - it('returns medium when value >= p97 but < p99', () => { - const stats = makeStats({ mad: 0, max: 300, p97: 120, p99: 150, count: 10 }); - expect(getOutlierEmphasis(130, stats)).toEqual({ - className: 'text-base-content font-medium', - showOutlierHint: true, - }); - }); - - it('mutes a positive value below p97', () => { - const stats = makeStats({ mad: 0, max: 300, p97: 120, p99: 150, count: 10 }); - expect(getOutlierEmphasis(50, stats)).toEqual({ - className: 'text-muted', - showOutlierHint: false, - }); - }); - - it('honors an explicitly negative mad identically to mad === 0', () => { - // Defensive: mad <= 0 takes the fallback path; -1 must behave like 0. - const stats = makeStats({ mad: -1, max: 300, p97: 120, p99: 150, count: 10 }); - expect(getOutlierEmphasis(200, stats)).toEqual({ - className: 'text-base-content font-semibold', - showOutlierHint: true, - }); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.branchcov0724pm.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.branchcov0724pm.test.ts deleted file mode 100644 index b224899b81..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.branchcov0724pm.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; -import { - UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH, - buildHostRowIndexById, - buildHostTableItems, - getHostRevealTargetIndex, - getHostSpacerHeights, - getNextUnifiedResourceTableSortState, - normalizeUnifiedResourceTableLayoutWidth, - shouldShowClusterGroupTypeLabel, - sortServiceResources, -} from '@/components/Infrastructure/unifiedResourceTableStateModel'; - -/** - * Branch-coverage additions for unifiedResourceTableStateModel.ts targeting the - * arms left cold by the existing happy-path spec - * (unifiedResourceTableStateModel.test.ts). - * - * Each test drives a specific measured-cold branch (default fallback, map miss, - * nullish coalescing RHS, non-windowed early return, ternary consequent) and - * asserts on the concrete returned value — never a tautology. - * - * The makeResource fixture mirrors the existing spec's builder so import style - * and field shape stay consistent. - */ - -const makeResource = (id: string, overrides: Partial = {}): Resource => - ({ - id, - type: 'agent', - name: `name-${id}`, - displayName: `Display ${id}`, - platformId: `platform-${id}`, - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'online', - lastSeen: Date.now(), - platformData: { sources: ['proxmox'] }, - ...overrides, - }) as Resource; - -describe('normalizeUnifiedResourceTableLayoutWidth — default-fallback arm', () => { - // Drives branch 6 arm 0 (lines 69-71): the trailing - // `return UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH;` reached only when the - // width argument is invalid AND the fallback is also invalid. The existing - // spec only exercises a valid width or an invalid width with a valid - // fallback, so the final default return is never reached. - it('falls back to the default constant when the width is absent and the fallback is NaN', () => { - expect(normalizeUnifiedResourceTableLayoutWidth(null, Number.NaN)).toBe( - UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH, - ); - }); - - it('falls back to the default constant when the width is absent and the fallback is 0', () => { - expect(normalizeUnifiedResourceTableLayoutWidth(null, 0)).toBe( - UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH, - ); - }); -}); - -describe('getHostRevealTargetIndex — map-miss returns null', () => { - // Drives branch 31 arm 0 (line 150: `rowIndexById.get(targetId) ?? null`): - // the LHS of `??` is undefined because targetId is absent from the map. The - // existing spec always resolves a present id, so the nullish-coalescing RHS - // is never taken. - const items = buildHostTableItems( - [{ cluster: 'cluster-a', resources: [makeResource('a'), makeResource('b')] }], - 'grouped', - ); - const rowIndexById = buildHostRowIndexById(items); - - it('returns null when expandedResourceId is not present in the row index', () => { - // rowIndexById only holds 'a' (1) and 'b' (2); 'missing' is absent. - expect(getHostRevealTargetIndex(rowIndexById, 'missing', null)).toBeNull(); - }); - - it('returns null when revealedResourceId is the chosen target but absent from the row index', () => { - // expandedResourceId is null so revealedResourceId 'ghost' wins the chain, - // then misses the map lookup. - expect(getHostRevealTargetIndex(rowIndexById, null, null, 'ghost')).toBeNull(); - }); -}); - -describe('shouldShowClusterGroupTypeLabel — nullish clusterLabel arm', () => { - // Drives branch 35 arm 0 (line 166: `(clusterLabel ?? '')`): the RHS of `??` - // is only evaluated when clusterLabel is null/undefined. The existing spec - // passes only concrete strings, so the coalescing RHS stays cold. - it('returns false for a null clusterLabel without throwing', () => { - expect(shouldShowClusterGroupTypeLabel(null)).toBe(false); - }); - - it('returns false for an undefined clusterLabel without throwing', () => { - expect(shouldShowClusterGroupTypeLabel(undefined)).toBe(false); - }); -}); - -describe('getHostSpacerHeights — non-windowed early-return arm', () => { - // Drives branch 38 arm 0 (lines 177-179): the `if (!isWindowed)` consequent - // that returns `{ top: 0, bottom: 0 }`. The existing spec always calls with - // isWindowed = true, so the zero-spacer early return is never produced. - it('returns zero spacers regardless of window bounds when windowing is disabled', () => { - const result = getHostSpacerHeights(100, 5, 20, false); - expect(result).toEqual({ top: 0, bottom: 0 }); - }); - - it('returns zero spacers even when a custom row height is supplied', () => { - // Discriminator: a custom estimatedRowHeight (40) would otherwise scale the - // top spacer to 5 * 40 = 200; the early return suppresses it entirely. - const result = getHostSpacerHeights(100, 5, 20, false, 40); - expect(result).toEqual({ top: 0, bottom: 0 }); - expect(result.top).not.toBe(200); - }); -}); - -describe('getNextUnifiedResourceTableSortState — asc default for name/source', () => { - // Drives branch 43 arm 0 (line 204: the `'asc'` consequent of - // `nextKey === 'name' || nextKey === 'source' ? 'asc' : 'desc'`). The existing - // spec only enters the function with currentKey === nextKey (so it never - // reaches line 204) or with nextKey 'cpu' (which takes the 'desc' alternate). - it('initializes a name sort to ascending when switching from a different key', () => { - expect(getNextUnifiedResourceTableSortState('default', 'asc', 'name')).toEqual({ - key: 'name', - direction: 'asc', - }); - }); - - it('initializes a source sort to ascending when switching from a different key', () => { - // Also drives the second operand of the `||` (`nextKey === 'source'`) true. - expect(getNextUnifiedResourceTableSortState('cpu', 'desc', 'source')).toEqual({ - key: 'source', - direction: 'asc', - }); - }); -}); - -describe('sortServiceResources — uncovered exported function', () => { - // Drives fn 15 (sortServiceResources, line 217): the only exported function - // the existing spec never invokes. It must (a) filter to the requested - // service type and (b) delegate to sortResources with 'default'/'asc', which - // ranks online resources first then by display name. Assertions are made on - // the concrete id sequence so they cannot pass against an unfiltered or - // unsorted list. - const services: Resource[] = [ - makeResource('zebra', { type: 'pbs', displayName: 'Zeta', status: 'offline' }), - makeResource('alpha', { type: 'pbs', displayName: 'Alpha', status: 'online' }), - makeResource('mango', { type: 'pbs', displayName: 'Mango', status: 'online' }), - makeResource('pmg-only', { type: 'pmg', displayName: 'Should Be Filtered', status: 'online' }), - makeResource('agent-only', { type: 'agent', displayName: 'Also Filtered', status: 'online' }), - ]; - - it('returns only pbs resources, online-first then by display name', () => { - const result = sortServiceResources(services, 'pbs'); - expect(result.map((r) => r.id)).toEqual(['alpha', 'mango', 'zebra']); - expect(result.every((r) => r.type === 'pbs')).toBe(true); - }); - - it('returns only pmg resources when type is pmg', () => { - const result = sortServiceResources(services, 'pmg'); - expect(result.map((r) => r.id)).toEqual(['pmg-only']); - expect(result.every((r) => r.type === 'pmg')).toBe(true); - }); - - it('returns an empty array when the input list is empty', () => { - expect(sortServiceResources([], 'pbs')).toEqual([]); - }); - - it('does not mutate the input array', () => { - const snapshot = services.map((r) => r.id); - sortServiceResources(services, 'pbs'); - expect(services.map((r) => r.id)).toEqual(snapshot); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts deleted file mode 100644 index 1e028a9764..0000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; -import type { ResourceGroup } from '@/components/Infrastructure/infrastructureSelectors'; -import { - buildHostRowIndexById, - buildHostTableItems, - buildResourceLabelById, - getHostRevealTargetIndex, - getHostSpacerHeights, - getNextUnifiedResourceTableSortState, - getUnifiedResourceTableColumnPresentations, - getUnifiedResourceTableHeaderLabels, - getUnifiedResourceTableLayoutMode, - getUnifiedResourceTableShellClass, - getUnifiedResourceTableSortIndicator, - getUnifiedSources, - getVisibleHostTableItems, - isUnifiedResourceHostDiskIoVisible, - isUnifiedResourceServiceColumnVisible, - isUnifiedResourceTableColumnVisible, - normalizeUnifiedResourceTableLayoutWidth, - shouldShowClusterGroupTypeLabel, - shouldShowUnifiedResourceHostTable, - shouldUseUnifiedResourceTableMobileLayout, -} from '@/components/Infrastructure/unifiedResourceTableStateModel'; - -const makeResource = (id: string, overrides: Partial = {}): Resource => - ({ - id, - type: 'agent', - name: `name-${id}`, - displayName: `Display ${id}`, - platformId: `platform-${id}`, - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'online', - lastSeen: Date.now(), - platformData: { sources: ['proxmox'] }, - ...overrides, - }) as Resource; - -describe('unifiedResourceTableStateModel', () => { - it('builds resource labels from the canonical preferred display name', () => { - const labels = buildResourceLabelById([ - makeResource('a', { displayName: 'Alpha' }), - makeResource('b', { displayName: 'Beta' }), - ]); - - expect(labels.get('a')).toBe('Alpha'); - expect(labels.get('b')).toBe('Beta'); - }); - - it('builds host table items with group headers only in grouped mode', () => { - const groups: ResourceGroup[] = [ - { - cluster: 'cluster-a', - resources: [makeResource('a'), makeResource('b')], - }, - ]; - - expect(buildHostTableItems(groups, 'grouped').map((item) => item.type)).toEqual([ - 'header', - 'row', - 'row', - ]); - expect(buildHostTableItems(groups, 'flat').map((item) => item.type)).toEqual(['row', 'row']); - }); - - it('suppresses duplicate cluster type labels when the group name already ends with cluster', () => { - expect(shouldShowClusterGroupTypeLabel('Core Fabric')).toBe(true); - expect(shouldShowClusterGroupTypeLabel('Production Cluster')).toBe(false); - expect(shouldShowClusterGroupTypeLabel('production cluster')).toBe(false); - expect(shouldShowClusterGroupTypeLabel('')).toBe(false); - }); - - it('tracks reveal indexes only for row items', () => { - const items = buildHostTableItems( - [{ cluster: 'cluster-a', resources: [makeResource('a'), makeResource('b')] }], - 'grouped', - ); - const rowIndexById = buildHostRowIndexById(items); - - expect(getHostRevealTargetIndex(rowIndexById, 'b', null)).toBe(2); - expect(getHostRevealTargetIndex(rowIndexById, null, 'a')).toBe(1); - expect(getHostRevealTargetIndex(rowIndexById, null, null)).toBeNull(); - }); - - it('computes visible items and spacer heights from the active window', () => { - const items = buildHostTableItems( - [{ cluster: '', resources: [makeResource('a'), makeResource('b'), makeResource('c')] }], - 'flat', - ); - - expect(getVisibleHostTableItems(items, false, 1, 2)).toEqual(items); - expect( - getVisibleHostTableItems(items, true, 1, 3).map((item) => - item.type === 'row' ? item.resource.id : item.type, - ), - ).toEqual(['b', 'c']); - expect(getHostSpacerHeights(items.length, 1, 3, true, 40)).toEqual({ - top: 40, - bottom: 0, - }); - }); - - it('cycles sort state through asc, desc, then default', () => { - expect(getNextUnifiedResourceTableSortState('default', 'asc', 'cpu')).toEqual({ - key: 'cpu', - direction: 'desc', - }); - expect(getNextUnifiedResourceTableSortState('cpu', 'desc', 'cpu')).toEqual({ - key: 'default', - direction: 'asc', - }); - expect(getNextUnifiedResourceTableSortState('name', 'asc', 'name')).toEqual({ - key: 'name', - direction: 'desc', - }); - expect(getUnifiedResourceTableSortIndicator('name', 'asc', 'name')).toBe('▲'); - expect(getUnifiedResourceTableSortIndicator('cpu', 'desc', 'cpu')).toBe('▼'); - expect(getUnifiedResourceTableSortIndicator('default', 'asc', 'cpu')).toBeNull(); - }); - - it('derives responsive column presentations and host-table visibility as pure layout policy', () => { - const mobileColumns = getUnifiedResourceTableColumnPresentations('mobile'); - const narrowTabletColumns = getUnifiedResourceTableColumnPresentations('tablet', 760); - const tabletColumns = getUnifiedResourceTableColumnPresentations('tablet', 820); - const compactColumns = getUnifiedResourceTableColumnPresentations('compact'); - const wideColumns = getUnifiedResourceTableColumnPresentations('wide'); - - expect(getUnifiedResourceTableShellClass('mobile')).toContain('table-fixed'); - expect(getUnifiedResourceTableShellClass('mobile')).toContain('min-w-full'); - expect(getUnifiedResourceTableShellClass('mobile')).not.toContain('min-w-[640px]'); - expect(getUnifiedResourceTableShellClass('compact')).toContain('min-w-[640px]'); - expect(getUnifiedResourceTableShellClass('wide')).toContain('min-w-[640px]'); - expect(getUnifiedResourceTableShellClass('wide')).not.toContain('min-w-max'); - // Mobile keeps the prioritized column set but preserves a readable width - // floor inside the shared horizontal-scroll shell. Wider modes keep all - // host columns visible while compressing their tracks before any - // lower-priority columns are dropped. - expect(mobileColumns.resourceColumn.width).toBe('34%'); - expect(mobileColumns.metricColumn.width).toBe('14%'); - expect(mobileColumns.ioColumn.width).toBe('12%'); - expect(mobileColumns.uptimeColumn.width).toBe('12%'); - expect(mobileColumns.serviceResourceColumn.width).toBe('28%'); - expect(mobileColumns.serviceSourceColumn.width).toBe('10%'); - expect(mobileColumns.serviceCountColumn.width).toBe('11%'); - expect(mobileColumns.serviceHealthColumn.width).toBe('13%'); - expect(mobileColumns.serviceActionColumn.width).toBe('14%'); - expect(narrowTabletColumns.resourceColumn.width).toBe('34%'); - expect(narrowTabletColumns.ioColumn.width).toBe('14%'); - expect(narrowTabletColumns.sourceColumn.width).toBe('13%'); - expect(isUnifiedResourceHostDiskIoVisible(799)).toBe(false); - expect(isUnifiedResourceHostDiskIoVisible(800)).toBe(true); - expect(tabletColumns.resourceColumn.width).toBe('28%'); - expect(tabletColumns.serviceResourceColumn.width).toBe('24%'); - expect(tabletColumns.metricColumn.width).toBe('12%'); - expect(tabletColumns.ioColumn.width).toBe('12%'); - expect(tabletColumns.sourceColumn.width).toBe('12%'); - expect(tabletColumns.serviceSourceColumn.width).toBe('8%'); - expect(compactColumns.resourceColumn.width).toBe('16%'); - expect(compactColumns.serviceResourceColumn.width).toBe('18%'); - expect(compactColumns.metricColumn.width).toBe('10%'); - expect(compactColumns.sourceColumn.width).toBe('13%'); - expect(compactColumns.serviceSourceColumn.width).toBe('9.5%'); - expect(compactColumns.tempColumn.width).toBe('6.5%'); - expect(wideColumns.resourceColumn.width).toBe('18%'); - expect(wideColumns.serviceResourceColumn.width).toBe('18%'); - expect(wideColumns.serviceSourceColumn.width).toBe('10%'); - expect(wideColumns.ioColumn.width).toBe('12.5%'); - expect(wideColumns.serviceActionColumn.width).toBe('16%'); - expect(getUnifiedResourceTableHeaderLabels('wide').memory).toBe('Memory'); - expect(getUnifiedResourceTableHeaderLabels('wide').source).toBe('System'); - expect(getUnifiedResourceTableHeaderLabels('compact').memory).toBe('Mem'); - expect(getUnifiedResourceTableHeaderLabels('compact').source).toBe('System'); - expect(getUnifiedResourceTableHeaderLabels('tablet').network).toBe('Net'); - expect(getUnifiedResourceTableHeaderLabels('tablet').source).toBe('Sys'); - expect(getUnifiedResourceTableHeaderLabels('mobile').datastores).toBe('Store'); - expect(shouldShowUnifiedResourceHostTable(0, 0)).toBe(true); - expect(shouldShowUnifiedResourceHostTable(0, 2)).toBe(false); - expect(shouldShowUnifiedResourceHostTable(3, 2)).toBe(true); - }); - - it('derives infrastructure table breakpoints from the measured table surface width', () => { - expect(normalizeUnifiedResourceTableLayoutWidth(820.4)).toBe(820); - expect(normalizeUnifiedResourceTableLayoutWidth(null, 700)).toBe(700); - expect(getUnifiedResourceTableLayoutMode(699)).toBe('mobile'); - expect(getUnifiedResourceTableLayoutMode(700)).toBe('tablet'); - expect(getUnifiedResourceTableLayoutMode(899)).toBe('tablet'); - expect(getUnifiedResourceTableLayoutMode(900)).toBe('compact'); - expect(getUnifiedResourceTableLayoutMode(1159)).toBe('compact'); - expect(getUnifiedResourceTableLayoutMode(1160)).toBe('wide'); - expect(shouldUseUnifiedResourceTableMobileLayout(699)).toBe(true); - expect(shouldUseUnifiedResourceTableMobileLayout(700)).toBe(false); - expect(isUnifiedResourceTableColumnVisible('primary', 640)).toBe(true); - expect(isUnifiedResourceTableColumnVisible('secondary', 699)).toBe(false); - expect(isUnifiedResourceTableColumnVisible('secondary', 700)).toBe(true); - expect(isUnifiedResourceTableColumnVisible('supplementary', 899)).toBe(false); - expect(isUnifiedResourceTableColumnVisible('supplementary', 900)).toBe(true); - expect(isUnifiedResourceTableColumnVisible('detailed', 1159)).toBe(false); - expect(isUnifiedResourceTableColumnVisible('detailed', 1160)).toBe(true); - expect(isUnifiedResourceServiceColumnVisible('primary', 499)).toBe(false); - expect(isUnifiedResourceServiceColumnVisible('primary', 500)).toBe(true); - expect(isUnifiedResourceServiceColumnVisible('secondary', 579)).toBe(false); - expect(isUnifiedResourceServiceColumnVisible('secondary', 580)).toBe(true); - expect(isUnifiedResourceServiceColumnVisible('supplementary', 639)).toBe(false); - expect(isUnifiedResourceServiceColumnVisible('supplementary', 640)).toBe(true); - expect(isUnifiedResourceServiceColumnVisible('detailed', 899)).toBe(false); - expect(isUnifiedResourceServiceColumnVisible('detailed', 900)).toBe(true); - }); - - it('reads unified source tags from platform data without hook state', () => { - expect( - getUnifiedSources(makeResource('a', { platformData: { sources: ['proxmox', 'agent'] } })), - ).toEqual(['proxmox', 'agent']); - expect( - getUnifiedSources( - makeResource('b', { - sources: ['agent', 'docker'], - platformData: { sources: ['kubernetes'] }, - }), - ), - ).toEqual(['agent', 'docker']); - expect(getUnifiedSources(makeResource('c', { platformData: {} }))).toEqual([]); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts b/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts deleted file mode 100644 index 2110fe9a3e..0000000000 --- a/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts +++ /dev/null @@ -1,395 +0,0 @@ -import type { Resource } from '@/types/resource'; -import { getCpuPercent, getMemoryPercent } from '@/types/resource'; -import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; -import { getInfrastructureSystemIdentitySortLabel } from '@/utils/resourceBadgePresentation'; -import { normalizeSourcePlatformKey, type KnownSourcePlatform } from '@/utils/sourcePlatforms'; -import { getCanonicalStatusLabel, STATUS_SORT_ORDER } from '@/utils/status'; -import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction'; -import { getResourceDiskSummary } from '@/utils/format'; - -export interface IODistributionStats { - median: number; - mad: number; - max: number; - p97: number; - p99: number; - count: number; -} - -export interface OutlierEmphasis { - fontWeight: string; - color: string; - showOutlierHint: boolean; -} - -export interface ResourceGroup { - cluster: string; - resources: Resource[]; -} - -const INFRASTRUCTURE_STANDALONE_SUMMARY_GROUP_ID = 'cluster:__standalone__'; - -const isServiceInfrastructureResource = (resource: Resource) => - resource.type === 'pbs' || resource.type === 'pmg'; - -const isResourceOnline = (resource: Resource) => { - const status = resource.status?.toLowerCase(); - return status !== 'offline' && status !== 'stopped'; -}; - -const computeMedian = (values: number[]): number => { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - if (sorted.length % 2 === 0) { - return (sorted[mid - 1] + sorted[mid]) / 2; - } - return sorted[mid]; -}; - -const computePercentile = (values: number[], percentile: number): number => { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const clamped = Math.max(0, Math.min(1, percentile)); - const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(clamped * sorted.length) - 1)); - return sorted[index]; -}; - -const buildIODistribution = (values: number[]): IODistributionStats => { - const valid = values.filter((value) => Number.isFinite(value) && value >= 0); - if (valid.length === 0) { - return { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 }; - } - - const median = computeMedian(valid); - const deviations = valid.map((value) => Math.abs(value - median)); - const mad = computeMedian(deviations); - const max = Math.max(...valid, 0); - const p97 = computePercentile(valid, 0.97); - const p99 = computePercentile(valid, 0.99); - - return { median, mad, max, p97, p99, count: valid.length }; -}; - -const getSortValue = (resource: Resource, key: string): number | string | null => { - switch (key) { - case 'name': - return getPreferredInfrastructureDisplayName(resource); - case 'uptime': - return resource.uptime ?? 0; - case 'cpu': - return resource.cpu ? getCpuPercent(resource) : null; - case 'memory': - return resource.memory ? getMemoryPercent(resource) : null; - case 'disk': - return getResourceDiskSummary(resource)?.usage ?? null; - case 'network': - return resource.network ? resource.network.rxBytes + resource.network.txBytes : null; - case 'diskio': - return resource.diskIO?.readRate !== undefined && resource.diskIO?.writeRate !== undefined - ? resource.diskIO.readRate + resource.diskIO.writeRate - : null; - case 'source': - return getInfrastructureSystemIdentitySortLabel(resource); - case 'temp': - return resource.temperature ?? null; - default: - return null; - } -}; - -const defaultComparison = (a: Resource, b: Resource) => { - const aOnline = isResourceOnline(a); - const bOnline = isResourceOnline(b); - if (aOnline !== bOnline) return aOnline ? -1 : 1; - return getPreferredInfrastructureDisplayName(a).localeCompare( - getPreferredInfrastructureDisplayName(b), - ); -}; - -const compareValues = (valueA: number | string | null, valueB: number | string | null) => { - const aEmpty = - valueA === null || valueA === undefined || (typeof valueA === 'number' && Number.isNaN(valueA)); - const bEmpty = - valueB === null || valueB === undefined || (typeof valueB === 'number' && Number.isNaN(valueB)); - - if (aEmpty && bEmpty) return 0; - if (aEmpty) return 1; - if (bEmpty) return -1; - - if (typeof valueA === 'number' && typeof valueB === 'number') { - if (valueA === valueB) return 0; - return valueA < valueB ? -1 : 1; - } - - const aStr = String(valueA).toLowerCase(); - const bStr = String(valueB).toLowerCase(); - - if (aStr === bStr) return 0; - return aStr < bStr ? -1 : 1; -}; - -export const tokenizeSearch = (query: string): string[] => - query - .trim() - .toLowerCase() - .split(/\s+/) - .filter((term) => term.length > 0); - -export const matchesSearch = (resource: Resource, term: string): boolean => { - if (!term) return true; - const normalizedTerm = term.toLowerCase(); - const candidates: string[] = [ - // Local search box; the operator types raw names. Haystack already includes - // raw hostname and ips, so the displayName must be raw too for consistency. - getPreferredInfrastructureDisplayName(resource), - resource.id, - resource.identity?.hostname ?? '', - ...(resource.identity?.ips ?? []), - ...(resource.tags ?? []), - ]; - const haystack = candidates - .filter((value): value is string => typeof value === 'string' && value.length > 0) - .join(' ') - .toLowerCase(); - return haystack.includes(normalizedTerm); -}; - -export const getResourceSources = (resource: Resource): KnownSourcePlatform[] => { - const platformData = resource.platformData as { sources?: string[] } | undefined; - const normalized = (platformData?.sources ?? []) - .map((source) => normalizeSourcePlatformKey(source)) - .filter((source): source is KnownSourcePlatform => Boolean(source)); - return Array.from(new Set(normalized)); -}; - -export const collectAvailableSources = (resources: Resource[]): Set => { - const set = new Set(); - resources.forEach((resource) => { - getResourceSources(resource).forEach((source) => set.add(source)); - }); - return set; -}; - -export const collectAvailableStatuses = (resources: Resource[]): Set => { - const set = new Set(); - resources.forEach((resource) => { - const status = (resource.status || 'unknown').toLowerCase(); - if (status) set.add(status); - }); - return set; -}; - -export const buildStatusOptions = ( - statuses: Set, -): Array<{ key: string; label: string }> => { - const items = Array.from(statuses); - const getStatusSortIndex = (status: string): number => { - if (STATUS_SORT_ORDER.includes(status as (typeof STATUS_SORT_ORDER)[number])) { - return STATUS_SORT_ORDER.indexOf(status as (typeof STATUS_SORT_ORDER)[number]); - } - return -1; - }; - items.sort((a, b) => { - const indexA = getStatusSortIndex(a); - const indexB = getStatusSortIndex(b); - if (indexA === -1 && indexB === -1) return a.localeCompare(b); - if (indexA === -1) return 1; - if (indexB === -1) return -1; - return indexA - indexB; - }); - return items.map((status) => ({ - key: status, - label: getCanonicalStatusLabel(status, status), - })); -}; - -export const filterResources = ( - resources: Resource[], - sources: Set, - statuses: Set, - searchTerms: string[], -): Resource[] => { - let filtered = resources; - - if (sources.size > 0) { - filtered = filtered.filter((resource) => { - const resourceSources = getResourceSources(resource); - if (resourceSources.length === 0) return false; - return resourceSources.some((source) => sources.has(source)); - }); - } - - if (statuses.size > 0) { - filtered = filtered.filter((resource) => { - const status = (resource.status || 'unknown').toLowerCase(); - return statuses.has(status); - }); - } - - if (searchTerms.length > 0) { - filtered = filtered.filter((resource) => - searchTerms.every((term) => matchesSearch(resource, term)), - ); - } - - return filtered; -}; - -export const splitPrimaryAndServiceResources = ( - resources: Resource[], -): { primaryResources: Resource[]; services: Resource[] } => ({ - primaryResources: resources.filter((resource) => !isServiceInfrastructureResource(resource)), - services: resources.filter((resource) => isServiceInfrastructureResource(resource)), -}); - -export const sortResources = ( - resources: Resource[], - sortKey: string, - sortDirection: 'asc' | 'desc', -): Resource[] => { - const list = [...resources]; - return list.sort((a, b) => { - if (sortKey === 'default') { - return defaultComparison(a, b); - } - - const valueA = getSortValue(a, sortKey); - const valueB = getSortValue(b, sortKey); - const comparison = compareValues(valueA, valueB); - - if (comparison !== 0) { - return sortDirection === 'asc' ? comparison : -comparison; - } - - return defaultComparison(a, b); - }); -}; - -export const groupResources = ( - sortedResources: Resource[], - mode: 'grouped' | 'flat', -): ResourceGroup[] => { - if (mode !== 'grouped') { - return [{ cluster: '', resources: sortedResources }]; - } - - const groups = new Map(); - for (const resource of sortedResources) { - const cluster = resource.clusterId || ''; - const list = groups.get(cluster); - if (list) { - list.push(resource); - } else { - groups.set(cluster, [resource]); - } - } - - const entries = Array.from(groups.entries()).map(([cluster, resources]) => ({ - cluster, - resources, - })); - entries.sort((a, b) => { - if (!a.cluster && b.cluster) return 1; - if (a.cluster && !b.cluster) return -1; - return a.cluster.localeCompare(b.cluster); - }); - return entries; -}; - -export const buildInfrastructureSummaryGroupId = (cluster: string | null | undefined): string => { - const normalizedCluster = (cluster ?? '').trim(); - return normalizedCluster - ? `cluster:${normalizedCluster}` - : INFRASTRUCTURE_STANDALONE_SUMMARY_GROUP_ID; -}; - -export const buildInfrastructureSummaryGroupScope = ( - group: ResourceGroup, -): SummarySeriesGroupScope | null => { - const seriesIds = Array.from( - new Set(group.resources.map((resource) => resource.id?.trim() || '').filter(Boolean)), - ); - if (seriesIds.length === 0) { - return null; - } - - const clusterLabel = group.cluster.trim(); - const resourceCountLabel = `${group.resources.length} resource${group.resources.length === 1 ? '' : 's'}`; - - return { - id: buildInfrastructureSummaryGroupId(group.cluster), - label: clusterLabel - ? `${clusterLabel} (${resourceCountLabel})` - : `Standalone (${resourceCountLabel})`, - seriesIds, - }; -}; - -export const infrastructureHasVisibleSummaryGroupScope = ( - resources: Resource[], - scope: SummarySeriesGroupScope | null | undefined, -): boolean => { - if (!scope) { - return false; - } - const visibleResourceIds = new Set( - resources.map((resource) => resource.id?.trim() || '').filter(Boolean), - ); - return scope.seriesIds.some((seriesId) => visibleResourceIds.has(seriesId.trim())); -}; - -export const computeIOScale = ( - resources: Resource[], -): { network: IODistributionStats; diskIO: IODistributionStats } => { - const networkValues: number[] = []; - const diskIOValues: number[] = []; - - for (const resource of resources) { - const networkTotal = (resource.network?.rxBytes ?? 0) + (resource.network?.txBytes ?? 0); - if (resource.network) { - networkValues.push(networkTotal); - } - - if (resource.diskIO?.readRate !== undefined && resource.diskIO?.writeRate !== undefined) { - diskIOValues.push(resource.diskIO.readRate + resource.diskIO.writeRate); - } - } - - return { - network: buildIODistribution(networkValues), - diskIO: buildIODistribution(diskIOValues), - }; -}; - -export const getOutlierEmphasis = (value: number, stats: IODistributionStats): OutlierEmphasis => { - if (!Number.isFinite(value) || value <= 0 || stats.max <= 0) { - return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false }; - } - - if (stats.count < 4) { - const ratio = value / stats.max; - if (ratio >= 0.995) { - return { fontWeight: '500', color: 'text-base-content', showOutlierHint: true }; - } - return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false }; - } - - if (stats.mad > 0) { - const modifiedZ = (0.6745 * (value - stats.median)) / stats.mad; - if (modifiedZ >= 6.5 && value >= stats.p99) { - return { fontWeight: '600', color: 'text-base-content', showOutlierHint: true }; - } - if (modifiedZ >= 5.5 && value >= stats.p97) { - return { fontWeight: '500', color: 'text-base-content', showOutlierHint: true }; - } - return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false }; - } - - if (value >= stats.p99) - return { fontWeight: '600', color: 'text-base-content', showOutlierHint: true }; - if (value >= stats.p97) - return { fontWeight: '500', color: 'text-base-content', showOutlierHint: true }; - if (value > 0) return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false }; - return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false }; -}; diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts b/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts index 3a77a7aded..07e3b11301 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts @@ -15,6 +15,7 @@ import type { } from '@/types/api'; import type { Resource, ResourceMetric, ResourceVMwareMeta } from '@/types/resource'; import { formatTemperature } from '@/utils/temperature'; +import { getPhysicalDiskTemperaturePresentation } from '@/features/storageBackups/diskTemperaturePresentation'; import { formatBytes } from '@/utils/format'; import { getActionableAgentIdFromResource } from '@/utils/agentResources'; import { @@ -316,7 +317,9 @@ export const toAgentFromResource = ( sensors: agent.sensors, raid: agent.raid, status: resource.status, - uptimeSeconds: agent.uptimeSeconds ?? resource.uptime ?? 0, + // A silent agent's uptime is from its last report, not how long the + // machine has been up. + uptimeSeconds: resource.agent?.stale ? 0 : (agent.uptimeSeconds ?? resource.uptime ?? 0), lastSeen: resource.lastSeen, agentVersion: agent.agentVersion, commandsEnabled: agent.commandsEnabled, @@ -399,7 +402,23 @@ const buildTypedGPUTemperatureKeys = (gpus?: HostSensorSummary['gpu']) => { return keys; }; -export const buildTemperatureRows = (sensors?: HostSensorSummary) => { +// Matches the backend's reason for readings a host agent past its reporting +// lease no longer collects (models.HostAgentStoppedReportingReason). +export const HOST_AGENT_STOPPED_REPORTING_REASON = 'host agent stopped reporting'; + +export type TemperatureRowsOptions = { + /** + * Why the machine's own sensor readings are retained rather than current, + * such as a host agent past its reporting lease. Disk rows keep their own + * collection state instead. + */ + lastKnownReason?: string; +}; + +export const buildTemperatureRows = ( + sensors?: HostSensorSummary, + options: TemperatureRowsOptions = {}, +) => { const rows: { label: string; value: string; valueTitle?: string }[] = []; const thermalState = sensors?.thermalState; const typedGPUTemperatureKeys = buildTypedGPUTemperatureKeys(sensors?.gpu); @@ -499,16 +518,31 @@ export const buildTemperatureRows = (sensors?: HostSensorSummary) => { }); } + // This card has no other cue that a value is not current, so a retained + // sensor reading says so in its value, as a retained disk reading does. + const lastKnownReason = options.lastKnownReason?.trim(); + if (lastKnownReason) { + const title = `Last known reading, not current: ${lastKnownReason}`; + for (const row of rows) { + row.value = `${row.value} (last known)`; + row.valueTitle = title; + } + } + const smart = sensors?.smart; if (smart) { smart - .filter((disk) => !disk.standby && Number.isFinite(disk.temperature)) + .filter((disk) => !disk.standby) .sort((a, b) => a.device.localeCompare(b.device)) .forEach((disk) => { + const temperature = getPhysicalDiskTemperaturePresentation(disk); + if (!temperature) return; + // A retained reading, such as a silent agent's, says so in the value + // because this card has no other cue that it is not current. rows.push({ label: `Disk ${disk.device}`, - value: formatTemperature(disk.temperature), - valueTitle: `${disk.temperature.toFixed(1)}°C`, + value: temperature.current ? temperature.label : `${temperature.label} (last known)`, + valueTitle: temperature.title ?? `${disk.temperature.toFixed(1)}°C`, }); }); } diff --git a/frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts b/frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts deleted file mode 100644 index 5567ad386a..0000000000 --- a/frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Resource } from '@/types/resource'; -import { getServiceHealthSummaryPresentation } from '@/utils/serviceHealthPresentation'; -import type { IODistributionStats } from '@/components/Infrastructure/infrastructureSelectors'; -import type { PBSPlatformData, PMGPlatformData } from './resourceDetailMappers'; -import { getPbsActivitySummary, getPbsJobTotal } from './resourceDetailDrawerServiceModel'; - -type PBSServiceData = PBSPlatformData; -type PMGServiceData = PMGPlatformData; - -export type PBSTableRow = { - datastores: number | null; - jobs: number | null; - activity: string | null; - activityDetail: string | null; - activeTaskCount: number; - health: string | null; - tone: ReturnType['tone']; -}; - -export type PMGTableRow = { - queue: number | null; - deferred: number | null; - hold: number | null; - nodes: number | null; - health: string | null; - tone: ReturnType['tone']; -}; - -export interface IOEmphasis { - className: string; - showOutlierHint: boolean; -} - -export const isResourceOnline = (resource: Resource) => { - const status = resource.status?.toLowerCase(); - return status !== 'offline' && status !== 'stopped'; -}; - -export const getPBSTableRow = (resource: Resource): PBSTableRow | null => { - if (resource.type !== 'pbs') return null; - const platformData = resource.platformData as - { pbs?: PBSServiceData; pmg?: PMGServiceData } | undefined; - const pbs = platformData?.pbs; - const totalJobs = getPbsJobTotal(pbs); - const activitySummary = getPbsActivitySummary(pbs); - const health = pbs?.connectionHealth?.trim() || null; - - return { - datastores: (pbs?.datastoreCount || 0) > 0 ? pbs?.datastoreCount || 0 : null, - jobs: totalJobs > 0 ? totalJobs : null, - activity: activitySummary.label, - activityDetail: activitySummary.detail, - activeTaskCount: activitySummary.activeTaskCount, - health, - tone: getServiceHealthSummaryPresentation(resource.status, health).tone, - }; -}; - -export const getPMGTableRow = (resource: Resource): PMGTableRow | null => { - if (resource.type !== 'pmg') return null; - const platformData = resource.platformData as - { pbs?: PBSServiceData; pmg?: PMGServiceData } | undefined; - const pmg = platformData?.pmg; - const health = pmg?.connectionHealth?.trim() || null; - const backlog = (pmg?.queueDeferred || 0) + (pmg?.queueHold || 0); - - return { - queue: (pmg?.queueTotal || 0) > 0 ? pmg?.queueTotal || 0 : null, - deferred: (pmg?.queueDeferred || 0) > 0 ? pmg?.queueDeferred || 0 : null, - hold: (pmg?.queueHold || 0) > 0 ? pmg?.queueHold || 0 : null, - nodes: (pmg?.nodeCount || 0) > 0 ? pmg?.nodeCount || 0 : null, - health, - tone: - backlog > 0 ? 'warning' : getServiceHealthSummaryPresentation(resource.status, health).tone, - }; -}; - -export const getOutlierEmphasis = (value: number, stats: IODistributionStats): IOEmphasis => { - if (!Number.isFinite(value) || value <= 0 || stats.max <= 0) { - return { className: 'text-muted', showOutlierHint: false }; - } - - if (stats.count < 4) { - const ratio = value / stats.max; - if (ratio >= 0.995) { - return { className: 'text-base-content font-medium', showOutlierHint: true }; - } - return { className: 'text-muted', showOutlierHint: false }; - } - - if (stats.mad > 0) { - const modifiedZ = (0.6745 * (value - stats.median)) / stats.mad; - if (modifiedZ >= 6.5 && value >= stats.p99) { - return { className: 'text-base-content font-semibold', showOutlierHint: true }; - } - if (modifiedZ >= 5.5 && value >= stats.p97) { - return { className: 'text-base-content font-medium', showOutlierHint: true }; - } - return { className: 'text-muted', showOutlierHint: false }; - } - - if (value >= stats.p99) - return { className: 'text-base-content font-semibold', showOutlierHint: true }; - if (value >= stats.p97) - return { className: 'text-base-content font-medium', showOutlierHint: true }; - if (value > 0) return { className: 'text-muted', showOutlierHint: false }; - return { className: 'text-muted', showOutlierHint: false }; -}; diff --git a/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts b/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts deleted file mode 100644 index 86ae9b087b..0000000000 --- a/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts +++ /dev/null @@ -1,449 +0,0 @@ -import type { Resource } from '@/types/resource'; -import type { ColumnPriority } from '@/hooks/useBreakpoint'; -import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; -import { - sortResources, - type ResourceGroup, -} from '@/components/Infrastructure/infrastructureSelectors'; - -export type UnifiedResourceTableSortKey = - | 'default' - | 'name' - | 'uptime' - | 'cpu' - | 'memory' - | 'disk' - | 'network' - | 'diskio' - | 'source' - | 'temp'; - -export type UnifiedResourceTableSortDirection = 'asc' | 'desc'; - -export type HostTableHeaderItem = { - type: 'header'; - group: ResourceGroup; -}; - -export type HostTableResourceItem = { - type: 'row'; - group: ResourceGroup; - resource: Resource; -}; - -export type HostTableItem = HostTableHeaderItem | HostTableResourceItem; - -export const HOST_TABLE_ESTIMATED_ROW_HEIGHT = 40; -export const HOST_TABLE_WINDOW_SIZE = 137; -export const UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH = 1024; -export const UNIFIED_RESOURCE_TABLE_MOBILE_LAYOUT_WIDTH = 700; -export const UNIFIED_RESOURCE_TABLE_TABLET_DISK_IO_LAYOUT_WIDTH = 800; -export const UNIFIED_RESOURCE_TABLE_COMPACT_LAYOUT_WIDTH = 900; -export const UNIFIED_RESOURCE_TABLE_WIDE_LAYOUT_WIDTH = 1160; -export const UNIFIED_RESOURCE_TABLE_COLUMN_BREAKPOINTS: Record = { - essential: 0, - primary: 640, - secondary: UNIFIED_RESOURCE_TABLE_MOBILE_LAYOUT_WIDTH, - supplementary: UNIFIED_RESOURCE_TABLE_COMPACT_LAYOUT_WIDTH, - detailed: UNIFIED_RESOURCE_TABLE_WIDE_LAYOUT_WIDTH, -}; -export const UNIFIED_RESOURCE_SERVICE_COLUMN_BREAKPOINTS: Record = { - essential: 0, - primary: 500, - secondary: 580, - supplementary: 640, - detailed: 900, -}; - -export type UnifiedResourceTableLayoutMode = 'mobile' | 'tablet' | 'compact' | 'wide'; - -export const normalizeUnifiedResourceTableLayoutWidth = ( - width: number | null | undefined, - fallback: number = UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH, -): number => { - if (typeof width === 'number' && Number.isFinite(width) && width > 0) { - return Math.round(width); - } - if (Number.isFinite(fallback) && fallback > 0) { - return Math.round(fallback); - } - return UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH; -}; - -export const getUnifiedResourceTableLayoutMode = ( - layoutWidth: number, -): UnifiedResourceTableLayoutMode => { - const width = normalizeUnifiedResourceTableLayoutWidth(layoutWidth); - - if (width < UNIFIED_RESOURCE_TABLE_MOBILE_LAYOUT_WIDTH) return 'mobile'; - if (width < UNIFIED_RESOURCE_TABLE_COMPACT_LAYOUT_WIDTH) return 'tablet'; - if (width < UNIFIED_RESOURCE_TABLE_WIDE_LAYOUT_WIDTH) return 'compact'; - return 'wide'; -}; - -export const shouldUseUnifiedResourceTableMobileLayout = (layoutWidth: number): boolean => - getUnifiedResourceTableLayoutMode(layoutWidth) === 'mobile'; - -export const isUnifiedResourceTableColumnVisible = ( - priority: ColumnPriority, - layoutWidth: number, -): boolean => - normalizeUnifiedResourceTableLayoutWidth(layoutWidth) >= - UNIFIED_RESOURCE_TABLE_COLUMN_BREAKPOINTS[priority]; - -export const isUnifiedResourceServiceColumnVisible = ( - priority: ColumnPriority, - layoutWidth: number, -): boolean => - normalizeUnifiedResourceTableLayoutWidth(layoutWidth) >= - UNIFIED_RESOURCE_SERVICE_COLUMN_BREAKPOINTS[priority]; - -export const isUnifiedResourceHostDiskIoVisible = (layoutWidth: number): boolean => - normalizeUnifiedResourceTableLayoutWidth(layoutWidth) >= - UNIFIED_RESOURCE_TABLE_TABLET_DISK_IO_LAYOUT_WIDTH; - -export const buildResourceLabelById = (resources: Resource[]): Map => { - const map = new Map(); - for (const resource of resources) { - map.set(resource.id, getPreferredInfrastructureDisplayName(resource)); - } - return map; -}; - -export const buildHostTableItems = ( - groupedResources: ResourceGroup[], - groupingMode: 'grouped' | 'flat' | undefined, -): HostTableItem[] => { - const items: HostTableItem[] = []; - const showGroupHeaders = groupingMode === 'grouped'; - - for (const group of groupedResources) { - if (showGroupHeaders) { - items.push({ type: 'header', group }); - } - for (const resource of group.resources) { - items.push({ type: 'row', group, resource }); - } - } - - return items; -}; - -export const buildHostRowIndexById = (items: HostTableItem[]): Map => { - const map = new Map(); - items.forEach((item, index) => { - if (item.type === 'row') { - map.set(item.resource.id, index); - } - }); - return map; -}; - -export const getHostRevealTargetIndex = ( - rowIndexById: Map, - expandedResourceId: string | null, - highlightedResourceId: string | null | undefined, - revealedResourceId?: string | null, -): number | null => { - const targetId = expandedResourceId ?? revealedResourceId ?? highlightedResourceId ?? null; - if (!targetId) return null; - return rowIndexById.get(targetId) ?? null; -}; - -export const getVisibleHostTableItems = ( - items: HostTableItem[], - isWindowed: boolean, - startIndex: number, - endIndex: number, -): HostTableItem[] => { - if (!isWindowed) return items; - return items.slice(startIndex, endIndex); -}; - -export const shouldShowClusterGroupTypeLabel = ( - clusterLabel: string | null | undefined, -): boolean => { - const normalized = (clusterLabel ?? '').trim().replace(/\s+/g, ' ').toLowerCase(); - return Boolean(normalized) && !/\bcluster$/.test(normalized); -}; - -export const getHostSpacerHeights = ( - totalCount: number, - startIndex: number, - endIndex: number, - isWindowed: boolean, - estimatedRowHeight: number = HOST_TABLE_ESTIMATED_ROW_HEIGHT, -): { top: number; bottom: number } => { - if (!isWindowed) { - return { top: 0, bottom: 0 }; - } - - return { - top: startIndex * estimatedRowHeight, - bottom: Math.max(0, (totalCount - endIndex) * estimatedRowHeight), - }; -}; - -export const getNextUnifiedResourceTableSortState = ( - currentKey: UnifiedResourceTableSortKey, - currentDirection: UnifiedResourceTableSortDirection, - nextKey: Exclude, -): { - key: UnifiedResourceTableSortKey; - direction: UnifiedResourceTableSortDirection; -} => { - if (currentKey === nextKey) { - if (currentDirection === 'asc') { - return { key: nextKey, direction: 'desc' }; - } - return { key: 'default', direction: 'asc' }; - } - - return { - key: nextKey, - direction: nextKey === 'name' || nextKey === 'source' ? 'asc' : 'desc', - }; -}; - -export const getUnifiedResourceTableSortIndicator = ( - activeKey: UnifiedResourceTableSortKey, - activeDirection: UnifiedResourceTableSortDirection, - key: UnifiedResourceTableSortKey, -): '▲' | '▼' | null => { - if (activeKey !== key) return null; - return activeDirection === 'asc' ? '▲' : '▼'; -}; - -export const sortServiceResources = (services: Resource[], type: 'pbs' | 'pmg'): Resource[] => - sortResources( - services.filter((resource) => resource.type === type), - 'default', - 'asc', - ); - -export const shouldShowUnifiedResourceHostTable = ( - primaryResourceCount: number, - serviceResourceCount: number, -): boolean => primaryResourceCount > 0 || serviceResourceCount === 0; - -export const getUnifiedSources = (resource: Resource): string[] => { - if (Array.isArray(resource.sources) && resource.sources.length > 0) { - return resource.sources; - } - const platformData = resource.platformData as { sources?: string[] } | undefined; - return platformData?.sources ?? []; -}; - -export interface UnifiedResourceTableColumnPresentation { - className: string; - width?: string | number; -} - -export type UnifiedResourceTableColumnPresentations = { - resourceColumn: UnifiedResourceTableColumnPresentation; - serviceResourceColumn: UnifiedResourceTableColumnPresentation; - metricColumn: UnifiedResourceTableColumnPresentation; - ioColumn: UnifiedResourceTableColumnPresentation; - sourceColumn: UnifiedResourceTableColumnPresentation; - serviceSourceColumn: UnifiedResourceTableColumnPresentation; - uptimeColumn: UnifiedResourceTableColumnPresentation; - tempColumn: UnifiedResourceTableColumnPresentation; - serviceCountColumn: UnifiedResourceTableColumnPresentation; - serviceQueueColumn: UnifiedResourceTableColumnPresentation; - serviceHealthColumn: UnifiedResourceTableColumnPresentation; - serviceActionColumn: UnifiedResourceTableColumnPresentation; -}; - -export type UnifiedResourceTableHeaderLabels = { - resource: string; - cpu: string; - memory: string; - disk: string; - network: string; - diskIo: string; - source: string; - uptime: string; - temp: string; - datastores: string; - activity: string; - queue: string; - deferred: string; - hold: string; - nodes: string; - health: string; - action: string; -}; - -const buildUnifiedResourceTableColumnPresentation = ( - className: string, - width?: string | number, -): UnifiedResourceTableColumnPresentation => ({ - className, - width, -}); - -export const getUnifiedResourceTableShellClass = ( - layoutMode: UnifiedResourceTableLayoutMode, -): string => - `table-fixed ${layoutMode === 'mobile' ? 'min-w-full' : 'min-w-[640px] sm:min-w-full'}${ - layoutMode === 'wide' ? '' : ' text-[11px] sm:text-xs' - }`; - -export const getUnifiedResourceTableHeaderLabels = ( - layoutMode: UnifiedResourceTableLayoutMode, -): UnifiedResourceTableHeaderLabels => { - if (layoutMode === 'wide') { - return { - resource: 'Resource', - cpu: 'CPU', - memory: 'Memory', - disk: 'Disk', - network: 'Net I/O', - diskIo: 'Disk I/O', - source: 'System', - uptime: 'Uptime', - temp: 'Temp', - datastores: 'Datastores', - activity: 'Activity', - queue: 'Queue', - deferred: 'Deferred', - hold: 'Hold', - nodes: 'Nodes', - health: 'Health', - action: 'Action', - }; - } - - if (layoutMode === 'compact') { - return { - resource: 'Resource', - cpu: 'CPU', - memory: 'Mem', - disk: 'Disk', - network: 'Net I/O', - diskIo: 'Disk I/O', - source: 'System', - uptime: 'Up', - temp: 'Temp', - datastores: 'Stores', - activity: 'Activity', - queue: 'Queue', - deferred: 'Def', - hold: 'Hold', - nodes: 'Nodes', - health: 'Health', - action: 'Action', - }; - } - - return { - resource: 'Resource', - cpu: 'CPU', - memory: 'Mem', - disk: 'Disk', - network: 'Net', - diskIo: 'I/O', - source: 'Sys', - uptime: 'Up', - temp: 'Temp', - datastores: 'Store', - activity: 'Jobs', - queue: 'Queue', - deferred: 'Def', - hold: 'Hold', - nodes: 'Nodes', - health: 'Health', - action: 'Action', - }; -}; - -export const getUnifiedResourceTableColumnPresentations = ( - layoutMode: UnifiedResourceTableLayoutMode, - layoutWidth?: number, -): UnifiedResourceTableColumnPresentations => { - if (layoutMode === 'mobile') { - // Mobile widths are percentages so the prioritized host columns fill the - // viewport without horizontal scroll. Hidden columns keep placeholder - // widths that never render. - return { - resourceColumn: buildUnifiedResourceTableColumnPresentation('', '34%'), - serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '28%'), - metricColumn: buildUnifiedResourceTableColumnPresentation('', '14%'), - ioColumn: buildUnifiedResourceTableColumnPresentation('', '12%'), - sourceColumn: buildUnifiedResourceTableColumnPresentation('', '20%'), - serviceSourceColumn: buildUnifiedResourceTableColumnPresentation('', '10%'), - uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '12%'), - tempColumn: buildUnifiedResourceTableColumnPresentation('', '15%'), - serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '11%'), - serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '8%'), - serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '13%'), - serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '14%'), - }; - } - - if (layoutMode === 'tablet') { - if (typeof layoutWidth === 'number' && isUnifiedResourceHostDiskIoVisible(layoutWidth)) { - return { - resourceColumn: buildUnifiedResourceTableColumnPresentation('', '28%'), - serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '24%'), - metricColumn: buildUnifiedResourceTableColumnPresentation('', '12%'), - ioColumn: buildUnifiedResourceTableColumnPresentation('', '12%'), - sourceColumn: buildUnifiedResourceTableColumnPresentation('', '12%'), - serviceSourceColumn: buildUnifiedResourceTableColumnPresentation('', '8%'), - uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '7%'), - tempColumn: buildUnifiedResourceTableColumnPresentation('', '6%'), - serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '8%'), - serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '7.5%'), - serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '14%'), - serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '13%'), - }; - } - - return { - resourceColumn: buildUnifiedResourceTableColumnPresentation('', '34%'), - serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '24%'), - metricColumn: buildUnifiedResourceTableColumnPresentation('', '13%'), - ioColumn: buildUnifiedResourceTableColumnPresentation('', '14%'), - sourceColumn: buildUnifiedResourceTableColumnPresentation('', '13%'), - serviceSourceColumn: buildUnifiedResourceTableColumnPresentation('', '8%'), - uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '7%'), - tempColumn: buildUnifiedResourceTableColumnPresentation('', '6%'), - serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '8%'), - serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '7.5%'), - serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '14%'), - serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '13%'), - }; - } - - if (layoutMode === 'compact') { - return { - resourceColumn: buildUnifiedResourceTableColumnPresentation('', '16%'), - serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '18%'), - metricColumn: buildUnifiedResourceTableColumnPresentation('', '10%'), - ioColumn: buildUnifiedResourceTableColumnPresentation('', '12.5%'), - sourceColumn: buildUnifiedResourceTableColumnPresentation('', '13%'), - serviceSourceColumn: buildUnifiedResourceTableColumnPresentation('', '9.5%'), - uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '7.5%'), - tempColumn: buildUnifiedResourceTableColumnPresentation('', '6.5%'), - serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '9.5%'), - serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '9.5%'), - serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '14%'), - serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '12%'), - }; - } - - return { - resourceColumn: buildUnifiedResourceTableColumnPresentation('', '18%'), - serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '18%'), - metricColumn: buildUnifiedResourceTableColumnPresentation('', '10.5%'), - ioColumn: buildUnifiedResourceTableColumnPresentation('', '12.5%'), - sourceColumn: buildUnifiedResourceTableColumnPresentation('', '10%'), - serviceSourceColumn: buildUnifiedResourceTableColumnPresentation('', '10%'), - uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '8%'), - tempColumn: buildUnifiedResourceTableColumnPresentation('', '7.5%'), - serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '8.5%'), - serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '8.5%'), - serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '14%'), - serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '16%'), - }; -}; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 1b33f7d387..2cd10f13cd 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -35,6 +35,7 @@ import type { ResourceIntelligence } from '@/types/aiIntelligence'; import { buildCustomSensorRows, buildTemperatureRows, + HOST_AGENT_STOPPED_REPORTING_REASON, toAgentFromResource, toNodeFromProxmox, type AgentPlatformData, @@ -201,7 +202,16 @@ export const useResourceDetailDrawerDerivedState = ( [], ); const agentInfo = createMemo(() => toAgentFromResource(resource(), agentMeta())); - const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors)); + // A silent agent's sensors and disk usage are its last report, even on a + // row another source keeps online. + const agentReadingsLastKnownReason = createMemo(() => + resource().agent?.stale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined, + ); + const temperatureRows = createMemo(() => + buildTemperatureRows(agentInfo()?.sensors, { + lastKnownReason: agentReadingsLastKnownReason(), + }), + ); const customSensorRows = createMemo(() => buildCustomSensorRows(agentInfo()?.sensors)); const dockerHostData = createMemo(() => platformData()?.docker as DockerPlatformData | undefined); @@ -468,6 +478,7 @@ export const useResourceDetailDrawerDerivedState = ( proxmoxNode, proxmoxNetworkInterfaces, agentInfo, + agentReadingsLastKnownReason, temperatureRows, customSensorRows, dockerHostData, diff --git a/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts b/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts deleted file mode 100644 index b8006f13c2..0000000000 --- a/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; -import type { Resource } from '@/types/resource'; -import type { ColumnPriority } from '@/hooks/useBreakpoint'; -import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { useAlertsActivation } from '@/stores/alertsActivation'; -import type { AlertThresholdScope, DisplayMetricType } from '@/utils/metricThresholds'; -import { - buildInfrastructureSummaryGroupScope, - splitPrimaryAndServiceResources, - sortResources, - groupResources, - computeIOScale, -} from '@/components/Infrastructure/infrastructureSelectors'; -import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction'; -import { useTableWindowing } from './useTableWindowing'; -import { useUnifiedResourceTableViewportSync } from './useUnifiedResourceTableViewportSync'; -import { - buildHostRowIndexById, - buildHostTableItems, - buildResourceLabelById, - getHostRevealTargetIndex, - getHostSpacerHeights, - getNextUnifiedResourceTableSortState, - getUnifiedResourceTableColumnPresentations, - getUnifiedResourceTableHeaderLabels, - getUnifiedResourceTableLayoutMode, - isUnifiedResourceHostDiskIoVisible, - isUnifiedResourceServiceColumnVisible, - isUnifiedResourceTableColumnVisible, - getUnifiedResourceTableShellClass, - getUnifiedResourceTableSortIndicator, - getUnifiedSources, - getVisibleHostTableItems, - HOST_TABLE_ESTIMATED_ROW_HEIGHT, - HOST_TABLE_WINDOW_SIZE, - normalizeUnifiedResourceTableLayoutWidth, - shouldShowUnifiedResourceHostTable, - shouldUseUnifiedResourceTableMobileLayout, - sortServiceResources, - type HostTableItem, - type UnifiedResourceTableSortKey as SortKey, -} from './unifiedResourceTableStateModel'; - -export interface UnifiedResourceTableProps { - resources: Resource[]; - expandedResourceId: string | null; - clearPinnedSummaryScope?: () => void; - highlightedResourceId?: string | null; - revealedResourceId?: string | null; - hoveredResourceId?: string | null; - activeSummaryGroupScope?: SummarySeriesGroupScope | null; - hoveredSummaryGroupScope?: SummarySeriesGroupScope | null; - focusedSummaryGroupScope?: SummarySeriesGroupScope | null; - focusedSummaryGroupId?: string | null; - onExpandedResourceChange: (id: string | null) => void; - onHoverChange?: (id: string | null) => void; - onGroupFocusChange?: (groupId: string | null) => void; - onGroupHoverChange?: (scope: SummarySeriesGroupScope | null) => void; - groupingMode?: 'grouped' | 'flat'; - onDeployCluster?: (clusterId: string, clusterName: string) => void; - setTableRootRef?: (element: HTMLDivElement | undefined) => void; -} - -export function useUnifiedResourceTableState(props: UnifiedResourceTableProps) { - const breakpoint = useBreakpoint(); - const alertsActivation = useAlertsActivation(); - const [tableContainerWidth, setTableContainerWidth] = createSignal(null); - const [tableRootElement, setTableRootElement] = createSignal(null); - const [sortKey, setSortKey] = createSignal('default'); - const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); - - const updateTableContainerWidth = (width: number | null | undefined) => { - if (typeof width === 'number' && Number.isFinite(width) && width > 0) { - setTableContainerWidth(Math.round(width)); - } - }; - - const setTableRootRef = (element: HTMLDivElement | undefined) => { - setTableRootElement(element ?? null); - props.setTableRootRef?.(element); - if (element) { - updateTableContainerWidth(element.clientWidth); - } else { - setTableContainerWidth(null); - } - }; - - createEffect(() => { - const element = tableRootElement(); - if (!element || typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver((entries) => { - const entryWidth = entries[0]?.contentRect.width; - updateTableContainerWidth(entryWidth ?? element.clientWidth); - }); - observer.observe(element); - onCleanup(() => observer.disconnect()); - }); - - const fallbackViewportWidth = () => { - const widthAccessor = breakpoint.width as (() => number) | undefined; - const width = widthAccessor?.(); - return normalizeUnifiedResourceTableLayoutWidth( - width, - typeof window !== 'undefined' ? window.innerWidth : undefined, - ); - }; - const tableLayoutWidth = createMemo(() => - normalizeUnifiedResourceTableLayoutWidth(tableContainerWidth(), fallbackViewportWidth()), - ); - const layoutMode = createMemo(() => getUnifiedResourceTableLayoutMode(tableLayoutWidth())); - const isMobile = createMemo(() => shouldUseUnifiedResourceTableMobileLayout(tableLayoutWidth())); - const isHostDiskIoVisible = createMemo(() => - isUnifiedResourceHostDiskIoVisible(tableLayoutWidth()), - ); - const isVisible = (priority: ColumnPriority) => - isUnifiedResourceTableColumnVisible(priority, tableLayoutWidth()); - const isServiceVisible = (priority: ColumnPriority) => - isUnifiedResourceServiceColumnVisible(priority, tableLayoutWidth()); - - const split = createMemo(() => splitPrimaryAndServiceResources(props.resources)); - const primaryResources = createMemo(() => split().primaryResources); - const serviceResources = createMemo(() => split().services); - const primaryResourceIds = createMemo( - () => new Set(primaryResources().map((resource) => resource.id)), - ); - const serviceResourceIds = createMemo( - () => new Set(serviceResources().map((resource) => resource.id)), - ); - - const sortedResources = createMemo(() => - sortResources(primaryResources(), sortKey(), sortDirection()), - ); - const resourceLabelById = createMemo(() => buildResourceLabelById(props.resources)); - const resolveResourceLabel = (resourceId: string): string | undefined => - resourceLabelById().get(resourceId); - - const groupedResources = createMemo(() => - groupResources(sortedResources(), props.groupingMode ?? 'grouped'), - ); - const hostTableItems = createMemo(() => - buildHostTableItems(groupedResources(), props.groupingMode), - ); - const hostRowIndexById = createMemo(() => buildHostRowIndexById(hostTableItems())); - const hostRevealTargetIndex = createMemo(() => - getHostRevealTargetIndex( - hostRowIndexById(), - props.expandedResourceId, - props.highlightedResourceId, - props.revealedResourceId, - ), - ); - - const hostWindowing = useTableWindowing({ - totalCount: () => hostTableItems().length, - windowSize: HOST_TABLE_WINDOW_SIZE, - revealIndex: hostRevealTargetIndex, - }); - - const visibleHostTableItems = createMemo(() => - getVisibleHostTableItems( - hostTableItems(), - hostWindowing.isWindowed(), - hostWindowing.startIndex(), - hostWindowing.endIndex(), - ), - ); - const hostSpacerHeights = createMemo(() => - getHostSpacerHeights( - hostTableItems().length, - hostWindowing.startIndex(), - hostWindowing.endIndex(), - hostWindowing.isWindowed(), - HOST_TABLE_ESTIMATED_ROW_HEIGHT, - ), - ); - const viewportSync = useUnifiedResourceTableViewportSync({ - totalCount: () => hostTableItems().length, - estimatedRowHeight: HOST_TABLE_ESTIMATED_ROW_HEIGHT, - hostWindowing, - }); - - const sortedPBSResources = createMemo(() => sortServiceResources(serviceResources(), 'pbs')); - const sortedPMGResources = createMemo(() => sortServiceResources(serviceResources(), 'pmg')); - - const ioScale = createMemo(() => computeIOScale(primaryResources())); - - const handleSort = (key: Exclude) => { - const nextSort = getNextUnifiedResourceTableSortState(sortKey(), sortDirection(), key); - setSortKey(nextSort.key); - setSortDirection(nextSort.direction); - }; - - const renderSortIndicator = (key: SortKey) => - getUnifiedResourceTableSortIndicator(sortKey(), sortDirection(), key); - - const getMetricThresholds = ( - scope: AlertThresholdScope, - metric: DisplayMetricType, - resourceIds?: string | string[], - ) => alertsActivation.getMetricThresholds(scope, metric, resourceIds); - - const toggleExpand = (resourceId: string) => { - props.onExpandedResourceChange(props.expandedResourceId === resourceId ? null : resourceId); - }; - - const tableShellClass = createMemo(() => getUnifiedResourceTableShellClass(layoutMode())); - const columnPresentations = createMemo(() => - getUnifiedResourceTableColumnPresentations(layoutMode(), tableLayoutWidth()), - ); - const headerLabels = createMemo(() => getUnifiedResourceTableHeaderLabels(layoutMode())); - const showHostTable = createMemo(() => - shouldShowUnifiedResourceHostTable(primaryResources().length, serviceResources().length), - ); - const showHostClearAction = createMemo(() => - Boolean( - props.focusedSummaryGroupId || - (props.expandedResourceId && primaryResourceIds().has(props.expandedResourceId)), - ), - ); - const showServiceClearAction = createMemo(() => - Boolean(props.expandedResourceId && serviceResourceIds().has(props.expandedResourceId)), - ); - - return { - layoutMode, - isMobile, - isHostDiskIoVisible, - isVisible, - isServiceVisible, - getMetricThresholds, - handleSort, - renderSortIndicator, - resolveResourceLabel, - setTableRootRef, - visibleHostTableItems, - hostTopSpacerHeight: () => hostSpacerHeights().top, - hostBottomSpacerHeight: () => hostSpacerHeights().bottom, - hostWindowing, - sortedPBSResources, - sortedPMGResources, - ioScale, - ...viewportSync, - tableShellClass, - headerLabels, - resourceColumn: () => columnPresentations().resourceColumn, - serviceResourceColumn: () => columnPresentations().serviceResourceColumn, - metricColumn: () => columnPresentations().metricColumn, - ioColumn: () => columnPresentations().ioColumn, - sourceColumn: () => columnPresentations().sourceColumn, - serviceSourceColumn: () => columnPresentations().serviceSourceColumn, - uptimeColumn: () => columnPresentations().uptimeColumn, - tempColumn: () => columnPresentations().tempColumn, - showHostTable, - showHostClearAction, - showServiceClearAction, - serviceCountColumn: () => columnPresentations().serviceCountColumn, - serviceQueueColumn: () => columnPresentations().serviceQueueColumn, - serviceHealthColumn: () => columnPresentations().serviceHealthColumn, - serviceActionColumn: () => columnPresentations().serviceActionColumn, - toggleExpand, - buildHostSummaryGroupScope: buildInfrastructureSummaryGroupScope, - getUnifiedSources, - }; -} - -export type UnifiedResourceTableState = ReturnType; diff --git a/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts b/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts deleted file mode 100644 index 70c7ffe008..0000000000 --- a/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { createEffect, createSignal, onCleanup, untrack, type Accessor } from 'solid-js'; -import { - bindWindowedPageScrollEvents, - findWindowedPageScrollContainer, - isWindowedSurfaceHidden, - wheelDeltaInPixels, -} from '@/components/shared/windowedPageScroll'; -import { useTableWindowing } from './useTableWindowing'; - -interface UseUnifiedResourceTableViewportSyncOptions { - totalCount: Accessor; - estimatedRowHeight: number; - hostWindowing: ReturnType; -} - -export function useUnifiedResourceTableViewportSync( - options: UseUnifiedResourceTableViewportSyncOptions, -) { - const { totalCount, estimatedRowHeight, hostWindowing } = options; - const [hostBodyRef, setHostBodyRef] = createSignal(null); - - const syncHostWindowToViewport = (projectedScrollDelta = 0) => { - if (!hostWindowing.isWindowed() || typeof window === 'undefined') return; - const body = hostBodyRef(); - if (!body || isWindowedSurfaceHidden(body)) return; - const rect = body.getBoundingClientRect(); - const scrollContainer = findWindowedPageScrollContainer(body); - if (scrollContainer) { - const containerRect = scrollContainer.getBoundingClientRect(); - hostWindowing.onScroll( - Math.max(0, containerRect.top - rect.top + projectedScrollDelta), - scrollContainer.clientHeight || window.innerHeight, - estimatedRowHeight, - ); - return; - } - hostWindowing.onScroll( - Math.max(0, -rect.top + projectedScrollDelta), - window.innerHeight, - estimatedRowHeight, - ); - }; - - createEffect(() => { - if (typeof window === 'undefined') return; - totalCount(); - if (!hostWindowing.isWindowed()) return; - if (!hostBodyRef()) return; - - const body = hostBodyRef()!; - const scrollContainer = findWindowedPageScrollContainer(body); - const scrollTarget: HTMLElement | Window = scrollContainer ?? window; - const viewportHeight = () => scrollContainer?.clientHeight || window.innerHeight; - const handleViewportChange = () => syncHostWindowToViewport(); - const handleWheel = (event: Event) => { - const wheelEvent = event as WheelEvent; - if (wheelEvent.deltaY === 0) return; - syncHostWindowToViewport(wheelDeltaInPixels(wheelEvent, viewportHeight())); - }; - // The initial measurement reads the current window bounds and may move the - // window. Keep those reads outside this setup effect's dependency graph; - // otherwise a far-away reveal target and the viewport can repeatedly move - // the window back and forth until Solid exhausts the call stack. - untrack(handleViewportChange); - onCleanup( - bindWindowedPageScrollEvents({ - scrollTarget, - onScroll: handleViewportChange, - onWheel: handleWheel, - onResize: handleViewportChange, - }), - ); - }); - - return { - setHostBodyRef, - }; -} diff --git a/frontend-modern/src/components/Storage/DiskDetail.tsx b/frontend-modern/src/components/Storage/DiskDetail.tsx index 3588836cc0..3343c8cfd5 100644 --- a/frontend-modern/src/components/Storage/DiskDetail.tsx +++ b/frontend-modern/src/components/Storage/DiskDetail.tsx @@ -17,7 +17,7 @@ import { DISK_DETAIL_HEALTH_SUMMARY_CLASS, DISK_DETAIL_HISTORY_RANGE_OPTIONS, DISK_DETAIL_LIVE_CHARTS, - getDiskAttributeValueTextClass, + getDiskAttributeCardValueTextClass, getDiskDetailHistoryFallbackMessage, getDiskDetailLiveBadgeLabel, } from '@/features/storageBackups/diskDetailPresentation'; @@ -166,7 +166,7 @@ export const DiskDetail: Component = (props) => { )} @@ -180,10 +180,7 @@ export const DiskDetail: Component = (props) => {
-
+
diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index fdff3b3d36..db5c65b4b0 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -13,9 +13,8 @@ import { TableHeader, TableRow, } from '@/components/shared/Table'; -import { useAlertsActivation } from '@/stores/alertsActivation'; import { formatBytes } from '@/utils/format'; -import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature'; +import { getTemperatureTextClass } from '@/utils/temperature'; import { PHYSICAL_DISK_CELL_DEVICE_CLASS, PHYSICAL_DISK_CELL_DISK_CLASS, @@ -63,6 +62,7 @@ import { PHYSICAL_DISK_NAME_WRAP_CLASS, PHYSICAL_DISK_SIZE_VALUE_CLASS, PHYSICAL_DISK_TEMPERATURE_CLASS, + PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS, PHYSICAL_DISK_VALUE_TEXT_CLASS, PHYSICAL_DISK_TABLE_BODY_CLASS, PHYSICAL_DISK_TABLE_CLASS, @@ -76,6 +76,7 @@ import { getPhysicalDiskEmptyStatePresentation, getPhysicalDiskHealthCompactLabel, getPhysicalDiskHealthStatus, + getPhysicalDiskTemperatureThresholds, getPhysicalDiskHealthSummary, getPhysicalDiskHostLabel, getPhysicalDiskLifeLabel, @@ -83,6 +84,7 @@ import { getPhysicalDiskParentLabel, getPhysicalDiskRoleLabel, getPhysicalDiskTableLayoutModeForContainer, + getPhysicalDiskTemperaturePresentation, isPhysicalDiskColumnVisible, type PhysicalDiskTableColumnId, } from '@/features/storageBackups/diskPresentation'; @@ -130,7 +132,6 @@ export const DiskList: Component = (props) => { }; const columnStyle = (columnId: PhysicalDiskTableColumnId) => getPhysicalDiskColumnWidthStyle(layoutMode(), columnId); - const { getDiskTemperatureThresholds } = useAlertsActivation(); const model = useDiskListModel({ disks: () => props.disks, nodes: () => props.nodes, @@ -335,6 +336,9 @@ export const DiskList: Component = (props) => { const status = createMemo(() => getPhysicalDiskHealthStatus(data())); const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk)); const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status())); + const temperature = createMemo(() => + getPhysicalDiskTemperaturePresentation(data()), + ); const healthCompactLabel = createMemo(() => getPhysicalDiskHealthCompactLabel(status().label), ); @@ -509,18 +513,31 @@ export const DiskList: Component = (props) => { data-storage-column="temp" > 0} + when={temperature()} fallback={—} > - - {formatTemperature(data().temperature)} - + {(reading) => ( + + {reading().label} + + , last known + + + )} diff --git a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx index c2e7383750..ddecaebb0e 100644 --- a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx @@ -14,6 +14,7 @@ import { getLinkedDiskHealthDotVariant, getLinkedDiskTemperatureTextClass, } from '@/features/storageBackups/diskDetailPresentation'; +import { PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS } from '@/features/storageBackups/diskPresentation'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS, @@ -39,6 +40,7 @@ import { } from '@/features/storageBackups/detailPresentation'; import type { StorageRecord } from '@/features/storageBackups/models'; import type { Resource } from '@/types/resource'; +import { formatTemperature } from '@/utils/temperature'; import { StorageDetailKeyValueRow } from './StorageDetailKeyValueRow'; import { useStoragePoolDetailModel } from './useStoragePoolDetailModel'; @@ -113,8 +115,7 @@ export const StoragePoolDetail: Component = (props) => { />
0}>
@@ -279,12 +280,20 @@ export const StoragePoolDetail: Component = (props) => { 0}> - {disk.temperature}°C + {formatTemperature(disk.temperature)} + + , last known +
@@ -295,10 +304,7 @@ export const StoragePoolDetail: Component = (props) => {
-
+
diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx index e1630ffa95..6d921d6624 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx @@ -99,6 +99,10 @@ describe('DiskDetail', () => { expect( screen.getByText('Temperature is temporarily unavailable: collection deadline exceeded'), ).toBeInTheDocument(); + // The retained value sits under its own label with no health tone. + expect(screen.queryByText('Temperature')).not.toBeInTheDocument(); + expect(screen.getByText('Last known temperature')).toBeInTheDocument(); + expect(screen.getByText('42°C')).toHaveClass('text-muted'); expect( screen.getByText('Disk I/O is unsupported: per-member counters unavailable'), ).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx index ae75eb0693..e91f30ba64 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx @@ -2,8 +2,12 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/te import { createSignal } from 'solid-js'; import { createStore, reconcile } from 'solid-js/store'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AlertsAPI } from '@/api/alerts'; +import type { AlertConfig } from '@/types/alerts'; import type { Resource } from '@/types/resource'; import { DiskList } from '@/components/Storage/DiskList'; +import { useAlertsActivation } from '@/stores/alertsActivation'; +import { eventBus } from '@/stores/events'; vi.mock('@/components/Storage/DiskDetail', () => ({ DiskDetail: (props: { disk: Resource }) =>
{props.disk.id}
, @@ -84,6 +88,59 @@ describe('DiskList', () => { cleanup(); }); + it('shows a retained temperature as last known, not as a live threshold reading', async () => { + const withTemperature = (collection: NonNullable['collection']) => + buildDisk('sda', 'tower', { diskType: 'sata', temperature: 72, collection }); + const [disks, setDisks] = createSignal([ + withTemperature({ temperature: { state: 'available', source: 'host_agent' } }), + ]); + const view = render(() => ( + {}} + /> + )); + const reading = () => + view.container.querySelector( + '[data-row-id="sda"] td[data-storage-column="temp"] [data-temperature-reading]', + )!; + + expect(reading()).toHaveAttribute('data-temperature-reading', 'current'); + expect(reading()).toHaveClass('text-red-600'); + expect(reading()).not.toHaveAttribute('title'); + expect(reading()).toHaveTextContent(/^72°C$/); + + // The same row, retained after its host agent stopped reporting. + setDisks([ + withTemperature({ + temperature: { + state: 'unavailable', + source: 'host_agent', + reason: 'host agent stopped reporting', + }, + }), + ]); + await waitFor(() => + expect(reading()).toHaveAttribute('data-temperature-reading', 'last-known'), + ); + expect(reading()).toHaveClass('text-muted'); + expect(reading().className).not.toMatch(/text-(red|amber|green)-/); + expect(reading()).toHaveAttribute( + 'title', + 'Last known reading, not current: host agent stopped reporting', + ); + expect(reading()).toHaveTextContent('72°C, last known'); + + // A source that predates collection state still reads as current. + setDisks([withTemperature(undefined)]); + await waitFor(() => expect(reading()).toHaveAttribute('data-temperature-reading', 'current')); + expect(reading()).toHaveClass('text-red-600'); + }); + it('refreshes keyed disk rows without losing the expanded detail or keyboard focus', async () => { const initial = buildDisk('sda', 'tower', { diskType: 'ssd', wearout: 96 }); const [disks, setDisks] = createSignal([initial]); @@ -290,6 +347,134 @@ describe('DiskList', () => { ); }); + it('says a disk that is only hot is running hot, not due for replacement', () => { + const view = renderDiskList({ + disks: [ + buildDisk('sda', 'pve3', { + model: 'Crucial MX500 2TB', + diskType: 'sata', + wearout: 95, + temperature: 56, + }), + buildDisk('sdb', 'pve3', { + temperature: 72, + smart: { pendingSectors: 2 }, + risk: { + level: 'critical', + reasons: [ + { + code: 'pending_sectors', + severity: 'critical', + summary: 'Pending sectors detected (2)', + }, + ], + }, + }), + buildDisk('nvme0n1', 'pve3', { + model: 'Samsung 990 PRO', + diskType: 'nvme', + temperature: 63, + }), + ], + nodes: [buildNode('node-pve3', 'pve3')], + selectedNode: null, + searchTerm: '', + }); + + const rows = Array.from(view.container.querySelectorAll('[data-row-id]')); + // The disk that needs replacing sorts above the one that needs cooling. + expect(rows.map((row) => row.getAttribute('data-row-id'))).toEqual(['sdb', 'sda', 'nvme0n1']); + const hotRow = within(rows[1] as HTMLElement); + expect(hotRow.queryByText('Replace Now')).not.toBeInTheDocument(); + expect(hotRow.getByText('Running Hot')).toHaveClass( + 'platform-table-label-full', + 'text-red-700', + ); + expect(hotRow.getByText('Running Hot')).toHaveAttribute( + 'title', + 'Disk temperature is 56°C, at or above its 55°C alert threshold.', + ); + expect(hotRow.getByText('Hot')).toHaveClass('platform-table-label-compact', 'text-red-700'); + // The Temp cell beside it reads red too: the same SATA alert trigger. + expect(hotRow.getByText('56°C')).toHaveClass('text-red-600'); + const failingRow = within(rows[0] as HTMLElement); + expect(failingRow.getByText('Replace Now')).toHaveAttribute( + 'title', + 'Pending sectors detected (2)', + ); + // An NVMe at 63C is under its 70C trigger and its 65C warning colour. + const nvmeRow = within(rows[2] as HTMLElement); + expect(nvmeRow.getByText('Healthy')).toBeInTheDocument(); + expect(nvmeRow.getByText('63°C')).toHaveClass('text-green-600'); + }); + + it('follows a disk from warm to hot and back to healthy', async () => { + const [disks, setDisks] = createStore({ + items: [buildDisk('sda', 'pve3', { temperature: 52 })], + }); + const [healthFilter, setHealthFilter] = createSignal<'all' | 'critical'>('all'); + const view = render(() => ( + {}} + /> + )); + const row = () => view.container.querySelector('[data-row-id="sda"]') as HTMLElement | null; + // Warm: the Temp cell warns in amber, the verdict stays Healthy. + expect(within(row()!).getByText('52°C')).toHaveClass('text-amber-600'); + expect(within(row()!).getByText('Healthy')).toBeInTheDocument(); + setHealthFilter('critical'); + await waitFor(() => expect(row()).toBeNull()); + + setDisks('items', reconcile([buildDisk('sda', 'pve3', { temperature: 56 })])); + await waitFor(() => expect(row()).not.toBeNull()); + expect(within(row()!).getByText('Running Hot')).toHaveClass('text-red-700'); + + setHealthFilter('all'); + setDisks('items', reconcile([buildDisk('sda', 'pve3', { temperature: 41 })])); + await waitFor(() => expect(within(row()!).getByText('Healthy')).toBeInTheDocument()); + expect(within(row()!).queryByText('Running Hot')).not.toBeInTheDocument(); + }); + + it('judges heat by the NVMe trigger the user raised in Alerts', async () => { + const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({ + enabled: true, + activationState: 'active', + agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } }, + diskTempByType: { + nvme: { trigger: 75, clear: 70 }, + sas: { trigger: 65, clear: 60 }, + sata: { trigger: 55, clear: 50 }, + }, + } as unknown as AlertConfig); + try { + const view = renderDiskList({ + disks: [buildDisk('nvme0n1', 'pve3', { diskType: 'nvme', temperature: 72 })], + nodes: [], + selectedNode: null, + searchTerm: '', + }); + const row = () => + within(view.container.querySelector('[data-row-id="nvme0n1"]') as HTMLElement); + // Factory thresholds until the user's configuration loads: 72C passes + // the 70C NVMe trigger. + expect(row().getByText('Running Hot')).toBeInTheDocument(); + expect(row().getByText('72°C')).toHaveClass('text-red-600'); + + await useAlertsActivation().refreshConfig(); + await waitFor(() => expect(row().getByText('Healthy')).toBeInTheDocument()); + expect(row().getByText('72°C')).toHaveClass('text-amber-600'); + } finally { + getConfig.mockRestore(); + eventBus.emit('org_switched', 'default'); + } + }); + it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => { renderDiskList({ disks: [ diff --git a/frontend-modern/src/components/Storage/useDiskDetailModel.ts b/frontend-modern/src/components/Storage/useDiskDetailModel.ts index 2038b38433..e8c31c049b 100644 --- a/frontend-modern/src/components/Storage/useDiskDetailModel.ts +++ b/frontend-modern/src/components/Storage/useDiskDetailModel.ts @@ -3,6 +3,7 @@ import type { HistoryTimeRange } from '@/api/charts'; import { getPhysicalDiskCollectionMessages, extractPhysicalDiskPresentationData, + getPhysicalDiskTemperatureThresholds, type PhysicalDiskPresentationData, } from '@/features/storageBackups/diskPresentation'; import { @@ -23,11 +24,11 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => { const { getDiskTemperatureThresholds } = useAlertsActivation(); const diskData = createMemo(() => - extractPhysicalDiskPresentationData(options.disk()), + extractPhysicalDiskPresentationData(options.disk(), getDiskTemperatureThresholds), ); const historyResourceId = createMemo(() => resolvePhysicalDiskHistoryResourceId(options.disk())); const attributeCards = createMemo(() => - getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)), + getDiskDetailAttributeCards(diskData(), getPhysicalDiskTemperatureThresholds(diskData())), ); const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData())); const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData())); diff --git a/frontend-modern/src/components/Storage/useDiskListModel.ts b/frontend-modern/src/components/Storage/useDiskListModel.ts index a4aec530bf..93428b1a15 100644 --- a/frontend-modern/src/components/Storage/useDiskListModel.ts +++ b/frontend-modern/src/components/Storage/useDiskListModel.ts @@ -9,6 +9,7 @@ import { filterAndSortPhysicalDisks, type PhysicalDiskPresentationData, } from '@/features/storageBackups/diskPresentation'; +import { useAlertsActivation } from '@/stores/alertsActivation'; import { matchesPhysicalDiskNode } from './diskResourceUtils'; type UseDiskListModelOptions = { @@ -26,13 +27,19 @@ type UseDiskListModelOptions = { export const useDiskListModel = (options: UseDiskListModelOptions) => { const hasPVENodes = createMemo(() => options.nodes().length > 0); + const { getDiskTemperatureThresholds } = useAlertsActivation(); - const diskDataById = createMemo(() => buildPhysicalDiskPresentationDataMap(options.disks())); + // Each disk's heat is judged by the user's alert disk temperature thresholds, + // so the presentation data recomputes when the alert configuration loads. + const diskDataById = createMemo(() => + buildPhysicalDiskPresentationDataMap(options.disks(), getDiskTemperatureThresholds), + ); const roleFilterOptions = createMemo(() => buildPhysicalDiskRoleFilterOptions(options.disks())); const groupFilterOptions = createMemo(() => buildPhysicalDiskGroupFilterOptions(options.disks())); const getDiskData = (disk: Resource): PhysicalDiskPresentationData => - diskDataById().get(disk.id) ?? extractPhysicalDiskPresentationData(disk); + diskDataById().get(disk.id) ?? + extractPhysicalDiskPresentationData(disk, getDiskTemperatureThresholds); const selectedNodeResource = createMemo( () => options.nodes().find((node) => node.id === options.selectedNode()) ?? null, diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 443cbbd5d5..5f094a8215 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -171,7 +171,7 @@ function makeGuestWithDiscoveryTarget(overrides: Partial = {}): W } function getDiscoveryPanel(): HTMLElement { - const panel = screen.getByTestId('discovery-tab').closest('[style*="overflow-anchor"]'); + const panel = screen.getByTestId('discovery-tab').closest('[class*="overflow-anchor"]'); if (!(panel instanceof HTMLElement)) { throw new Error('Discovery tab panel not found'); } @@ -528,6 +528,46 @@ describe('GuestDrawer', () => { ).toBeInTheDocument(); }); + it('leads with the live reading when an open alert holds below its trigger', () => { + solidRender(() => ( + + )); + + expect(screen.getByText('Memory 87% now, back under the 90% alert level')).toBeInTheDocument(); + expect( + screen.getByText('Stays open until it reaches 85% or lower and stays there for 5 minutes.'), + ).toBeInTheDocument(); + expect(screen.queryByText('VM memory at 91.0%')).not.toBeInTheDocument(); + }); + it('keeps generic Assistant and context-copy actions out of the drawer header', () => { render(() => ); @@ -609,7 +649,7 @@ describe('GuestDrawer', () => { const { container } = solidRender(() => ( )); - const panels = container.querySelectorAll('[style*="overflow-anchor"]'); + const panels = container.querySelectorAll('[class*="overflow-anchor"]'); expect(panels[0]).not.toHaveClass('hidden'); expect(panels[1]).toHaveClass('hidden'); }); @@ -910,7 +950,7 @@ describe('GuestDrawer', () => { )); await fireEvent.click(screen.getByRole('tab', { name: 'Discovery' })); - const panels = container.querySelectorAll('[style*="overflow-anchor"]'); + const panels = container.querySelectorAll('[class*="overflow-anchor"]'); expect(panels[0]).toHaveClass('hidden'); expect(getDiscoveryPanel()).not.toHaveClass('hidden'); }); @@ -1044,7 +1084,7 @@ describe('GuestDrawer', () => { )); await fireEvent.click(screen.getByRole('tab', { name: 'Discovery' })); await fireEvent.click(screen.getByRole('tab', { name: 'Overview' })); - const panels = container.querySelectorAll('[style*="overflow-anchor"]'); + const panels = container.querySelectorAll('[class*="overflow-anchor"]'); expect(panels[0]).not.toHaveClass('hidden'); expect(getDiscoveryPanel()).toHaveClass('hidden'); }); diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx index 74901bb1d3..4b0b7f12c8 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx @@ -160,7 +160,7 @@ const GuestDrawerContent: Component = (props) => { {/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps. overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */} -
+
= (props) => {
{hasHistorySupport() && activeTab() === 'history' && ( -
+
= (props) => { up to the app-level Suspense and replace the entire page. */} {(_targetKey) => ( -
+
}> = (props) => { )} -
+
+ import('./GuestPhysicalDisks').then(({ GuestPhysicalDisks }) => ({ + default: GuestPhysicalDisks, + })), +); + interface GuestDrawerOverviewProps { guest: GuestDrawerProps['guest']; guestOsSummary: string; @@ -297,7 +305,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { ({ id: alert.id, - message: getGuestDrawerAlertMessage(alert, { + ...getGuestDrawerAlertAttention(alert, { guest: props.guest, memoryDisplayBasis: props.memoryDisplayBasis, parentMemoryTotal: props.parentMemoryTotal, @@ -317,7 +325,9 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { props.guest.agentRaid?.length } > - + + + diff --git a/frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx b/frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx index a9c75d3db7..2b057ff711 100644 --- a/frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx +++ b/frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx @@ -5,9 +5,13 @@ import { extractPhysicalDiskPresentationData, getPhysicalDiskHealthStatus, } from '@/features/storageBackups/diskPresentation'; +import { + PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS, + getPhysicalDiskTemperaturePresentation, +} from '@/features/storageBackups/diskTemperaturePresentation'; +import { useAlertsActivation } from '@/stores/alertsActivation'; import type { Resource } from '@/types/resource'; import { formatBytes } from '@/utils/format'; -import { formatTemperature } from '@/utils/temperature'; // SMART history and chart controls are only needed after a disk row is opened. // Keep their Storage detail module out of the initial Workloads surface. @@ -17,8 +21,10 @@ const LazyDiskDetail = lazy(() => const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => { const [expanded, setExpanded] = createSignal(false); - const data = () => extractPhysicalDiskPresentationData(props.disk); + const { getDiskTemperatureThresholds } = useAlertsActivation(); + const data = () => extractPhysicalDiskPresentationData(props.disk, getDiskTemperatureThresholds); const health = () => getPhysicalDiskHealthStatus(data()); + const temperature = () => getPhysicalDiskTemperaturePresentation(data()); const label = () => data().model || data().devPath || props.disk.name; return ( @@ -35,8 +41,19 @@ const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => { {health().label} - 0}> - {formatTemperature(data().temperature)} + + {(reading) => ( + + {reading().label} + + , last known + + + )} 0}> {formatBytes(data().size)} diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index adb2046f0a..ef0d60564d 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, Show, splitProps } from 'solid-js'; +import { createMemo, createSignal, Show, splitProps, type JSX } from 'solid-js'; import BoxIcon from 'lucide-solid/icons/box'; import type { VM } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; @@ -22,6 +22,7 @@ import { getWorkloadCPUPercent, resolveWorkloadType } from '@/utils/workloads'; import { EnhancedCPUBar } from '@/components/Workloads/EnhancedCPUBar'; import { MetricMiniSparkline, + MetricMiniSparklineRatePair, type MetricMiniSparklineValueLabelContext, } from '@/components/Workloads/MetricMiniSparkline'; import { UpdateButton } from '@/components/shared/ContainerUpdateBadge'; @@ -38,6 +39,7 @@ import type { GuestRowProps } from './guestRowModel'; import { useGuestRowState } from './useGuestRowState'; import type { WorkloadMetricHistoryReader, + WorkloadRateMetric, WorkloadTableMetric, } from './workloadMetricHistoryModel'; @@ -200,29 +202,88 @@ export function GuestRow(props: GuestRowProps) { } return clampMetricPercent(fallbackUsage); }; + // Every input is an accessor so a live snapshot updates the mounted chart in + // place. Reading signals while building the chart would remount it on each + // update and drop an open scrub tooltip. const renderMetricSparkline = ( metric: WorkloadTableMetric, - valueLabel: string, - title: string, - unit = '%', - valueLabelMode: 'inline' | 'tooltip' | 'hidden' = 'inline', - formatValue?: (value: number) => string, - seriesOptions?: Parameters[2], - valueLabelContext?: MetricMiniSparklineValueLabelContext, + options: { + valueLabel: () => string; + title: () => string; + unit?: string; + formatValue: (value: number) => string; + seriesOptions?: () => Parameters[2]; + valueLabelContext?: () => MetricMiniSparklineValueLabelContext; + valueContent?: () => JSX.Element; + }, ) => ( ); + const renderCpuSparkline = () => + renderMetricSparkline('cpu', { + valueLabel: () => formatMetricPercent(cpuPercent()), + title: () => `${props.guest.name} CPU history`, + formatValue: formatMetricPercent, + }); + const renderMemorySparkline = () => + renderMetricSparkline('memory', { + valueLabel: () => + memoryDisplayUnavailable() + ? 'N/A' + : formatMetricPercent( + usagePercent( + props.guest.memory?.used, + memoryDisplayTotal(), + isHostMemoryBasis() ? undefined : props.guest.memory?.usage, + ), + ), + title: () => + isHostMemoryBasis() + ? `${props.guest.name} host memory share history` + : `${props.guest.name} memory history`, + formatValue: formatMetricPercent, + seriesOptions: () => ({ + memoryDisplayBasis: props.memoryDisplayBasis, + parentMemoryTotal: props.parentMemoryTotal, + }), + }); + const renderDiskSparkline = () => + renderMetricSparkline('disk', { + valueLabel: () => (hasDiskUsage() ? formatMetricPercent(props.guest.disk?.usage) : '—'), + title: () => `${props.guest.name} disk usage history`, + formatValue: formatMetricPercent, + valueLabelContext: () => diskValueLabelContext(), + }); + const renderRateSparkline = (metric: WorkloadRateMetric) => { + const isNetwork = metric === 'netIo'; + const live = () => isRunning() && telemetryAvailable(isNetwork ? 'networkIO' : 'diskIO'); + const values = (): [number, number] => + isNetwork ? [networkIn(), networkOut()] : [diskRead(), diskWrite()]; + return renderMetricSparkline(metric, { + valueLabel: () => + live() ? `${formatSpeed(values()[0])} / ${formatSpeed(values()[1])}` : '—', + title: () => `${props.guest.name} ${isNetwork ? 'network' : 'disk'} I/O history`, + unit: 'B/s', + formatValue: formatSpeed, + valueContent: () => + live() ? : undefined, + }); + }; const getDiskStatusTooltip = () => { if (!isVM(props.guest)) return 'Disk stats unavailable'; @@ -475,27 +536,13 @@ export function GuestRow(props: GuestRowProps) { } >
- {renderMetricSparkline( - 'cpu', - formatMetricPercent(cpuPercent()), - `${props.guest.name} CPU history`, - '%', - 'inline', - formatMetricPercent, - )} + {renderCpuSparkline()}
} > - {renderMetricSparkline( - 'cpu', - formatMetricPercent(cpuPercent()), - `${props.guest.name} CPU history`, - '%', - 'inline', - formatMetricPercent, - )} + {renderCpuSparkline()} @@ -545,55 +592,13 @@ export function GuestRow(props: GuestRowProps) { } >
- {renderMetricSparkline( - 'memory', - memoryDisplayUnavailable() - ? 'N/A' - : formatMetricPercent( - usagePercent( - props.guest.memory?.used, - memoryDisplayTotal(), - isHostMemoryBasis() ? undefined : props.guest.memory?.usage, - ), - ), - isHostMemoryBasis() - ? `${props.guest.name} host memory share history` - : `${props.guest.name} memory history`, - '%', - 'inline', - formatMetricPercent, - { - memoryDisplayBasis: props.memoryDisplayBasis, - parentMemoryTotal: props.parentMemoryTotal, - }, - )} + {renderMemorySparkline()}
} > - {renderMetricSparkline( - 'memory', - memoryDisplayUnavailable() - ? 'N/A' - : formatMetricPercent( - usagePercent( - props.guest.memory?.used, - memoryDisplayTotal(), - isHostMemoryBasis() ? undefined : props.guest.memory?.usage, - ), - ), - isHostMemoryBasis() - ? `${props.guest.name} host memory share history` - : `${props.guest.name} memory history`, - '%', - 'inline', - formatMetricPercent, - { - memoryDisplayBasis: props.memoryDisplayBasis, - parentMemoryTotal: props.parentMemoryTotal, - }, - )} + {renderMemorySparkline()} @@ -605,18 +610,7 @@ export function GuestRow(props: GuestRowProps) { data-workload-col="disk" title={diskReadStatus()?.message} > - - {renderMetricSparkline( - 'disk', - hasDiskUsage() ? formatMetricPercent(props.guest.disk?.usage) : '—', - `${props.guest.name} disk usage history`, - '%', - 'inline', - formatMetricPercent, - undefined, - diskValueLabelContext(), - )} - + {renderDiskSparkline()}
- {renderMetricSparkline( - 'disk', - formatMetricPercent(props.guest.disk?.usage), - `${props.guest.name} disk usage history`, - '%', - 'inline', - formatMetricPercent, - undefined, - diskValueLabelContext(), - )} + {renderDiskSparkline()}
@@ -916,18 +901,7 @@ export function GuestRow(props: GuestRowProps) { {/* Net I/O */} - - {renderMetricSparkline( - 'netIo', - isRunning() && telemetryAvailable('networkIO') - ? `${formatSpeed(networkIn())} / ${formatSpeed(networkOut())}` - : '—', - `${props.guest.name} network I/O history`, - 'B/s', - 'tooltip', - formatSpeed, - )} - + {renderRateSparkline('netIo')} } > -
- ↓ - - {formatSpeed(networkIn())} - - ↑ - - {formatSpeed(networkOut())} - -
+ + ↓ + + {formatSpeed(networkIn())} + + ↑ + + {formatSpeed(networkOut())} + +
+ } + > +
+ {renderRateSparkline('netIo')} +
+
@@ -971,18 +954,7 @@ export function GuestRow(props: GuestRowProps) { {/* Disk I/O */} - - {renderMetricSparkline( - 'diskIo', - isRunning() && telemetryAvailable('diskIO') - ? `${formatSpeed(diskRead())} / ${formatSpeed(diskWrite())}` - : '—', - `${props.guest.name} disk I/O history`, - 'B/s', - 'tooltip', - formatSpeed, - )} - + {renderRateSparkline('diskIo')} } > -
- R - - {formatSpeed(diskRead())} - - W - - {formatSpeed(diskWrite())} - -
+ + R + + {formatSpeed(diskRead())} + + W + + {formatSpeed(diskWrite())} + +
+ } + > +
+ {renderRateSparkline('diskIo')} +
+ diff --git a/frontend-modern/src/components/Workloads/MetricDisplayModeSegmentedControl.tsx b/frontend-modern/src/components/Workloads/MetricDisplayModeSegmentedControl.tsx index a6faaa2a3b..e5e5ee47d8 100644 --- a/frontend-modern/src/components/Workloads/MetricDisplayModeSegmentedControl.tsx +++ b/frontend-modern/src/components/Workloads/MetricDisplayModeSegmentedControl.tsx @@ -47,7 +47,7 @@ export const MetricHoverModeSegmentedControl: Component = (props) const showInlineValue = createMemo( () => valueLabelMode() === 'inline' || (valueLabelMode() === 'tooltip' && !hasLine()), ); - const [hoveredState, setHoveredState] = createSignal( - null, - ); + // Keep only where the pointer is. The tooltip's point and values derive from + // the current series, so a live update under an open tooltip re-reads it + // instead of showing the snapshot taken at the last mouse move. + const [pointer, setPointer] = createSignal(null); + const hoveredState = createMemo(() => { + const current = pointer(); + if (!current) return null; + const state = computeMetricMiniSparklineHoverState(props.series, current.cursorRatio, 1); + return state ? { ...state, tooltipX: current.tooltipX, tooltipY: current.tooltipY } : null; + }); const synchronizedState = createMemo(() => { const ratio = props.cursorRatio; if (ratio === null || ratio === undefined) return null; @@ -102,7 +117,7 @@ export const MetricMiniSparkline: Component = (props) ); const handleMouseMove: JSX.EventHandler = (event) => { if (!hasLine()) { - setHoveredState(null); + setPointer(null); props.onCursorRatioChange?.(null); return; } @@ -113,10 +128,10 @@ export const MetricMiniSparkline: Component = (props) event.clientX - rect.left, rect.width, ); - setHoveredState( + setPointer( next ? { - ...next, + cursorRatio: next.cursorRatio, tooltipX: event.clientX, tooltipY: rect.top, } @@ -125,7 +140,7 @@ export const MetricMiniSparkline: Component = (props) props.onCursorRatioChange?.(next?.cursorRatio ?? null); }; const handleMouseLeave = () => { - setHoveredState(null); + setPointer(null); props.onCursorRatioChange?.(null); }; const showTooltip = createMemo(() => (props.showTooltip ?? true) && hoveredState()); @@ -179,7 +194,7 @@ export const MetricMiniSparkline: Component = (props) - {displayLabel()} + {props.valueContent ?? displayLabel()} @@ -215,3 +230,28 @@ export const MetricMiniSparkline: Component = (props)
); }; + +interface MetricMiniSparklineRatePairProps { + metric: WorkloadRateMetric; + values: readonly [number, number]; +} + +/** + * Current in/out (or read/write) rates compact enough to sit beside a rate + * sparkline. The full rates belong in the chart's valueLabel, which carries + * them to assistive technology, so this abbreviation stays out of that tree. + */ +export const MetricMiniSparklineRatePair: Component = (props) => ( + +); diff --git a/frontend-modern/src/components/Workloads/NodeDrawer.tsx b/frontend-modern/src/components/Workloads/NodeDrawer.tsx index 38006379af..20ee64ad42 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawer.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawer.tsx @@ -124,7 +124,7 @@ export const NodeDrawer: Component = (props) => { {/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps. overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */} -
+
= (props) => { />
-
+
= (props) => { />
-
+
= (props) => { {(target) => ( -
+
}> ({ id: alert.id, - message: alert.message, + ...getAlertAttentionCopy(alert), subject: cleanText(alert.resourceName) || cleanText(alert.nodeDisplayName) || diff --git a/frontend-modern/src/components/Workloads/ThresholdSlider.tsx b/frontend-modern/src/components/Workloads/ThresholdSlider.tsx index 520895ff1e..dae5b17ed8 100644 --- a/frontend-modern/src/components/Workloads/ThresholdSlider.tsx +++ b/frontend-modern/src/components/Workloads/ThresholdSlider.tsx @@ -33,9 +33,8 @@ export function ThresholdSlider(props: ThresholdSliderProps) { onInput={state.handleInput} onMouseDown={state.handleMouseDown} onWheel={state.handleInputWheel} - class={`absolute inset-0 w-full h-3.5 opacity-0 ${props.disabled ? 'cursor-not-allowed' : 'cursor-pointer'} z-20`} + class={`absolute inset-0 w-full h-3.5 opacity-0 ${props.disabled ? 'cursor-not-allowed' : 'cursor-pointer'} z-20 touch-none`} disabled={props.disabled} - style={{ 'touch-action': 'none' }} title={state.sliderTitle()} /> diff --git a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx index 9caf56f762..de8a3cab1d 100644 --- a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx @@ -1,10 +1,6 @@ import { createMemo, For, Show } from 'solid-js'; import { ComponentErrorBoundary } from '@/components/ErrorBoundary'; -import { EnhancedCPUBar } from '@/components/Workloads/EnhancedCPUBar'; -import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline'; -import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar'; -import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar'; import { GROUPED_TABLE_ROW_BADGE_CLASS, getGroupedTableRowCellClass, @@ -16,12 +12,9 @@ import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; import { createSummaryInteractiveRowPreviewHandlers } from '@/components/shared/summaryInteractionA11y'; import { buildSummaryDisclosureControlsId } from '@/components/shared/summaryInteractionA11y'; import { TableBody, TableCell, TableRow } from '@/components/shared/Table'; -import type { Node } from '@/types/api'; import { getAlertsForResource, getAlertStyles } from '@/utils/alerts'; import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity'; -import { formatSpeed, formatUptime } from '@/utils/format'; import { isNodeOnline } from '@/utils/status'; -import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature'; import { getCanonicalWorkloadId, getWorkloadMetadataIdCandidates } from '@/utils/workloads'; import { resolveSummaryGroupMemberInteractionState, @@ -34,14 +27,12 @@ import { NodeDrawer } from './NodeDrawer'; import { buildWorkloadSummaryGroupScope } from './workloadSelectors'; import { getWorkloadGuestMetadataRecord } from './workloadGuestMetadataRecord'; import type { WorkloadsState } from './useWorkloadsState'; -import type { WorkloadTableMetric } from './workloadMetricHistoryModel'; type WorkloadPanelProps = Pick< WorkloadsState, | 'activeAlerts' | 'alertsEnabled' | 'bottomSpacerHeight' - | 'compactGroupHeaders' | 'getGroupLabel' | 'getNodeTemperatureThresholds' | 'groupedGuests' @@ -58,7 +49,6 @@ type WorkloadPanelProps = Pick< | 'focusedSummaryWorkloadGroupScope' | 'focusedSummaryWorkloadGroupId' | 'hoveredSummaryWorkloadGroupScope' - | 'isMobile' | 'nestedWorkloadContextByGuestId' | 'nodeByInstance' | 'search' @@ -80,289 +70,9 @@ type WorkloadPanelProps = Pick< | 'workloadTableLayoutMode' | 'workloadTableVisibleColumnIds' | 'workloadColumnWidths' - | 'workloadTableVisibleColumns' >; -const GROUP_NODE_METRIC_CELL_CLASS = 'px-1.5 sm:px-2 py-0.5 align-middle'; -const GROUP_NODE_NAME_CELL_CLASS = getGroupedTableRowCellClass( - 'py-1! pl-2! sm:pl-3! pr-1.5! sm:pr-2!', -); - -const getGroupNodeColumnCellClass = (_columnId: string, isNameColumn: boolean): string => { - if (isNameColumn) return GROUP_NODE_NAME_CELL_CLASS; - return GROUP_NODE_METRIC_CELL_CLASS; -}; - -const normalizeMetricPercent = (value: number | null | undefined): number => { - if (typeof value !== 'number' || !Number.isFinite(value)) return 0; - return value <= 1 ? Math.max(0, value * 100) : Math.max(0, value); -}; - -const formatMetricPercent = (value: number | null | undefined): string => - `${Math.round(normalizeMetricPercent(value))}%`; - -const getUsedPercent = (used?: number | null, total?: number | null): number => { - if (typeof used !== 'number' || typeof total !== 'number' || total <= 0) return 0; - return normalizeMetricPercent((used / total) * 100); -}; - -const getGroupNodeMetricKey = (node: Node): string => node.linkedAgentId || node.id || node.name; - -const getGroupNodePveVersion = (node: Node): string => { - const version = (node.pveVersion || '').trim(); - if (!version || version.toLowerCase() === 'unknown') return ''; - return ( - version.match(/pve-manager\/([^/\s]+)/i)?.[1] || version.match(/\d+(?:\.\d+)+/)?.[0] || version - ); -}; - -const renderGroupNodeEmptyCell = () => ( -
- -
-); - export function WorkloadPanel(props: WorkloadPanelProps) { - const renderGroupNodeSparkline = ( - node: Node, - metric: WorkloadTableMetric, - valueLabel: string, - title: string, - unit = '%', - valueLabelMode: 'inline' | 'tooltip' | 'hidden' = 'inline', - formatValue?: (value: number) => string, - ) => ( - - ); - - const renderGroupNodeRatePair = ( - leftLabel: string, - leftClass: string, - leftValue: number | null | undefined, - rightLabel: string, - rightClass: string, - rightValue: number | null | undefined, - ) => { - const left = Math.max(0, leftValue ?? 0); - const right = Math.max(0, rightValue ?? 0); - if (left <= 0 && right <= 0) return renderGroupNodeEmptyCell(); - - return ( -
- {leftLabel} - - {formatSpeed(left)} - - {rightLabel} - - {formatSpeed(right)} - -
- ); - }; - - const renderGroupNodeInfoCell = (node: Node) => { - const version = getGroupNodePveVersion(node); - const temperature = getCpuTemperature(node.temperature); - if (!version && temperature === null) return renderGroupNodeEmptyCell(); - - return ( -
- - PVE {version} - - - - {formatTemperature(temperature)} - - -
- ); - }; - - const renderGroupNodeColumnCell = (columnId: string, node: Node) => { - switch (columnId) { - case 'type': - return ( -
- - PVE - -
- ); - case 'info': - case 'vmid': - return renderGroupNodeInfoCell(node); - case 'cpu': - if (props.workloadMetricDisplayMode() === 'sparklines') { - return renderGroupNodeSparkline( - node, - 'cpu', - formatMetricPercent(node.cpu), - `${node.name} CPU history`, - '%', - 'inline', - formatMetricPercent, - ); - } - return ( -
- -
- ); - case 'memory': - if (props.workloadMetricDisplayMode() === 'sparklines') { - return renderGroupNodeSparkline( - node, - 'memory', - node.memory?.usageUnavailable - ? 'N/A' - : formatMetricPercent(getUsedPercent(node.memory?.used, node.memory?.total)), - `${node.name} memory history`, - '%', - 'inline', - formatMetricPercent, - ); - } - return ( -
- -
- ); - case 'disk': - if (props.workloadMetricDisplayMode() === 'sparklines') { - return renderGroupNodeSparkline( - node, - 'disk', - formatMetricPercent(node.disk?.usage), - `${node.name} disk usage history`, - '%', - 'inline', - formatMetricPercent, - ); - } - return ( -
- -
- ); - case 'uptime': - return ( -
- 0} fallback={renderGroupNodeEmptyCell()}> - 0 && node.uptime < 3600 ? 'text-orange-500' : 'text-muted' - }`} - > - {formatUptime(node.uptime, props.isMobile())} - - -
- ); - case 'node': - case 'context': - return ( -
- - {node.name} - -
- ); - case 'os': - return ( -
- PVE -
- ); - case 'netIo': - if (props.workloadMetricDisplayMode() === 'sparklines') { - return renderGroupNodeSparkline( - node, - 'netIo', - `${formatSpeed(node.networkIn ?? 0)} / ${formatSpeed(node.networkOut ?? 0)}`, - `${node.name} network I/O history`, - 'B/s', - 'tooltip', - formatSpeed, - ); - } - return renderGroupNodeRatePair( - '↓', - 'text-emerald-500', - node.networkIn, - '↑', - 'text-orange-400', - node.networkOut, - ); - case 'diskIo': - if (props.workloadMetricDisplayMode() === 'sparklines') { - return renderGroupNodeSparkline( - node, - 'diskIo', - `${formatSpeed(node.diskRead ?? 0)} / ${formatSpeed(node.diskWrite ?? 0)}`, - `${node.name} disk I/O history`, - 'B/s', - 'tooltip', - formatSpeed, - ); - } - return renderGroupNodeRatePair( - 'R', - 'font-mono text-blue-500', - node.diskRead, - 'W', - 'font-mono text-amber-500', - node.diskWrite, - ); - default: - return renderGroupNodeEmptyCell(); - } - }; - return ( 0}> @@ -485,22 +195,8 @@ export function WorkloadPanel(props: WorkloadPanelProps) { > td>div]:flex-nowrap' diff --git a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx index 75814e225a..34d226b784 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx @@ -294,6 +294,20 @@ export const WorkloadsFilter: Component = (props) => { buildWorkloadSearchSuggestions(props.searchSuggestionWorkloads?.() ?? []), ); + // The history hint clears on the first populated row preview, while the + // pointer is over the table. Once shown, it keeps its box for as long as row + // hover previews history: removing it could unwrap the action row and slide + // the hovered guest out from under the pointer, retargeting the lens to the + // next row. Leaving bars + history hover, or remounting, releases the box. + const historyHintSlotReserved = createMemo( + (reserved) => + props.metricHistoryHintVisible?.() === true || + (reserved && + props.metricDisplayMode?.() === 'bars' && + (props.metricHoverMode?.() ?? 'history') === 'history'), + false, + ); + return ( = (props) => { } >
- +

{item.message}

+ +

{item.detail}

+
= (props) => { id={popoverId} role="dialog" aria-labelledby={titleId} - class="fixed z-9999 bg-surface rounded-md shadow-xs border border-border overflow-hidden animate-in fade-in-0 zoom-in-95 duration-150" + class="fixed z-9999 bg-surface rounded-md shadow-xs border border-border overflow-hidden animate-in fade-in-0 zoom-in-95 duration-150 min-w-[200px]" style={{ top: `${state.popoverPosition().top}px`, left: `${state.popoverPosition().left}px`, 'max-width': `${state.maxWidth()}px`, - 'min-width': '200px', }} > {/* Header */} diff --git a/frontend-modern/src/components/shared/NodeGroupHeader.tsx b/frontend-modern/src/components/shared/NodeGroupHeader.tsx index 0c3b9feb69..2dfa8db0fc 100644 --- a/frontend-modern/src/components/shared/NodeGroupHeader.tsx +++ b/frontend-modern/src/components/shared/NodeGroupHeader.tsx @@ -1,12 +1,8 @@ -import { Component, For, Show, type JSX } from 'solid-js'; +import { Component, Show, type JSX } from 'solid-js'; import type { Node } from '@/types/api'; -import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { getNodeDisplayName, getNodeExternalUrl, hasAlternateDisplayName } from '@/utils/nodes'; import { StatusDot } from '@/components/shared/StatusDot'; import { getNodeStatusIndicator } from '@/utils/status'; -import { formatUptime } from '@/utils/format'; -import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature'; -import { formatProxmoxVersion } from '@/utils/proxmoxVersion'; import { GROUPED_TABLE_ROW_BADGE_CLASS, getGroupedTableRowCellClass, @@ -17,13 +13,8 @@ import { ResourceNameWithWebInterfaceLink } from './WebInterfaceLink'; interface NodeGroupHeaderProps { node: Node; colspan?: number; - columns?: Array<{ id: string }>; - columnCellClass?: (columnId: string, isNameColumn: boolean) => string; leadingAction?: JSX.Element; - renderColumnCell?: (columnId: string, node: Node) => JSX.Element; renderAs?: 'tr' | 'div'; - showFactsInName?: boolean; - temperatureThresholds?: MetricDisplayThresholds | null; trClass?: string; trProps?: JSX.HTMLAttributes & Partial>; @@ -35,12 +26,6 @@ export const NodeGroupHeader: Component = (props) => { const nodeUrl = () => getNodeExternalUrl(props.node); const displayName = () => getNodeDisplayName(props.node); const showActualName = () => hasAlternateDisplayName(props.node); - const pveVersion = () => formatProxmoxVersion(props.node.pveVersion); - const cpuTemperature = () => getCpuTemperature(props.node.temperature); - const hasNodeFacts = () => - Boolean(pveVersion()) || - cpuTemperature() !== null || - (typeof props.node.uptime === 'number' && props.node.uptime > 0); const InnerContent = () => (
= (props) => { Agent - - - -
); @@ -118,38 +84,9 @@ export const NodeGroupHeader: Component = (props) => { } > - 0 && Boolean(props.renderColumnCell)} - fallback={ - - - - } - > - - {(column) => { - const isNameColumn = () => column.id === 'name'; - return ( - - - - - - ); - }} - - + + +
); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 527c07f433..2a6f7284e9 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -1,3 +1,6 @@ +import { traverse, transformSync, types as t } from '@babel/core'; +// @ts-expect-error babel-preset-solid ships no type declarations. +import solidPreset from 'babel-preset-solid'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -101,7 +104,6 @@ import summaryTableFocusSource from '@/components/shared/summaryTableFocus.ts?ra import tableCardSource from '@/components/shared/TableCard.tsx?raw'; import groupedTableModeSegmentedControlSource from '@/components/shared/GroupedTableModeSegmentedControl.tsx?raw'; import groupedTableRowPresentationSource from '@/components/shared/groupedTableRowPresentation.ts?raw'; -import unifiedResourceTableViewportSyncSource from '@/components/Infrastructure/useUnifiedResourceTableViewportSync.ts?raw'; import storagePoolsTableWindowingSource from '@/components/Storage/useStoragePoolsTableWindowing.ts?raw'; import workloadViewportSyncSource from '@/components/Workloads/useWorkloadViewportSync.ts?raw'; import platformWindowedItemsSource from '@/features/platformPage/usePlatformWindowedItems.ts?raw'; @@ -217,16 +219,13 @@ import proxmoxCoverageTableSource from '@/features/proxmox/ProxmoxCoverageTable. import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw'; import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw'; import proxmoxHostTableModelSource from '@/features/proxmox/proxmoxHostTableModel.ts?raw'; +import proxmoxPageModelSource from '@/features/proxmox/proxmoxPageModel.ts?raw'; import proxmoxRecoverableTableSource from '@/features/proxmox/ProxmoxRecoverableTable.tsx?raw'; import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplicationTable.tsx?raw'; import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw'; import availabilityChecksTableSource from '@/features/standalone/AvailabilityChecksTable.tsx?raw'; import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw'; import agentMachineTableModelSource from '@/features/standalone/agentMachineTableModel.ts?raw'; -import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw'; -import unifiedResourceServiceInfrastructureCardSource from '@/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx?raw'; -import unifiedResourcePBSTableSectionSource from '@/components/Infrastructure/UnifiedResourcePBSTableSection.tsx?raw'; -import unifiedResourcePMGTableSectionSource from '@/components/Infrastructure/UnifiedResourcePMGTableSection.tsx?raw'; import proxmoxMailGatewayDrawerSource from '@/features/proxmox/ProxmoxMailGatewayDrawer.tsx?raw'; import swarmServicesDrawerSource from '@/components/Docker/SwarmServicesDrawer.tsx?raw'; import k8sDeploymentsDrawerSource from '@/components/Kubernetes/K8sDeploymentsDrawer.tsx?raw'; @@ -352,6 +351,19 @@ describe('shared primitive guardrails', () => { expect(source).toContain(' { + const source = readFrontendSource('src/components/shared/cards/DisksCard.tsx'); + expect(source).toContain('lastKnownReason?: string;'); + expect(source).toContain('`Last known reading, not current: ${reason}`'); + expect(source).toMatch(/\s*]*data-testid="disks-card-mount-bar"/, + ); + expect( + readFrontendSource('src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx'), + ).toContain('lastKnownReason={props.drawer.agentReadingsLastKnownReason()}'); + }); + it('keeps one canonical agent-host metric history group catalog', () => { expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([ 'utilization', @@ -1057,8 +1069,6 @@ describe('shared primitive guardrails', () => { expect(registeredRule?.canonical?.path).toBe('src/components/shared/TableCardHeader.tsx'); expect(registeredRule?.canonical?.export).toBe('TableCardHeader'); expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ - 'src/components/Infrastructure/UnifiedResourceHostTableCard.tsx', - 'src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx', 'src/components/Storage/StorageContentCard.tsx', 'src/features/platformPage/sharedPlatformPage.tsx', ]); @@ -1080,11 +1090,7 @@ describe('shared primitive guardrails', () => { expect(tableCardHeaderSource).toContain('TABLE_CARD_HEADER_CLASS'); expect(tableCardHeaderSource).not.toContain('Pinned to'); expect(tableCardHeaderSource).not.toContain('Scoped to'); - for (const source of [ - unifiedResourceHostTableCardSource, - unifiedResourceServiceInfrastructureCardSource, - storageContentCardSource, - ]) { + for (const source of [storageContentCardSource]) { expect(source).toContain('TableCardHeader'); expect(source).not.toContain('SummaryTableCardHeader'); } @@ -1116,8 +1122,6 @@ describe('shared primitive guardrails', () => { expect(registeredRule?.canonical?.path).toBe('src/components/shared/TableCard.tsx'); expect(registeredRule?.canonical?.export).toBe('TableCard'); expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ - 'src/components/Infrastructure/UnifiedResourceHostTableCard.tsx', - 'src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx', 'src/components/Storage/StorageContentCard.tsx', 'src/components/Workloads/WorkloadsSurface.tsx', 'src/components/Workloads/WorkloadsTable.tsx', @@ -1149,8 +1153,6 @@ describe('shared primitive guardrails', () => { workloadsSurfaceSource, alertHistoryTableSectionSource, sharedPlatformPageSource, - unifiedResourceHostTableCardSource, - unifiedResourceServiceInfrastructureCardSource, storageContentCardSource, ]) { expect(source).toContain('TableCard'); @@ -1394,13 +1396,7 @@ describe('shared primitive guardrails', () => { expect(guestRowStateSource).not.toContain('bg-sky-50/70'); expect(guestRowStateSource).not.toContain('ring-sky-400/25'); - for (const source of [ - storagePoolRowSource, - diskListSource, - unifiedResourceHostTableCardSource, - unifiedResourcePBSTableSectionSource, - unifiedResourcePMGTableSectionSource, - ]) { + for (const source of [storagePoolRowSource, diskListSource]) { expect(source).toContain('data-summary-row-active'); expect(source).not.toContain('bg-sky-50/70'); expect(source).not.toContain('ring-sky-400/25'); @@ -1416,8 +1412,6 @@ describe('shared primitive guardrails', () => { expect(nodeGroupHeaderSource).toContain('getGroupedTableRowCellClass'); expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass'); expect(workloadPanelSource).toContain('getGroupedTableRowCellClass'); - expect(unifiedResourceHostTableCardSource).toContain('getInteractiveGroupedTableRowClass'); - expect(unifiedResourceHostTableCardSource).toContain('getGroupedTableRowCellClass'); expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowClass'); expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowCellClass'); expect(alertHistoryTableGroupRowSource).not.toContain('class="bg-surface-alt"'); @@ -1426,13 +1420,12 @@ describe('shared primitive guardrails', () => { expect(infrastructureSourceManagerSource).toContain('getGroupedTableRowClass'); expect(infrastructureSourceManagerSource).toContain('getGroupedTableRowCellClass'); expect(infrastructureSourceManagerSource).not.toContain('bg-base hover:bg-base'); - expect(unifiedResourceHostTableCardSource).toContain('data-summary-group-member-active'); }); it('routes Proxmox node version presentation through the shared formatter', () => { - expect(nodeGroupHeaderSource).toContain("from '@/utils/proxmoxVersion'"); - expect(nodeGroupHeaderSource).toContain('formatProxmoxVersion(props.node.pveVersion)'); - expect(nodeGroupHeaderSource).not.toContain('pve-manager\\/'); + expect(proxmoxPageModelSource).toContain("from '@/utils/proxmoxVersion'"); + expect(proxmoxPageModelSource).toContain('formatProxmoxVersion(resource.proxmox?.pveVersion)'); + expect(proxmoxPageModelSource).not.toContain('pve-manager\\/'); expect(proxmoxVersionSource).toContain('formatProxmoxVersion'); expect(proxmoxVersionSource).toContain('pve-manager\\/'); @@ -1463,7 +1456,6 @@ describe('shared primitive guardrails', () => { expect(windowedPageScrollSource).not.toContain("addEventListener('touch"); for (const source of [ platformWindowedItemsSource, - unifiedResourceTableViewportSyncSource, storagePoolsTableWindowingSource, workloadViewportSyncSource, ]) { @@ -1487,9 +1479,6 @@ describe('shared primitive guardrails', () => { expect(tableCardHeaderSource).toContain('TABLE_CARD_HEADER_CLASS'); for (const source of [ - unifiedResourceHostTableCardSource, - unifiedResourcePBSTableSectionSource, - unifiedResourcePMGTableSectionSource, alertHistoryTableSectionSource, workloadsTableSource, storagePoolsTableSource, @@ -1509,7 +1498,6 @@ describe('shared primitive guardrails', () => { } for (const source of [ - unifiedResourceHostTableCardSource, alertHistoryTableSectionSource, workloadsTableSource, storageContentCardSource, @@ -1517,9 +1505,7 @@ describe('shared primitive guardrails', () => { expect(source).toContain(' { expect(platformResourceDetailTableRowSource).toContain('SummaryRowActionButton'); expect(platformResourceDetailTableRowSource).toContain('hideWhenRowTappableOnMobile'); - for (const source of [ - guestRowSource, - storagePoolRowSource, - diskListSource, - unifiedResourceHostTableCardSource, - unifiedResourcePBSTableSectionSource, - unifiedResourcePMGTableSectionSource, - ]) { + for (const source of [guestRowSource, storagePoolRowSource, diskListSource]) { expect(source).toContain('createSummaryInteractiveRowPreviewHandlers'); expect(source).toContain('SummaryRowActionButton'); expect(source).toContain('hideWhenRowTappableOnMobile'); @@ -1740,7 +1719,6 @@ describe('shared primitive guardrails', () => { expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers'); expect(workloadPanelSource).not.toContain('kind="scope"'); expect(storageGroupRowSource).not.toContain('kind="scope"'); - expect(unifiedResourceHostTableCardSource).not.toContain('kind="scope"'); expect(nodeGroupHeaderSource).toContain('ResourceNameWithWebInterfaceLink'); expect(webInterfaceLinkSource).toContain('event.stopPropagation()'); @@ -5394,9 +5372,6 @@ describe('shared primitive guardrails', () => { 'src/features/proxmox/ProxmoxNodesTable.tsx', 'src/components/Alerts/AlertResourceGroupHeader.tsx', 'src/components/Alerts/AlertResourceTableRow.tsx', - 'src/components/Infrastructure/UnifiedResourceHostTableCard.tsx', - 'src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx', - 'src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx', 'src/features/docker/DockerNativeTableShared.tsx', 'src/features/docker/DockerContainersTable.tsx', 'src/features/kubernetes/KubernetesClustersTable.tsx', @@ -5413,9 +5388,6 @@ describe('shared primitive guardrails', () => { expect(proxmoxNodesTableSource).toContain('ResourceNameWithWebInterfaceLink'); expect(alertResourceGroupHeaderSource).toContain('ResourceNameWithWebInterfaceLink'); expect(alertResourceTableRowSource).toContain('ResourceNameWithWebInterfaceLink'); - expect(unifiedResourceHostTableCardSource).toContain('ResourceNameWithWebInterfaceLink'); - expect(unifiedResourcePBSTableSectionSource).toContain('ResourceNameWithWebInterfaceLink'); - expect(unifiedResourcePMGTableSectionSource).toContain('ResourceNameWithWebInterfaceLink'); expect(nodeGroupHeaderSource).not.toContain('target="_blank"'); expect(guestRowSource).not.toContain('target="_blank"'); expect(agentsMachinesTableSource).not.toContain('target="_blank"'); @@ -7766,9 +7738,6 @@ describe('shared primitive guardrails', () => { expect(inlineDetailTableRowSource).toContain('.focus({ preventScroll: true })'); expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual( expect.arrayContaining([ - 'src/components/Infrastructure/UnifiedResourceHostTableCard.tsx', - 'src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx', - 'src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx', 'src/components/Workloads/WorkloadPanel.tsx', 'src/features/docker/DockerHostsTable.tsx', 'src/features/docker/DockerNetworksTable.tsx', @@ -7796,9 +7765,6 @@ describe('shared primitive guardrails', () => { dockerHostsTableSource, proxmoxCoverageTableSource, proxmoxNodesTableSource, - unifiedResourceHostTableCardSource, - unifiedResourcePBSTableSectionSource, - unifiedResourcePMGTableSectionSource, workloadPanelSource, ]) { expect(source).toContain('InlineDetailTableRow'); @@ -8798,6 +8764,21 @@ describe('shared primitive guardrails', () => { expect(drawerAttentionSectionSource).toContain('Show fewer alerts'); expect(drawerAttentionSectionSource).toContain('aria-expanded={expanded()}'); expect(drawerAttentionSectionSource).not.toContain('DetailSectionTable'); + // A threshold alert held under its trigger leads with the live reading and + // a second line saying what clears it, never only its stale breach text. + expect(drawerAttentionSectionSource).toContain('item.detail'); + expect(drawerAttentionSectionSource).toContain('item.title ?? item.message'); + for (const liveAlertConsumer of [ + 'src/components/Workloads/NodeDrawerOverview.tsx', + 'src/features/proxmox/ProxmoxMailGatewayDrawer.tsx', + ]) { + const source = readFrontendSource(liveAlertConsumer); + expect(source).toContain('...getAlertAttentionCopy(alert)'); + expect(source).not.toContain('message: alert.message'); + } + expect(readFrontendSource('src/components/Workloads/guestDrawerModel.ts')).toContain( + 'getAlertAttentionCopy(alert)', + ); const overviewConsumers = [ 'src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx', @@ -8894,3 +8875,135 @@ describe('shared primitive guardrails', () => { } }); }); + +// The production server sends `style-src 'self' 'nonce-...'` without +// 'unsafe-inline' (internal/api/security.go), so the browser reports every +// style attribute that arrives as markup. Solid compiles static style values +// (`style="..."`, or any literal entry of a `style={{ ... }}` object) into the +// element's template HTML, which is such markup: each render logs a CSP +// violation, and whether the style still applies is up to the browser. The Vite +// dev server allows inline styles, so this only shows in a production build. +// Dynamic style values are applied through CSSOM at runtime and are fine. +const componentRuntimeSources = import.meta.glob( + ['../../**/*.tsx', '!../../**/__tests__/**', '!../../**/*.test.tsx'], + { query: '?raw', eager: true, import: 'default' }, +) as Record; + +const compileSolidTemplates = (source: string, filename: string): string[] => { + // Same Solid preset options vite-plugin-solid uses for the client build. + const result = transformSync(source, { + filename, + babelrc: false, + configFile: false, + ast: true, + code: false, + parserOpts: { plugins: ['jsx', 'typescript'] }, + presets: [[solidPreset, { generate: 'dom', hydratable: false }]], + }); + const templates: string[] = []; + if (!result?.ast) return templates; + + const templateHelpers = new Set(); + traverse(result.ast, { + ImportSpecifier(path) { + const imported = path.node.imported; + const importedName = t.isIdentifier(imported) ? imported.name : imported.value; + const parent = path.parentPath.node; + if ( + importedName === 'template' && + t.isImportDeclaration(parent) && + parent.source.value === 'solid-js/web' + ) { + templateHelpers.add(path.node.local.name); + } + }, + }); + if (templateHelpers.size === 0) return templates; + + traverse(result.ast, { + CallExpression(path) { + const { callee, arguments: args } = path.node; + if (!t.isIdentifier(callee) || !templateHelpers.has(callee.name)) return; + const [html] = args; + if (t.isTemplateLiteral(html)) { + templates.push(html.quasis.map((quasi) => quasi.value.cooked ?? '').join('')); + } else if (t.isStringLiteral(html)) { + templates.push(html.value); + } + }, + }); + return templates; +}; + +// Style attributes and + + ); + `; + + expect(findTemplateStyleAttributes(fixture, 'Fixture.tsx')).toEqual([ + '
', + '', + '

', + '', + '', + '