Canonicalize platform table row density

This commit is contained in:
rcourtman
2026-08-24 16:16:10 +01:00
parent 9c7cb9ed9b
commit 714b26b69f
32 changed files with 517 additions and 396 deletions
@@ -1169,10 +1169,15 @@ not a replacement status card, CTA band, or page-local nested card.
`PlatformTableShell` marks every
owned table with the shared `platform-table` class; direct Workloads and
Storage consumers and nested provider detail tables must apply that same
marker so the phone contract cannot vary by rendering path. Text-only
operational rows must use `getPlatformTableRowClass` so the absence of
buttons, badges, or progress bars does not collapse them below the canonical
compact scanning rhythm. Mobile truncation may rely on a full-value row
marker so the phone contract cannot vary by rendering path. The marker owns
a canonical 32-pixel summary-row rhythm across every provider and excludes
inline detail rows, which remain content-sized. Summary cells must render
one line only: secondary identity, raw provider labels, and descriptive
context belong in an existing operational column, a supplemental tooltip,
or its inline detail drawer rather than a stacked subtitle. Text-only
operational rows may use `getPlatformTableRowClass` to declare that same
shared rhythm explicitly; providers must not introduce local row-height
exceptions. Mobile truncation may rely on a full-value row
detail only when that row actually has a keyboard- and touch-operable
disclosure; a non-expandable identity cell must otherwise expose its
complete value without requiring hover. Default data rows stay single-line
@@ -3925,7 +3930,12 @@ label and destination intent; they must not own CTA button chrome.
Platform table frames are one of those registry-backed templates.
`frontend-modern/src/features/platformPage/sharedPlatformPage.tsx` owns
`PlatformTableShell`, including the canonical table card, header row, and body
divide styling. It also owns the final responsive table-class composition, so
divide styling, plus the single-line 32-pixel summary-row rhythm inherited by
Proxmox, Docker / Podman, Kubernetes, TrueNAS, vSphere, Standalone, Workloads,
and direct Storage consumers. Inline detail rows remain content-sized.
Secondary context must move to an existing column, a supplemental tooltip, or
the inline detail drawer instead of stacking a second visual line. The shell
also owns the final responsive table-class composition, so
feature tables may supply breakpoint floors but cannot accidentally override
the shared phone-width floor with `min-w-full`. Platform table frames now have
no local-frame exceptions in `shared-template-registry.json`: new and existing
@@ -90,7 +90,6 @@ it: that connection is invisible to the mapping rather than absent from the
source, and resolution stays inconclusive for the PBS instance until every
candidate connection has been observed.
Physical-disk to node association is evidence-directional. A disk reported by a
host agent carries no Proxmox scope, so its instance is empty and the matching
node name is the only evidence available; that match must stand. A disk that
@@ -197,11 +196,11 @@ a recovery-provider read helper or compatibility alias.
2. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx` shared with `unified-resources`: Proxmox workload coverage rows are both a storage/recovery protection-posture surface and a unified-resource identity consumer boundary.
3. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx` shared with `unified-resources`: Proxmox recoverable workload table rows are both a storage/recovery coverage surface and a unified-resource platform-table consumer boundary.
4. `internal/api/configapi/setup_script_render.go` shared with `agent-lifecycle`, `api-contracts`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection).
The user-chosen connection name carried by assisted setup travels on the
server-side setup token, not through the rendered script or its
registration payload, so honoring it changes neither the backup
visibility grants the script applies nor the storage-side identity that
backup and recovery attribution key on (hostname and candidate hosts).
The user-chosen connection name carried by assisted setup travels on the
server-side setup token, not through the rendered script or its
registration payload, so honoring it changes neither the backup
visibility grants the script applies nor the storage-side identity that
backup and recovery attribution key on (hostname and candidate hosts).
5. `internal/proxmoxidentity/backup_identity.go` shared with `alerts`, `monitoring`: Proxmox PBS backup subject identity is a shared runtime boundary for monitoring backup freshness, backup-age alert attribution, and recovery-point guest mapping.
Storage resource projections may show alert indicators from the canonical
@@ -465,6 +464,14 @@ Proxmox backup source/state chips are also a frontend-primitives boundary:
badge tones, `proxmoxBackupsTableShared.tsx` owns the backup table helper
composition, and visible source/state chips must render through `MetadataBadge`
instead of restoring local rounded-sm xs badge spans.
Proxmox backup-health and recoverable-artifact summary rows follow the same
platform-table density contract as every other provider surface. Storage and
recovery owns which backup, datastore, workload-type, and VMID facts matter to
an operator, while frontend-primitives owns the canonical 32px single-line
summary row. Compact layouts must not stack those secondary facts beneath the
resource name and make the row taller: keep essential identity inline when no
detail disclosure exists, and otherwise preserve supporting context in its
existing column, the row's accessible title, or the expanded detail content.
The Proxmox backup view selector follows the same ownership split:
storage/recovery owns the chronological-versus-coverage view values and backup
semantics, while the visible segmented selector shell must compose the
@@ -863,7 +870,7 @@ outside the storage/recovery contract and must not become restore material,
recovery scope, or a storage/recovery-owned secret source.
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes
2. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes
Commercial migration startup behavior in
`internal/api/licensing_handlers.go` and `internal/api/licensing_bridge.go`
remains adjacent cloud-paid/API state. Synthetic mock-license suppression
@@ -1093,7 +1100,7 @@ recovery scope, or a storage/recovery-owned secret source.
both directions: they may show their existing gated-capability affordances
when the policy exposes them, and they must not add edition- or
license-sniffing of their own to re-suppress or re-expose paid prompts.
5. Route canonical storage/recovery resource selection through `frontend-modern/src/hooks/useUnifiedResources.ts` and the owning `unified-resources` contract
3. Route canonical storage/recovery resource selection through `frontend-modern/src/hooks/useUnifiedResources.ts` and the owning `unified-resources` contract
That shared hook now also projects resource `clusterId` through the shared cluster-name helper, so storage and recovery links keep the same cluster-context label as other unified-resource consumers instead of rebuilding a local fallback chain.
That shared hook plus the adjacent websocket/store adapter path must keep
realtime transport merges canonical for storage/recovery consumers too:
@@ -1142,7 +1149,7 @@ recovery scope, or a storage/recovery-owned secret source.
are visible must now pass an explicit enabled gate into that shared hook and
any adjacent recovery-rollup query, so hidden workload-route selectors do
not hydrate storage/recovery transport on the protected hot path.
6. Preserve API-owned node identity continuity in shared `internal/api/` helpers so storage and recovery transport attachments do not fork by hostname-versus-IP drift across the same runtime.
4. Preserve API-owned node identity continuity in shared `internal/api/` helpers so storage and recovery transport attachments do not fork by hostname-versus-IP drift across the same runtime.
That same adjacent `internal/api/` boundary also owns canonical
host-alias propagation on grouped-system and attached-connection payloads.
`internal/api/connections_types.go`,
@@ -1201,7 +1208,7 @@ recovery scope, or a storage/recovery-owned secret source.
they must not treat that response as a source for owner identity because the
stable hosted owner principal is server-side org metadata resolved later
through magic-link verification.
7. Preserve fail-closed API assignment and lookup behavior in shared `internal/api/` helpers so storage and recovery surfaces do not inherit orphaned profile or resource references from unrelated transport mutations.
5. Preserve fail-closed API assignment and lookup behavior in shared `internal/api/` helpers so storage and recovery surfaces do not inherit orphaned profile or resource references from unrelated transport mutations.
Preserve fail-closed proxy-auth administrator evaluation in those same
shared helpers as an adjacent security/API boundary: storage and recovery
surfaces may consume an already-authorized admin request, but they must not
@@ -1213,12 +1220,12 @@ recovery scope, or a storage/recovery-owned secret source.
visibility, restore authority, protected-system ownership, and
recovery-local audit attribution must continue to use backend-authenticated
stable principals and storage/recovery resource identities.
8. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows.
9. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads.
10. Preserve canonical /api/auto-register token-action truth in shared `internal/api/` helpers so adjacent setup and recovery-adjacent transport flows stay on caller-supplied credential completion instead of reviving deleted alternate completion modes.
11. Preserve the canonical setup-script `source="script"` marker through those same shared auto-register helpers, and reject non-canonical source labels there, so later canonical reruns can keep treating script-confirmed tokens differently from agent-created tokens without reviving arbitrary caller-label compatibility.
12. Preserve the canonical auto-register node-type boundary in those same shared helpers so only supported `pve` and `pbs` registrations can complete, and unsupported runtime labels cannot bleed fake node identities into adjacent transport or recovery-adjacent state.
13. Preserve the canonical auto-register token-identity boundary in those same shared helpers so only Pulse-managed `pulse-monitor@{pve|pbs}!pulse-<canonical-scope-slug>` token IDs matching the requested node type can complete, and arbitrary, cross-type, or non-Pulse-managed token identities cannot bleed into adjacent transport or recovery-adjacent state.
6. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows.
7. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads.
8. Preserve canonical /api/auto-register token-action truth in shared `internal/api/` helpers so adjacent setup and recovery-adjacent transport flows stay on caller-supplied credential completion instead of reviving deleted alternate completion modes.
9. Preserve the canonical setup-script `source="script"` marker through those same shared auto-register helpers, and reject non-canonical source labels there, so later canonical reruns can keep treating script-confirmed tokens differently from agent-created tokens without reviving arbitrary caller-label compatibility.
10. Preserve the canonical auto-register node-type boundary in those same shared helpers so only supported `pve` and `pbs` registrations can complete, and unsupported runtime labels cannot bleed fake node identities into adjacent transport or recovery-adjacent state.
11. Preserve the canonical auto-register token-identity boundary in those same shared helpers so only Pulse-managed `pulse-monitor@{pve|pbs}!pulse-<canonical-scope-slug>` token IDs matching the requested node type can complete, and arbitrary, cross-type, or non-Pulse-managed token identities cannot bleed into adjacent transport or recovery-adjacent state.
Preserve canonical auto-register event intent in those same shared helpers:
only first-time node creation may emit the toast-bearing
`node_auto_registered` WebSocket event, while idempotent existing-node
@@ -1238,7 +1245,7 @@ recovery scope, or a storage/recovery-owned secret source.
same discovery-refresh plus node-deleted websocket side effects as a manual
delete so adjacent recovery/storage surfaces do not retain stale provider
context after Pulse credentials have been removed from the host.
14. Preserve canonical /api/auto-register DHCP continuity in those shared helpers so a PVE or PBS node that reruns registration from a new IP with the same canonical node name and deterministic Pulse-managed token identity updates in place instead of duplicating the inventory record.
12. Preserve canonical /api/auto-register DHCP continuity in those shared helpers so a PVE or PBS node that reruns registration from a new IP with the same canonical node name and deterministic Pulse-managed token identity updates in place instead of duplicating the inventory record.
Known contradictory TLS fingerprints must veto inferred resolved-address,
changed-address name/token, and cluster-member continuity matches so
separate protected sources are not overwritten when sites reuse node
@@ -1260,18 +1267,18 @@ recovery scope, or a storage/recovery-owned secret source.
transport flows must not reintroduce local marker trust or token rotation
when the canonical auto-register helper can verify whether Pulse still has
a matching node.
15. Preserve the governed root-or-sudo Unix wrapper in shared backend install-command helpers so storage- and recovery-adjacent transport surfaces do not inherit a stale raw `| bash -s --` install payload shape from the canonical agent-install-command API and hosted Proxmox install responses.
16. Preserve optional-auth tokenless behavior in those same shared backend install-command helpers so adjacent transport surfaces do not implicitly persist API tokens and flip auth-configured state when an operator only requested a Proxmox install command on a token-optional Pulse instance.
17. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so storage- and recovery-adjacent transport surfaces do not inherit browser-authored wildcard token bundles when they depend on the canonical security helper layer.
18. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so storage- and recovery-adjacent transport surfaces do not treat the mobile relay credential as a general AI scope bundle.
13. Preserve the governed root-or-sudo Unix wrapper in shared backend install-command helpers so storage- and recovery-adjacent transport surfaces do not inherit a stale raw `| bash -s --` install payload shape from the canonical agent-install-command API and hosted Proxmox install responses.
14. Preserve optional-auth tokenless behavior in those same shared backend install-command helpers so adjacent transport surfaces do not implicitly persist API tokens and flip auth-configured state when an operator only requested a Proxmox install command on a token-optional Pulse instance.
15. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so storage- and recovery-adjacent transport surfaces do not inherit browser-authored wildcard token bundles when they depend on the canonical security helper layer.
16. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so storage- and recovery-adjacent transport surfaces do not treat the mobile relay credential as a general AI scope bundle.
That same shared `internal/api/` machine boundary also owns hosted
entitlement refresh targeting: when storage- or recovery-adjacent hosted
routes execute under a tenant org without org-local billing state, the
refresh path must repair the instance-level `default` lease and evaluator
instead of rewriting the empty tenant org, so AI-guided recovery and
hosted diagnostics do not collapse into false free-tier behavior.
19. Preserve shipped local security-doc guidance in shared `internal/api/` config/setup helpers so storage- and recovery-adjacent transport surfaces do not reintroduce GitHub `main` security links when the running build already serves its own local security documentation route.
20. Keep shared `internal/api/` Patrol transport and alert-trigger edits feature-isolated: Patrol-specific recency fields, callback fan-out, or alert-bridge wiring changes must not leak into recovery queries, storage links, or recovery-adjacent install/setup flows unless this contract changes in the same slice.
17. Preserve shipped local security-doc guidance in shared `internal/api/` config/setup helpers so storage- and recovery-adjacent transport surfaces do not reintroduce GitHub `main` security links when the running build already serves its own local security documentation route.
18. Keep shared `internal/api/` Patrol transport and alert-trigger edits feature-isolated: Patrol-specific recency fields, callback fan-out, or alert-bridge wiring changes must not leak into recovery queries, storage links, or recovery-adjacent install/setup flows unless this contract changes in the same slice.
Retained Patrol objective routes follow the same adjacency rule. A brief,
canonical resource scope, observer lifecycle, or derived coverage state is
AI/runtime plus API-contract context; storage and recovery consumers must not
@@ -1477,47 +1484,47 @@ recovery scope, or a storage/recovery-owned secret source.
normalize the message, but they must not reinterpret it as a storage install
prerequisite, provider-connection error, or
recovery-capability verdict.
21. Keep provider-backed recovery onboarding on the adjacent platform-connections contract. When `internal/api/` grows or changes TrueNAS connection CRUD, masked-secret preservation, saved-connection retest routes, edit-form saved-test payload overlays, or similar provider setup flows, storage and recovery may consume the resulting recovery points but must not absorb that connection-management ownership into storage/recovery-local handlers or page flows. That same adjacency also covers per-surface scope as it flows through the unified connections aggregator: when `internal/api/connections_aggregator.go` projects TrueNAS `MonitorDatasets`/`MonitorPools`/`MonitorReplication` flags into the aggregator's `scope` map, storage and recovery may observe that projection to explain dataset/pool/replication coverage to operators but must not reinterpret those flags as a recovery freshness verdict, restore-capability gate, or storage-local scope registry.
That same adjacent platform-connections boundary now also owns
source-oriented `systems[]` grouping on `/api/connections`. Storage and
recovery may observe grouped source composition to explain whether a
platform row is collecting additional host telemetry through Pulse Agent,
but they must not reinterpret attached agents as separate protected
systems, duplicate recovery inventory rows, or a storage-local ownership
model. The same shared `/api/connections` contract also owns compact
`agentIdentity` facts for agent-backed rows; storage and recovery may read
that metadata, including host-profile ids such as `unraid`, when they need
to label a represented host, but they must not rebuild OS/endpoint identity
from recovery inventory or alias heuristics, or reinterpret an agent
host-profile id as a storage provider platform.
When that grouped platform row is a Proxmox cluster, storage and
recovery must also treat the backend-authored cluster moniker as the
canonical row identity instead of re-expanding cluster-member agents into
sibling host rows or per-node storage owners. If the grouped row carries
backend-authored cluster member nodes, adjacent storage/recovery surfaces
may use that composition for explanatory UI only; they must not promote
those child nodes into a second top-level grouped-system taxonomy or infer
per-node storage ownership from the settings payload. The same shared
`/api/connections` payload also owns any agent-version/update facts and
fleet-governance posture carried alongside those grouped rows; adjacent
storage or recovery surfaces may reuse that signal for operator context,
but must not fork their own version-comparison semantics, desired/applied
config-drift classifier, rollout-state classifier, credential-health
classifier, command-policy vocabulary, or another agent lifecycle
vocabulary. If `/api/connections` reports agent config drift as pending or
unknown because no trustworthy applied fingerprint exists, storage and
recovery must preserve that uncertainty instead of translating it into a
storage-local current/drifted verdict. If `/api/connections` reports
`configDrift: not-applicable` with a current applied rollout because no
managed host-agent config override is assigned, storage and recovery must
preserve that no-rollout state instead of translating it into a pending
storage/recovery problem.
When `/api/connections` attaches an exact-match host agent to a blocked
Proxmox API source without fresh node inventory, storage and recovery must
treat that as one represented source with host telemetry, not as a second
protected system or a storage-local duplicate host.
22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape.
23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape.
19. Keep provider-backed recovery onboarding on the adjacent platform-connections contract. When `internal/api/` grows or changes TrueNAS connection CRUD, masked-secret preservation, saved-connection retest routes, edit-form saved-test payload overlays, or similar provider setup flows, storage and recovery may consume the resulting recovery points but must not absorb that connection-management ownership into storage/recovery-local handlers or page flows. That same adjacency also covers per-surface scope as it flows through the unified connections aggregator: when `internal/api/connections_aggregator.go` projects TrueNAS `MonitorDatasets`/`MonitorPools`/`MonitorReplication` flags into the aggregator's `scope` map, storage and recovery may observe that projection to explain dataset/pool/replication coverage to operators but must not reinterpret those flags as a recovery freshness verdict, restore-capability gate, or storage-local scope registry.
That same adjacent platform-connections boundary now also owns
source-oriented `systems[]` grouping on `/api/connections`. Storage and
recovery may observe grouped source composition to explain whether a
platform row is collecting additional host telemetry through Pulse Agent,
but they must not reinterpret attached agents as separate protected
systems, duplicate recovery inventory rows, or a storage-local ownership
model. The same shared `/api/connections` contract also owns compact
`agentIdentity` facts for agent-backed rows; storage and recovery may read
that metadata, including host-profile ids such as `unraid`, when they need
to label a represented host, but they must not rebuild OS/endpoint identity
from recovery inventory or alias heuristics, or reinterpret an agent
host-profile id as a storage provider platform.
When that grouped platform row is a Proxmox cluster, storage and
recovery must also treat the backend-authored cluster moniker as the
canonical row identity instead of re-expanding cluster-member agents into
sibling host rows or per-node storage owners. If the grouped row carries
backend-authored cluster member nodes, adjacent storage/recovery surfaces
may use that composition for explanatory UI only; they must not promote
those child nodes into a second top-level grouped-system taxonomy or infer
per-node storage ownership from the settings payload. The same shared
`/api/connections` payload also owns any agent-version/update facts and
fleet-governance posture carried alongside those grouped rows; adjacent
storage or recovery surfaces may reuse that signal for operator context,
but must not fork their own version-comparison semantics, desired/applied
config-drift classifier, rollout-state classifier, credential-health
classifier, command-policy vocabulary, or another agent lifecycle
vocabulary. If `/api/connections` reports agent config drift as pending or
unknown because no trustworthy applied fingerprint exists, storage and
recovery must preserve that uncertainty instead of translating it into a
storage-local current/drifted verdict. If `/api/connections` reports
`configDrift: not-applicable` with a current applied rollout because no
managed host-agent config override is assigned, storage and recovery must
preserve that no-rollout state instead of translating it into a pending
storage/recovery problem.
When `/api/connections` attaches an exact-match host agent to a blocked
Proxmox API source without fresh node inventory, storage and recovery must
treat that as one represented source with host telemetry, not as a second
protected system or a storage-local duplicate host.
20. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape.
21. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape.
The same adjacent-boundary rule applies to `GET /api/agents/diagnostics`:
storage and recovery may read Agent Fleet Doctor evidence as operational
context for stale agents, version drift, profile drift, or identity split
@@ -1530,9 +1537,9 @@ recovery scope, or a storage/recovery-owned secret source.
Agent Doctor repair handoff may open an existing lifecycle flow or copy a
local installer command, but it must never be reinterpreted as a restore,
recovery, SMART, or cleanup action.
24. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload.
25. Keep provider-backed poll cadence and settings-runtime health on the adjacent platform-connections contract. When shared `internal/api/` and poller wiring expose TrueNAS last-sync status, failure summaries, discovered contribution counts, manual saved-test status refresh, or platform handoff links in settings, storage and recovery may consume the resulting datasets, apps, disks, and recovery artifacts but must not redefine those settings-runtime health semantics or connection-level handoffs in storage/recovery-local transport or page flows.
26. Keep recovery filter/query state on the shared route-state parsing contract without restoring standalone recovery navigation. When platform pages or other embedded owners expose TrueNAS recovery context, they may reuse the canonical recovery query vocabulary with owned `platform` and `node` fields, but they must land inside an owning platform/runtime route instead of inventing drawer-local recovery URLs, treating PBS services as the only recovery path, or sending operators to the retired Recovery aggregate route.
22. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload.
23. Keep provider-backed poll cadence and settings-runtime health on the adjacent platform-connections contract. When shared `internal/api/` and poller wiring expose TrueNAS last-sync status, failure summaries, discovered contribution counts, manual saved-test status refresh, or platform handoff links in settings, storage and recovery may consume the resulting datasets, apps, disks, and recovery artifacts but must not redefine those settings-runtime health semantics or connection-level handoffs in storage/recovery-local transport or page flows.
24. Keep recovery filter/query state on the shared route-state parsing contract without restoring standalone recovery navigation. When platform pages or other embedded owners expose TrueNAS recovery context, they may reuse the canonical recovery query vocabulary with owned `platform` and `node` fields, but they must land inside an owning platform/runtime route instead of inventing drawer-local recovery URLs, treating PBS services as the only recovery path, or sending operators to the retired Recovery aggregate route.
That same shared route-helper boundary also owns exact workload handoffs
when storage or recovery surfaces send operators back to node-scoped
workloads for investigation context. Proxmox VM and system-container links
@@ -1544,8 +1551,8 @@ recovery scope, or a storage/recovery-owned secret source.
Patrol control anchor, must stay owned by their product surface and
must not be reused as recovery entry points or storage/recovery navigation
aliases.
27. Keep alert-side recovery drill-ins on that same embedded-owner route-state contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through an owning platform/runtime destination using canonical recovery query vocabulary instead of freezing alert-local recovery URLs, reviving the retired Recovery aggregate route, or introducing another provider-shaped recovery handoff vocabulary.
28. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, poller-owned `poll` / `observed` summary payloads, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport.
25. Keep alert-side recovery drill-ins on that same embedded-owner route-state contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through an owning platform/runtime destination using canonical recovery query vocabulary instead of freezing alert-local recovery URLs, reviving the retired Recovery aggregate route, or introducing another provider-shaped recovery handoff vocabulary.
26. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, poller-owned `poll` / `observed` summary payloads, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport.
A successful VMware connection test with `degraded` optional signal or
performance diagnostics remains connection-readiness context only; storage
and recovery must not reinterpret that warning as datastore protection
@@ -1556,7 +1563,7 @@ recovery scope, or a storage/recovery-owned secret source.
route remains deployment-installability and API-contract owned; storage
and recovery must not treat release-note availability or publication time
as protection freshness, recovery evidence, or restore state.
29. Keep VMware datastore projection on the shared unified-resource and storage-source contracts. When `frontend-modern/src/hooks/useUnifiedResources.ts` or shared `internal/api/router.go` wiring starts surfacing VMware-backed canonical `storage` resources, storage and recovery may expose those datastores through the owned `vmware-vsphere` source/platform vocabulary for inventory, capacity, and handoff flows only; they must not reinterpret that projection as VMware recovery support, restore semantics, or a provider-local protection surface.
27. Keep VMware datastore projection on the shared unified-resource and storage-source contracts. When `frontend-modern/src/hooks/useUnifiedResources.ts` or shared `internal/api/router.go` wiring starts surfacing VMware-backed canonical `storage` resources, storage and recovery may expose those datastores through the owned `vmware-vsphere` source/platform vocabulary for inventory, capacity, and handoff flows only; they must not reinterpret that projection as VMware recovery support, restore semantics, or a provider-local protection surface.
The same shared unified-resource boundary also covers canonical
Resource.Uptime fallback on the consumer side. When
`frontend-modern/src/hooks/useUnifiedResources.ts`'s `toResource`
@@ -1574,7 +1581,7 @@ recovery scope, or a storage/recovery-owned secret source.
health-check targets as protection topology, or container block I/O totals
as backup throughput, recovery-point evidence, protection cadence, or
storage-health ownership.
30. Keep VMware placement, cluster service state, guest-detail, VM snapshot-tree, VM virtual-hardware configuration, VMware Tools, VM hardware Ethernet, VM hardware disk, and network enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, `vmware.clusterHaEnabled`, `vmware.clusterDrsEnabled`, folder, runtime-host, datastore-attachment, guest-hostname, guest-IP, `vmware.currentSnapshotId`, `vmware.snapshotTree`, snapshot creation/state/quiesce/current markers, child snapshot metadata, `vmware.hardware`, virtual hardware version, hardware upgrade policy/version/status/error, boot type/order/retry/setup-mode flags, CPU cores-per-socket and hot-add/remove flags, memory hot-add settings, `vmware.tools`, Tools run state, version status, version number/string, install type, upgrade policy, auto-update support, install-attempt count, guest reboot requests, `vmware.networkAdapters`, adapter MAC address/type, backing network id/name, backing type, connection state, start-connected / guest-control flags, `vmware.virtualDisks`, virtual disk label/type, IDE/SCSI/SATA/NVMe placement, VMDK path, backing type, datastore name, capacity, `vmware.networkType`, `vmware.networkHostNames`, `vmware.networkVmNames`, or `vmware.tags` onto canonical VMware `agent` / `vm` / `storage` / `network` resources, storage and recovery may use that detail for labeling, navigation, and VM investigation context only; they must not promote those topology, cluster-service, guest, snapshot-tree, virtual-hardware, VMware Tools, vNIC, virtual disk, tag, or network fields into recovery ownership, restore targeting, protection grouping, compliance scoring, or a VMware-local recovery taxonomy without a separately governed slice.
28. Keep VMware placement, cluster service state, guest-detail, VM snapshot-tree, VM virtual-hardware configuration, VMware Tools, VM hardware Ethernet, VM hardware disk, and network enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, `vmware.clusterHaEnabled`, `vmware.clusterDrsEnabled`, folder, runtime-host, datastore-attachment, guest-hostname, guest-IP, `vmware.currentSnapshotId`, `vmware.snapshotTree`, snapshot creation/state/quiesce/current markers, child snapshot metadata, `vmware.hardware`, virtual hardware version, hardware upgrade policy/version/status/error, boot type/order/retry/setup-mode flags, CPU cores-per-socket and hot-add/remove flags, memory hot-add settings, `vmware.tools`, Tools run state, version status, version number/string, install type, upgrade policy, auto-update support, install-attempt count, guest reboot requests, `vmware.networkAdapters`, adapter MAC address/type, backing network id/name, backing type, connection state, start-connected / guest-control flags, `vmware.virtualDisks`, virtual disk label/type, IDE/SCSI/SATA/NVMe placement, VMDK path, backing type, datastore name, capacity, `vmware.networkType`, `vmware.networkHostNames`, `vmware.networkVmNames`, or `vmware.tags` onto canonical VMware `agent` / `vm` / `storage` / `network` resources, storage and recovery may use that detail for labeling, navigation, and VM investigation context only; they must not promote those topology, cluster-service, guest, snapshot-tree, virtual-hardware, VMware Tools, vNIC, virtual disk, tag, or network fields into recovery ownership, restore targeting, protection grouping, compliance scoring, or a VMware-local recovery taxonomy without a separately governed slice.
`vmware.tags` is the sharpest case of that boundary, because vCenter tag
vocabularies routinely read like backup policy — an operator-authored
`Backup:Nightly` or `Compliance:PCI` is a label the operator wrote, not a
@@ -1583,7 +1590,7 @@ recovery scope, or a storage/recovery-owned secret source.
for an observed backup, satisfy a protection requirement, or move a
resource's coverage or compliance verdict; those verdicts continue to come
from recovery-owned evidence alone.
31. Keep VMware datastore classification neutral on the shared storage adapter contract. When `frontend-modern/src/features/storageBackups/resourceStorageMapping.ts`, `frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts`, and `frontend-modern/src/features/storageBackups/storageAdapters.ts` evolve canonical storage-record mapping, VMware-backed datastores must continue to land on the shared storage route as inventory-only datastores with neutral protection fallback, not as backup repositories, backup targets, or recovery-protected resources.
29. Keep VMware datastore classification neutral on the shared storage adapter contract. When `frontend-modern/src/features/storageBackups/resourceStorageMapping.ts`, `frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts`, and `frontend-modern/src/features/storageBackups/storageAdapters.ts` evolve canonical storage-record mapping, VMware-backed datastores must continue to land on the shared storage route as inventory-only datastores with neutral protection fallback, not as backup repositories, backup targets, or recovery-protected resources.
That same shared storage adapter boundary also owns canonical platform
family vocabulary through the governed platform manifest.
`frontend-modern/src/features/storageBackups/models.ts`,
@@ -1601,7 +1608,7 @@ recovery scope, or a storage/recovery-owned secret source.
recovery platform. Shared `AgentData.hostProfile` may carry that profile id
for presentation, while storage and recovery must continue to treat
`AgentData.platform` as the normalized runtime platform.
32. Keep agentless availability endpoints neutral on the shared unified-resource and API contracts. When `internal/api/availability_handlers.go`, `internal/api/connections_handlers.go`, `internal/api/platform_mock_connections.go`, or `frontend-modern/src/hooks/useUnifiedResources.ts` surface `network-endpoint` availability resources, storage and recovery may consume their liveness as infrastructure context only; they must not reinterpret ping/TCP/HTTP endpoints as storage providers, backup targets, recovery repositories, or protected-workload evidence.
30. Keep agentless availability endpoints neutral on the shared unified-resource and API contracts. When `internal/api/availability_handlers.go`, `internal/api/connections_handlers.go`, `internal/api/platform_mock_connections.go`, or `frontend-modern/src/hooks/useUnifiedResources.ts` surface `network-endpoint` availability resources, storage and recovery may consume their liveness as infrastructure context only; they must not reinterpret ping/TCP/HTTP endpoints as storage providers, backup targets, recovery repositories, or protected-workload evidence.
The same neutrality applies when one or more canonical
`availabilityChecks` facets attach to a storage-related resource. The
additive plural facet, its compatibility `availability` summary, evidence
@@ -1618,13 +1625,13 @@ recovery scope, or a storage/recovery-owned secret source.
`certificate_*` incident on those availability facets remain monitoring and
alerts context. They do not prove storage encryption, backup integrity,
repository authenticity, protection coverage, or restore readiness.
33. Keep infrastructure summary chart bucketing and short response caching presentation-only on the adjacent shared API boundary. When `internal/api/chartapi/service.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets or serves a cached summary payload for repeated operator-facing summary-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
31. Keep infrastructure summary chart bucketing and short response caching presentation-only on the adjacent shared API boundary. When `internal/api/chartapi/service.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets or serves a cached summary payload for repeated operator-facing summary-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
The same router may wire the adjacent server-owned agent command
authorization verifier, but storage/recovery must not treat an approval id,
signed command grant, or command result as restore authorization or
recovery evidence; that authority remains action-governance and
agent-lifecycle owned.
34. Keep workload chart downsampling and short response caching presentation-only on that same adjacent shared API boundary. When `internal/api/chartapi/service.go` caps mixed-cadence workload history into equal-time buckets or serves a cached workload-summary payload for repeated operator-facing workload-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
32. Keep workload chart downsampling and short response caching presentation-only on that same adjacent shared API boundary. When `internal/api/chartapi/service.go` caps mixed-cadence workload history into equal-time buckets or serves a cached workload-summary payload for repeated operator-facing workload-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
The same adjacent chart boundary now covers compact storage capacity
transport. `internal/api/chartapi/service.go` may batch only the canonical
`used` and `avail` storage series for `/api/charts/storage-summary`, but
@@ -1639,7 +1646,7 @@ recovery scope, or a storage/recovery-owned secret source.
In mock mode, that same compact route must stay aggregate-only and
sampler-prewarmed; storage and recovery must not trigger per-pool chart
reconstruction on the first dashboard request after each mock refresh.
36. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by
33. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by
surface. Storage/recovery preview entrypoints such as
`/preview/setup-complete` may remain public app-shell routes, but unrelated
commercial compatibility handoffs like `/pricing` must stay separate thin
@@ -1694,7 +1701,7 @@ recovery scope, or a storage/recovery-owned secret source.
owners must treat that as shell-owned navigation evidence, not as a reason
to restore Storage or Recovery as equal primary tabs or depend on
platform-only tab terminology.
37. Keep public self-hosted purchase handoff and activation routes on the
34. Keep public self-hosted purchase handoff and activation routes on the
adjacent commercial/auth boundary. When `internal/api/router.go`,
`internal/api/router_routes_cloud.go`, `internal/api/licensing_handlers.go`,
or `internal/api/demo_mode_commercial.go` evolve
@@ -1747,14 +1754,14 @@ recovery scope, or a storage/recovery-owned secret source.
runtime as populated mock inventory, but they must not expose
`demo_fixtures`, billing identity, or alternate entitlement semantics as
recovery-local transport or operator-facing storage metadata.
39. Keep storage and recovery route framing additive and owner-neutral.
35. Keep storage and recovery route framing additive and owner-neutral.
`frontend-modern/src/components/Storage/Storage.tsx` and storage/recovery-
adjacent route composition may use the shared `PageHeader` shell for
top-level route framing, but that header must stay additive on top of the
canonical storage page model, recovery presenters, and shared summary
caches. Header chrome must not become a second owner for storage filters,
recovery posture, commercial purchase state, or transport selection.
40. Keep the unified connections ledger owner-neutral toward storage and
36. Keep the unified connections ledger owner-neutral toward storage and
recovery. Shared `internal/api/router.go` may mount the
`/api/connections` and `/api/connections/probe` routes alongside the
existing storage/recovery-adjacent API surfaces, and
@@ -1807,7 +1814,7 @@ recovery scope, or a storage/recovery-owned secret source.
Storage filter option semantics stay storage-owned, but FilterBar chip
presentation is frontend-primitives-owned: storage status leading dots must
use `filterChipStatusDot` rather than storage-local span factories.
41. Keep agent memory composition descriptive on the shared unified-resource
37. Keep agent memory composition descriptive on the shared unified-resource
contract. `internal/unifiedresources/types.go` carries the reclaimable
page-cache split (`AgentMemoryMeta.cache`, holding used + cache + free
within the reported total) as host RAM description for machine surfaces.
@@ -1984,24 +1991,24 @@ files.
appliance, or recovery endpoint was read, mutated, or verified.
The operations-loop status projection is also adjacent-only. Its content-free
stage, next-action, Patrol evidence, contextual collaboration, pending
approval, governed action, verified outcome, and optional token-backed MCP
readiness fields may describe Pulse Intelligence activation progress, but storage and
recovery must not treat them as backup coverage, restore readiness, storage
health verification, appliance access, dataset access, API-token authority
for recovery paths, or recovery mutation proof.
If an aggregate active Patrol finding or pending approval outranks older
completed/resolved loop proof in that projection, that precedence remains only
current operator orientation; it is not backup freshness, restore authority, or
storage-local remediation proof.
Operations-loop workflow starter request counts are even narrower: they are
content-free markers that a native Assistant surface rendered, a first-party
Patrol control handoff started, a legacy Pro activation entry-point handoff
started, or a Pulse MCP surface rendered the manifest-owned
`pulse_operations_loop` starter. The aggregate Patrol control starter count may
include native Patrol, legacy Patrol autonomy, and legacy Pro activation starts,
while `proActivationOperationsLoopStarterCount` remains the legacy entry-point
count. Storage and recovery may observe those aggregate activation reports, but
must not treat starter
approval, governed action, verified outcome, and optional token-backed MCP
readiness fields may describe Pulse Intelligence activation progress, but storage and
recovery must not treat them as backup coverage, restore readiness, storage
health verification, appliance access, dataset access, API-token authority
for recovery paths, or recovery mutation proof.
If an aggregate active Patrol finding or pending approval outranks older
completed/resolved loop proof in that projection, that precedence remains only
current operator orientation; it is not backup freshness, restore authority, or
storage-local remediation proof.
Operations-loop workflow starter request counts are even narrower: they are
content-free markers that a native Assistant surface rendered, a first-party
Patrol control handoff started, a legacy Pro activation entry-point handoff
started, or a Pulse MCP surface rendered the manifest-owned
`pulse_operations_loop` starter. The aggregate Patrol control starter count may
include native Patrol, legacy Patrol autonomy, and legacy Pro activation starts,
while `proActivationOperationsLoopStarterCount` remains the legacy entry-point
count. Storage and recovery may observe those aggregate activation reports, but
must not treat starter
access as backup coverage, recovery freshness, restore readiness, storage
health verification, dataset access, appliance access, or evidence that any
recovery endpoint was used.
@@ -2104,7 +2111,7 @@ must not treat starter
behavior by fanning out per-pool `/api/metrics-store/history` reads, by
pulling the full storage-page `/api/storage-charts` payload, or by
inventing a dashboard-only storage history transport.
15a. Keep shared diagnostics cache scope honest when storage/recovery-adjacent
15a. Keep shared diagnostics cache scope honest when storage/recovery-adjacent
surfaces reuse `internal/api/diagnostics.go`. The shared diagnostics
payload must not include local commercial funnel summaries or
infrastructure-onboarding analytics, so recovery-adjacent diagnostics do
@@ -2120,7 +2127,7 @@ must not treat starter
load or save AI settings through shared helpers, any historical hosted
quickstart model IDs must be cleared before adjacent surfaces read or
re-emit that state. Legacy Anthropic OAuth tokens follow the same shared-owner rule: adjacent storage/recovery code may not use them as provider configuration and must leave cleanup to the AI settings contract.
17a. Keep adjacent AI paid-control state entitlement-effective on that shared
17a. Keep adjacent AI paid-control state entitlement-effective on that shared
`internal/api/` boundary. Storage- and recovery-adjacent flows may preserve
stored Assistant or Patrol preferences in config, but they must not treat
stored autonomous, auto-remediation, or alert-triggered analysis settings
@@ -2260,8 +2267,8 @@ capability over storage or recovery data.
pool reads as its concrete layout ("Mirror", "Raidz2") without the
discriminator ever carrying presentation detail. Regression coverage:
`labels a TrueNAS pool by its vdev layout while keeping the pool
discriminator` and `falls back to the topology discriminator when no
vdev layout is reported` in
discriminator` and `falls back to the topology discriminator when no
vdev layout is reported` in
`frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts`.
27. A rejected legacy RBAC import must not destroy the store. The import is
@@ -5270,6 +5277,7 @@ The ZFS pool-detail summary has one stable shape: `datasets` is always an
array, including when the backend reports no dataset inventory. Consumers and
branch proofs must retain the empty array instead of treating dataset support
as an optional return-field contract.
### Scheduled reporting adoption is read from persisted config as counts
The usage telemetry snapshot reads `LoadReportScheduleStore` and reports three
@@ -5283,6 +5291,7 @@ reporting actually produced output, not whether it is currently armed. The read
is in `applyLicensedFeatureConfigSnapshot`
(`pkg/server/telemetry_licensed_features.go`), pinned by
`TestApplyLicensedFeatureConfigSnapshot_CountsScheduledReportingAndProfiles`.
### Audit-read activity is a bounded, content-free local history
`audit_read_activity.json` follows the same shape as the existing external-agent
@@ -5294,6 +5303,7 @@ the 30-day telemetry window are excluded at count time by
`CountAuditReadActivitySince`, so the derived signal stays "is using" rather
than drifting into "has ever used"
(`TestAuditReadsOutsideWindowAreNotCounted`).
### Per-tenant resource stores are released on offboarding and shutdown
`ResourceHandlers.getStore` opens a SQLite handle per org and caches it for the
@@ -5307,6 +5317,7 @@ a fresh handle rather than using a closed one
(`TestResourceHandlers_CloseTenantStoreReleasesTheHandle`), and both entry points
are idempotent and nil-safe
(`TestResourceHandlers_CloseIsIdempotentAndNilSafe`).
### Report-ack version echo persists nothing
The unified-agent report ack's `serverVersion` field is supplied from the
@@ -5315,6 +5326,7 @@ running server's version at router wiring time
ingest path's persistence surface — agent-id continuity and host state — is
unchanged by the echo
(`TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart`).
### Security status capability payload gained an infrastructure field
`internal/api/` is a canonical reference in this contract's Extension Points, so
@@ -380,7 +380,11 @@ consumers use `platform-table-narrow-hidden` to demote only their lowest-value
field while the shared shell promotes identity to 40 percent and retains five
readable fields. Provider tables must keep summary rows single-line and may
truncate a value only when the row exposes its complete source value through a
touch- and keyboard-operable inline detail disclosure.
touch- and keyboard-operable inline detail disclosure. The frontend-primitives
owner fixes that summary rhythm at 32 pixels for every `platform-table` path;
provider consumers must not add stacked subtitles or local row-height
exceptions. Lower-priority provider metadata belongs in another operational
column, a supplemental tooltip, or the inline detail drawer.
Future platform tables must keep that split: row data and platform semantics
stay in the unified-resource consumer, and the repeated table shell stays in the
shared frontend primitive.
+62 -36
View File
@@ -1,37 +1,64 @@
{
"version": 1,
"base_sha": "6991208ad62c550328109f50ea53b7b4202da62b",
"verified_at": "2026-08-24T14:36:00Z",
"base_sha": "7e363e40e7aa21bf4d5029108a217c0f93b963b5",
"verified_at": "2026-08-24T15:09:06Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/shared/DetailSectionTable.tsx",
"frontend-modern/src/components/shared/ObjectDrawerHeader.tsx",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx"
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "65b714d5e11f32aad5baf27dab6529a1759478e86be377ae4e208fd3730a0815",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "d13a9e66746541b7c28311d2e07ba961df4620c2178ab8fc5ebc1a079a858263",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx": "14926ce4b200435a7675028d5467aed89b5c9cb894e788822ff9fd70ae7f1f6f",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "b933c323e93f56022f57d8083e5e2b4d715e8de906d7d3d38c7802ef1680b0c4",
"frontend-modern/src/components/shared/DetailSectionTable.tsx": "9ea698e10d5ef197a5e0bea3cc5a97a23173116b093cb4377a0bcbd29119fb20",
"frontend-modern/src/components/shared/ObjectDrawerHeader.tsx": "39702f2a07f9f3c9b64a82820ce21f6e42ba1b88b33e7900ebb353fab09cce83",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx": "e05dcca8cd2c9a170bc6a405d869982a6e6607d760083f62b95520cc131358ab",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "6ad83f0faf4c02fb29f027104ff91cc6fbafd8fc0db8da1693f1546e00bdca9b",
"frontend-modern/src/features/docker/DockerHostsTable.tsx": "c6af64cb0a2fbdf2d1973463b1a7d992685e39bda8456a138ed1ff81e2c71718",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "4da690f8ac74e95d77f728f7be910e140e25119f796e0874b2bbd6193e4cf5fc",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx": "e0eba7b1fa5233379bb7f0eda3e49920976a5b9b82fa4749f8e150f186f3a687",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "a192b907ce195f9c72257d25f0cf1e015a070e30cea794872dbeda2f4d2b1826"
"frontend-modern/src/features/docker/DockerAlertsTable.tsx": "c9cee8ac612f616810964394ca2873c44bc36abe5313a61e3d26a74579c9f1d5",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "3840ec971c93e89fe6842d942493b4811cf7b6c76a9c66b28c3480be14ecf304",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "0a76cb318523582bb1342ff23161041642ffcf5259c2205d127df11ed68e7584",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "efa5326b0bd8562348107513c632c44aa828eb03942b056acf2f53b25da3301b",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "22cdce43826f6543f0bf295099a4362c3428d80a8781064b7a3249323204ad06",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "216a0e0576145864500f4b975d5699a3fe6abc4fa226664f7522a19205030fa2",
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "8703f76bb5decb5a80a17d6cb221443d91691fb513619bd39bfd771092e66edc",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "3973e4bbb836c796d558f298b6c5c6e30ee36c608923ffe030118f23c2c101b6",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "87a3793e3871abfa534bff3220a433cf7e6ae75a658fd93813e8cde37af2da3f",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx": "c14a06d7087127289391b8a8e5af62ea88e1f30aac23c57e0994bc149c0108d3",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx": "3994bd01b6cd723bfacabf5edcd1afad7b5f23be5c15f8558f6efe74b4664a3f",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx": "f14b8373ac11cd9f7039032554c4d541bd69a66b9969e7d26976264ec6e18ef9",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "f36e5afd440d4f440eb72f1594842121c4abc32dfcff90305a3a538453724b19",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx": "a24d04e5ca1f9c6a9a4cdee795866cdd9069d93fa657aa7e3b65a0a4414b6711",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx": "ce44e4f4686a23d44999f6b8e035b46d30a7651c7aa3d978d560a0ac834318e5",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "df0c19160f33f5c23129317d941dd003d26d52edc5fda503475f3a07631571ca",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "76de93dde01009102aedbab381833530199e31861f82a1469689639d5ada264d",
"frontend-modern/src/index.css": "ee23f0612d3a28e74d1bd260709616b3e54b9836451a618d8f55dfb21d7bcfd4"
},
"routes": ["/proxmox/overview", "/docker/overview"],
"routes": [
"/proxmox/overview",
"/proxmox/backups/date",
"/proxmox/ceph",
"/docker/overview",
"/kubernetes/overview",
"/truenas/overview",
"/truenas/storage",
"/truenas/apps",
"/truenas/vms",
"/truenas/shares",
"/truenas/protection",
"/vmware/health",
"/vmware/activity",
"/standalone/machines"
],
"viewports": [
{
"width": 1280,
@@ -43,16 +70,15 @@
}
],
"states": [
"Proxmox node drawer with the complete subject header row presented as one visible collapse target at desktop and phone widths",
"Proxmox guest drawer with the same canonical full-width collapse affordance at phone width",
"Proxmox Ceph cluster and Mail Gateway drawers with the same canonical full-width collapse affordance at phone width",
"Docker host drawer with the canonical full-width collapse affordance and condensed technical rows at phone width"
"single-line platform summary rows across Proxmox, Docker, Kubernetes, TrueNAS, vSphere, and standalone machine tables",
"Proxmox node, guest, backup-server, Ceph, TrueNAS alert, app, VM, share, protection, storage, vSphere alert/activity, and standalone machine identities without stacked subtitles",
"default and expanded Proxmox node states with the inline detail row remaining content-sized",
"desktop and narrow table layouts with provider-specific column priority preserved"
],
"interactions": [
"expanded and collapsed a Proxmox node by pressing the full subject header row at desktop and phone widths",
"expanded and collapsed a Proxmox guest by pressing the full subject header row at phone width",
"expanded and collapsed Ceph cluster and Mail Gateway objects by pressing their full subject header rows at phone width",
"expanded and collapsed a Docker host by pressing the full subject header row at phone width",
"confirmed the visible subject content and chevron communicate the full-row collapse affordance without adding a separate Close control"
"expanded a Proxmox node from its canonical 32px summary row and collapsed it from the full drawer header",
"navigated every changed provider route at narrow width and inspected row height, truncation, clipping, horizontal containment, and bottom-navigation clearance",
"inspected representative Proxmox, Docker, TrueNAS, vSphere, and standalone tables at desktop width",
"confirmed supplemental provider metadata remains available through inline details or cell titles without creating a second visual line"
]
}
@@ -3847,7 +3847,7 @@
{
"id": "platform-table-shell-local-frame",
"category": "platform-table-shell",
"summary": "Platform tables must compose the shared PlatformTableShell frame instead of recreating the TableCard/TableHeader/TableBody shell locally.",
"summary": "Platform tables must compose the shared PlatformTableShell frame and its canonical single-line 32px summary-row rhythm instead of recreating local table chrome or provider-specific density.",
"canonical": {
"path": "src/features/platformPage/sharedPlatformPage.tsx",
"export": "PlatformTableShell"
@@ -560,7 +560,12 @@ describe('App architecture', () => {
expect(appStylesSource).toContain('.pulse-wide-data-surface.space-y-3');
expect(appStylesSource).toContain('.filter-bar > div > div:first-child button');
expect(appStylesSource).not.toContain('.proxmox-nodes-card > :first-child');
expect(appStylesSource).toContain('.pulse-wide-data-surface .host-row');
expect(appStylesSource).toContain(
'.table-fixed.platform-table > tbody > tr:not([data-inline-detail-for])',
);
expect(appStylesSource).not.toContain(
'.pulse-wide-data-surface .host-row,\n .pulse-wide-data-surface .workload-row',
);
expect(appStylesSource).toContain('.pulse-footer > div');
expect(appLayoutSource).toContain('tabs mb-2 hidden xl:flex');
expect(appLayoutSource).toContain('xl:px-2 2xl:px-3');
@@ -5267,6 +5267,12 @@ describe('shared primitive guardrails', () => {
expect(registeredGuard?.canonical?.export).toBe('PlatformTableShell');
expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0);
expect(sharedPlatformPageSource).toContain('export function PlatformTableShell');
expect(sharedPlatformPageSource).toContain(
"export const PLATFORM_TABLE_SUMMARY_ROW_CLASS = 'h-8'",
);
expect(sharedPlatformPageSource).toContain(
'getPlatformTableRowClass = (): string => PLATFORM_TABLE_SUMMARY_ROW_CLASS',
);
expect(sharedPlatformPageSource).toContain('TableCard class={props.cardClass');
expect(sharedPlatformPageSource).toContain('TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}');
expect(sharedPlatformPageSource).toContain('TableBody class={PLATFORM_TABLE_BODY_CLASS}');
@@ -5295,6 +5301,40 @@ describe('shared primitive guardrails', () => {
}
});
it('keeps provider summary rows on the canonical single-line density contract', () => {
expect(frontendIndexCssSource).toContain(
'.table-fixed.platform-table > tbody > tr:not([data-inline-detail-for])',
);
expect(frontendIndexCssSource).toContain('height: 2rem;');
expect(frontendIndexCssSource).not.toContain(
'.pulse-wide-data-surface .host-row,\n .pulse-wide-data-surface .workload-row',
);
for (const source of [
dockerAlertsTableSource,
kubernetesAlertsTableSource,
proxmoxBackupServersTableSource,
proxmoxCephTableSource,
proxmoxNodesTableSource,
proxmoxRecoverableTableSource,
agentsMachinesTableSource,
truenasAlertsTableSource,
truenasAppsTableSource,
truenasNetworkSharesTableSource,
truenasProtectionTableSource,
truenasStorageTopologyTableSource,
truenasSystemsTableSource,
truenasVirtualMachinesTableSource,
vsphereActivityTableSource,
vsphereAlertsTableSource,
]) {
expect(source).not.toContain('block truncate text-[10px] text-muted');
expect(source).not.toContain('flex min-w-0 flex-1 flex-col');
}
expect(agentsMachinesTableSource).not.toContain('grid-rows-2');
});
it('keeps platform count grammar and route status normalization shared', () => {
expect(sharedPlatformPageSource).toContain('export const getPlatformResourceCountNoun');
expect(sharedPlatformPageSource).toContain(
@@ -231,16 +231,21 @@ export const DockerAlertsTable: Component<{
<div class="min-w-0">
<div
class="truncate font-medium text-base-content"
title={incident.resourceName}
title={[
incident.resourceName,
formatPlatformAlertResourceType(
incident.resourceType,
'docker',
),
incident.resource.parentName
? `on ${incident.resource.parentName}`
: '',
]
.filter(Boolean)
.join(' · ')}
>
{incident.resourceName}
</div>
<div class="truncate text-[10px] text-muted">
{formatPlatformAlertResourceType(incident.resourceType, 'docker')}
<Show when={incident.resource.parentName}>
on {incident.resource.parentName}
</Show>
</div>
</div>
</div>
</TableCell>
@@ -251,24 +256,26 @@ export const DockerAlertsTable: Component<{
/>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="block truncate text-base-content" title={incident.summary}>
<span
class="block truncate text-base-content"
title={[incident.summary, incident.label].filter(Boolean).join(' · ')}
>
{incident.summary}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.label}
</span>
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')}`}>
<span
class="block truncate text-base-content"
title={docker()?.hostname || docker()?.swarm?.clusterName}
title={[
docker()?.hostname || docker()?.swarm?.clusterName,
docker()?.runtime ||
formatPlatformAlertCode(incident.code, 'docker'),
]
.filter(Boolean)
.join(' · ')}
>
{docker()?.hostname || docker()?.swarm?.clusterName || '-'}
</span>
<span class="block truncate text-[10px] text-muted">
{docker()?.runtime ||
formatPlatformAlertCode(incident.code, 'docker')}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
@@ -241,19 +241,21 @@ export const KubernetesAlertsTable: Component<{
<div class="min-w-0">
<div
class="truncate font-medium text-base-content"
title={incident.resourceName}
title={[
incident.resourceName,
formatPlatformAlertResourceType(
incident.resourceType,
'kubernetes',
),
k()?.ownerKind && k()?.ownerName
? `${k()?.ownerKind}/${k()?.ownerName}`
: '',
]
.filter(Boolean)
.join(' · ')}
>
{incident.resourceName}
</div>
<div class="truncate text-[10px] text-muted">
{formatPlatformAlertResourceType(
incident.resourceType,
'kubernetes',
)}
<Show when={k()?.ownerKind && k()?.ownerName}>
· {k()?.ownerKind}/{k()?.ownerName}
</Show>
</div>
</div>
</div>
</TableCell>
@@ -264,21 +266,26 @@ export const KubernetesAlertsTable: Component<{
/>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="block truncate text-base-content" title={incident.summary}>
<span
class="block truncate text-base-content"
title={[incident.summary, incident.label].filter(Boolean).join(' · ')}
>
{incident.summary}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.label}
</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="block truncate text-base-content" title={scopeText()}>
<span
class="block truncate text-base-content"
title={[
scopeText(),
k()?.nodeName ||
formatPlatformAlertCode(incident.code, 'kubernetes'),
]
.filter(Boolean)
.join(' · ')}
>
{scopeText()}
</span>
<span class="block truncate text-[10px] text-muted">
{k()?.nodeName ||
formatPlatformAlertCode(incident.code, 'kubernetes')}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
@@ -243,6 +243,7 @@ export const PLATFORM_TABLE_CARD_CLASS = 'rounded-md';
export const PLATFORM_TABLE_HEADER_ROW_CLASS = 'bg-surface-alt text-muted border-b border-border';
export const PLATFORM_TABLE_BODY_CLASS = 'divide-y divide-border';
export const PLATFORM_TABLE_CLASS = 'platform-table';
export const PLATFORM_TABLE_SUMMARY_ROW_CLASS = 'h-8';
export const PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT = 30;
export const PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT = 40;
// Responsive platform tables already decide which columns remain useful at
@@ -415,9 +416,10 @@ export const getPlatformTableHeadClassForKind = (kind: PlatformTableColumnKind):
export const getPlatformTableCellClassForKind = (kind: PlatformTableColumnKind): string =>
getPlatformTableCellClass(getPlatformColumnAlign(kind));
// Text-only rows otherwise collapse below the scanning rhythm established by
// platform rows with buttons, badges, or progress bars.
export const getPlatformTableRowClass = (): string => 'h-8';
// Summary rows stay on one 32px scanning rhythm across every provider. Detail
// rows are deliberately excluded at the shared CSS boundary so their content
// can expand without weakening the overview-table contract.
export const getPlatformTableRowClass = (): string => PLATFORM_TABLE_SUMMARY_ROW_CLASS;
export function PlatformResponsiveTableLabel(props: { compact: string; full: string }) {
return (
@@ -262,6 +262,7 @@ export function ProxmoxBackupServersTable(props: {
<TableRow class="hover:bg-surface-hover">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content truncate font-medium`}
title={[row.serverName, row.datastore?.name].filter(Boolean).join(' · ')}
>
<div class="flex min-w-0 items-center gap-1">
<PlatformResourceDetailToggleButton
@@ -276,19 +277,6 @@ export function ProxmoxBackupServersTable(props: {
{row.serverName}
</div>
</div>
<Show
when={
layoutMode() === 'compact' &&
!columnVisible('datastore') &&
row.datastore
}
>
{(datastore) => (
<div class="min-w-0 truncate font-mono text-[10px] font-normal text-muted">
{datastore().name}
</div>
)}
</Show>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
@@ -426,7 +426,10 @@ export const ProxmoxCephTable: Component<{
aria-expanded={isOpen()}
tabIndex={0}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<TableCell
class={getPlatformTableCellClassForKind('name')}
title={[name, cluster.platformId].filter(Boolean).join(' · ')}
>
<div class="flex items-center gap-2 min-w-0">
<PlatformResourceDetailToggleButton
expanded={isOpen()}
@@ -438,16 +441,13 @@ export const ProxmoxCephTable: Component<{
{name}
</span>
</div>
<Show when={cluster.platformId}>
<div
class="hidden text-[10px] text-muted font-mono truncate md:block"
title={cluster.platformId}
>
{cluster.platformId}
</div>
</Show>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<TableCell
class={getPlatformTableCellClassForKind('text')}
title={[ind.label, cluster.ceph?.healthStatus]
.filter(Boolean)
.join(' · ')}
>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
@@ -457,11 +457,6 @@ export const ProxmoxCephTable: Component<{
/>
<span class={`text-[11px] font-medium ${ind.tone}`}>{ind.label}</span>
</div>
<Show when={!!cluster.ceph?.healthStatus}>
<div class="hidden text-[10px] text-muted font-mono md:block">
{cluster.ceph?.healthStatus}
</div>
</Show>
</TableCell>
<Show when={showFsid()}>
<TableCell
@@ -358,7 +358,7 @@ export const ProxmoxNodesTable: Component<{
</For>
}
body={
<PlatformWindowedRows items={nodePreview.visibleRows} estimatedRowHeight={40}>
<PlatformWindowedRows items={nodePreview.visibleRows} estimatedRowHeight={32}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? '';
@@ -366,10 +366,6 @@ export const ProxmoxNodesTable: Component<{
layoutMode() === 'narrow' && nativeNodeName().length > 0
? nativeNodeName()
: name();
const showNativeNodeName = () =>
layoutMode() === 'phone' &&
nativeNodeName().length > 0 &&
nativeNodeName() !== name();
const drawerNode = createMemo(() => nodeFromResource(node));
const detailRowId = () => `proxmox-host-drawer-${node.id}`;
const isSelected = () => selectedNodeId() === node.id;
@@ -498,21 +494,20 @@ export const ProxmoxNodesTable: Component<{
title={node.status || 'unknown'}
ariaHidden
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="min-w-0 flex-1">
<ResourceNameWithWebInterfaceLink
name={name()}
url={externalUrl()}
class={`min-w-0 ${usesCondensedIdentity() ? '[&>a]:hidden' : ''}`}
nameClass="truncate font-semibold text-base-content"
title={`Open ${name()} web interface`}
title={
nativeNodeName() && nativeNodeName() !== name()
? `${name()} · Proxmox node ${nativeNodeName()}`
: `Open ${name()} web interface`
}
>
{visibleNodeLabel()}
</ResourceNameWithWebInterfaceLink>
<Show when={showNativeNodeName()}>
<span class="-mt-0.5 truncate text-[10px] text-muted">
{nativeNodeName()}
</span>
</Show>
</div>
<Show when={!isOnline()}>
<span class="hidden rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted sm:inline-flex">
@@ -116,15 +116,24 @@ export function ProxmoxRecoverableTable(props: {
const renderRow = (artifact: RecoverableArtifact): JSX.Element => (
<TableRow class="hover:bg-surface-hover" data-proxmox-backup-row="recoverable">
<TableCell class={`${getPlatformTableCellClassForKind('name')} text-base-content`}>
<div class="min-w-0">
<div class="truncate font-semibold">
<div class="flex min-w-0 items-center gap-1">
<div
class="min-w-0 truncate font-semibold"
title={[
artifact.workload.name || artifact.workload.label,
artifact.workload.typeLabel,
artifact.workload.vmid,
]
.filter(Boolean)
.join(' · ')}
>
{artifact.workload.name || artifact.workload.label}
</div>
<Show when={isCompactBackupIdentityLayout(layoutMode())}>
<div class="truncate text-[10px] text-muted">
<span class="shrink-0 text-[10px] text-muted">
{artifact.workload.typeLabel}
<Show when={artifact.workload.vmid}> {artifact.workload.vmid}</Show>
</div>
</span>
</Show>
</div>
</TableCell>
@@ -288,7 +288,7 @@ describe('ProxmoxNodesTable', () => {
expect(link).toHaveAttribute('href', 'https://pve1:8006');
});
it('keeps the provider-native node name visible beside a friendly name on phones', () => {
it('keeps provider-native node identity without adding a phone subtitle', () => {
render(() => (
<ProxmoxNodesTable
nodes={[
@@ -313,7 +313,7 @@ describe('ProxmoxNodesTable', () => {
));
expect(screen.getByText('West Production A')).toBeInTheDocument();
expect(screen.getByText('pve1')).toBeInTheDocument();
expect(screen.getByTitle('West Production A · Proxmox node pve1')).toBeInTheDocument();
});
it('passes alert-backed temperature thresholds into the node temperature gauge', () => {
@@ -4,7 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { ProxmoxPageSurface } from '../ProxmoxPageSurface';
import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw';
import proxmoxBackupServersTableSource from '../ProxmoxBackupServersTable.tsx?raw';
import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw';
import proxmoxRecoverableTableSource from '../ProxmoxRecoverableTable.tsx?raw';
const mockUseUnifiedResources = vi.fn();
const mockPathname = vi.hoisted(() => vi.fn(() => '/proxmox/overview'));
@@ -351,6 +353,17 @@ describe('ProxmoxPageSurface contract', () => {
expect(proxmoxBackupsTableSource).not.toContain('SavedViews');
});
it('keeps backup summary identity on one canonical compact row', () => {
expect(proxmoxBackupServersTableSource).not.toContain(
'font-mono text-[10px] font-normal text-muted',
);
expect(proxmoxBackupServersTableSource).toContain(
"title={[row.serverName, row.datastore?.name].filter(Boolean).join(' · ')}",
);
expect(proxmoxRecoverableTableSource).toContain('class="flex min-w-0 items-center gap-1"');
expect(proxmoxRecoverableTableSource).not.toContain('class="truncate text-[10px] text-muted"');
});
it('hydrates route-scoped resource families instead of one broad estate request', () => {
setResources([
makeResource({
@@ -363,7 +363,7 @@ const AgentMachineNetworkCell: Component<{
<AgentMachineMetricTooltip
triggerDataAttribute="data-agent-machine-network-trigger"
tooltipDataAttribute="data-agent-machine-network-tooltip"
triggerClass="grid w-full grid-cols-[0.75rem_minmax(0,1fr)] grid-rows-2 items-center gap-x-1 gap-y-0.5 text-[11px] leading-tight tabular-nums"
triggerClass="flex w-full min-w-0 items-center gap-1 text-[11px] tabular-nums"
tooltipClass="min-w-[230px] max-w-[360px] space-y-2"
enabled={hasDetails()}
ariaLabel={ariaLabel()}
@@ -371,9 +371,9 @@ const AgentMachineNetworkCell: Component<{
maxWidth={380}
trigger={
<>
<span class="inline-flex w-3 justify-center text-emerald-500"></span>
<span class="inline-flex shrink-0 text-emerald-500"></span>
<span class="min-w-0 truncate">{formatSpeed(props.network?.rxBytes ?? 0)}</span>
<span class="inline-flex w-3 justify-center text-orange-400"></span>
<span class="inline-flex shrink-0 text-orange-400"></span>
<span class="min-w-0 truncate">{formatSpeed(props.network?.txBytes ?? 0)}</span>
</>
}
@@ -493,7 +493,7 @@ const AgentMachineDiskIOCell: Component<{
<AgentMachineMetricTooltip
triggerDataAttribute="data-agent-machine-diskio-trigger"
tooltipDataAttribute="data-agent-machine-diskio-tooltip"
triggerClass="grid w-full grid-cols-[0.75rem_minmax(0,1fr)] grid-rows-2 items-center gap-x-1 gap-y-0.5 text-[11px] leading-tight tabular-nums"
triggerClass="flex w-full min-w-0 items-center gap-1 text-[11px] tabular-nums"
tooltipClass="min-w-[230px] max-w-[360px] space-y-2"
enabled={hasDetails()}
ariaLabel={ariaLabel()}
@@ -501,9 +501,9 @@ const AgentMachineDiskIOCell: Component<{
maxWidth={380}
trigger={
<>
<span class="inline-flex w-3 justify-center font-mono text-blue-500">R</span>
<span class="inline-flex shrink-0 font-mono text-blue-500">R</span>
<span class="min-w-0 truncate">{formatSpeed(props.diskIO?.readRate ?? 0)}</span>
<span class="inline-flex w-3 justify-center font-mono text-amber-500">W</span>
<span class="inline-flex shrink-0 font-mono text-amber-500">W</span>
<span class="min-w-0 truncate">{formatSpeed(props.diskIO?.writeRate ?? 0)}</span>
</>
}
@@ -1607,6 +1607,9 @@ export const AgentsMachinesTable: Component<{
>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} ${machineColumnWidthClass('machine')}`}
title={[name(), machineSubtitle() || systemLabel()]
.filter(Boolean)
.join(' · ')}
>
<div class="flex min-w-0 items-center gap-2">
<PlatformResourceDetailToggleButton
@@ -1628,26 +1631,6 @@ export const AgentsMachinesTable: Component<{
nameClass="truncate font-semibold text-base-content"
/>
</div>
<Show
when={machineSubtitle()}
fallback={
<span
class="mt-0.5 hidden truncate pl-10 text-[9px] text-muted sm:text-[10px] md:block"
title={systemLabel()}
>
{systemLabel()}
</span>
}
>
{(subtitle) => (
<span
class="mt-0.5 hidden truncate pl-10 text-[9px] text-muted sm:text-[10px] md:block"
title={subtitle()}
>
{subtitle()}
</span>
)}
</Show>
</TableCell>
<Show when={columnVisibility.isColumnVisible('system')}>
<TableCell
@@ -325,21 +325,21 @@ describe('AgentsMachinesTable', () => {
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sort by IP' })).not.toBeInTheDocument();
expect(screen.getByText('192.168.0.10')).toBeInTheDocument();
expect(screen.getByTitle('Tower · 192.168.0.10')).toBeInTheDocument();
await fireEvent.click(screen.getByLabelText('IP'));
await fireEvent.click(screen.getByLabelText('RAID'));
expect(screen.getByRole('button', { name: 'Sort by IP' })).toBeInTheDocument();
// The primary IP remains in the compact identity subtitle as a fallback
// while the dedicated user-selected column is container-hidden.
expect(screen.getAllByText('192.168.0.10').length).toBeGreaterThanOrEqual(2);
// The compact identity stays one line; the selected IP column owns the
// visible value while the identity tooltip retains the same context.
expect(screen.getAllByText('192.168.0.10').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('1 clean')).toBeInTheDocument();
const memoryBar = screen.getByTestId('agent-machine-memory-bar');
expect(memoryBar).toHaveAttribute('data-cache', '10');
expect(memoryBar).not.toHaveAttribute('data-cache-inclusive-label', 'Shown in Proxmox');
});
it('shows hostname and primary IP in the machine identity subtitle', () => {
it('keeps hostname and primary IP in the single-line machine identity context', () => {
render(() => (
<AgentsMachinesTable
resources={[
@@ -355,7 +355,9 @@ describe('AgentsMachinesTable', () => {
/>
));
expect(screen.getByText('richard-mac-mini.local | 192.168.0.98')).toBeInTheDocument();
expect(
screen.getByTitle('Mac Mini · richard-mac-mini.local | 192.168.0.98'),
).toBeInTheDocument();
});
it('normalizes canonical OS labels from platform fallback values', () => {
@@ -581,7 +583,7 @@ describe('AgentsMachinesTable', () => {
expect(screen.getByText('Windows Runner')).toBeInTheDocument();
});
it('preserves last-seen context in the machine subtitle when that column is hidden', async () => {
it('preserves last-seen context in the single-line machine identity when that column is hidden', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_600_000);
render(() => (
@@ -605,7 +607,7 @@ describe('AgentsMachinesTable', () => {
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Last seen'));
expect(screen.getByText('192.168.0.21 | seen 5m ago')).toBeInTheDocument();
expect(screen.getByTitle('Recent Machine · 192.168.0.21 | seen 5m ago')).toBeInTheDocument();
});
it('shows a row expansion affordance for machine details', async () => {
@@ -234,19 +234,21 @@ export const TrueNASAlertsTable: Component<{
<div class="min-w-0">
<div
class="truncate font-medium text-base-content"
title={incident.resourceName}
title={[
incident.resourceName,
formatPlatformAlertResourceType(
incident.resourceType,
'truenas',
),
incident.resource.parentName
? `on ${incident.resource.parentName}`
: '',
]
.filter(Boolean)
.join(' · ')}
>
{incident.resourceName}
</div>
<div class="truncate text-[10px] text-muted">
{formatPlatformAlertResourceType(
incident.resourceType,
'truenas',
)}
<Show when={incident.resource.parentName}>
on {incident.resource.parentName}
</Show>
</div>
</div>
</div>
</TableCell>
@@ -257,20 +259,25 @@ export const TrueNASAlertsTable: Component<{
/>
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')}`}>
<span class="block truncate text-base-content" title={incident.summary}>
<span
class="block truncate text-base-content"
title={[incident.summary, incident.label].filter(Boolean).join(' · ')}
>
{incident.summary}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.label}
</span>
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')}`}>
<span class="block truncate" title={incident.code}>
<span
class="block truncate"
title={[
formatPlatformAlertCode(incident.code, 'truenas'),
incident.source,
]
.filter(Boolean)
.join(' · ')}
>
{formatPlatformAlertCode(incident.code, 'truenas')}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.source}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
@@ -372,15 +372,17 @@ export const TrueNASAppsTable: Component<{
<div class="min-w-0">
<div
class="truenas-app-name-value truncate font-medium text-base-content"
title={name()}
title={[
name(),
`${formatPlatformTableTitleCaseValue(app()?.state)} on ${
resource.parentName || 'TrueNAS'
}${app()?.customApp ? ' · Custom' : ''}`,
]
.filter(Boolean)
.join(' · ')}
>
{name()}
</div>
<div class="truncate text-[10px] text-muted">
{formatPlatformTableTitleCaseValue(app()?.state)} on{' '}
{resource.parentName || 'TrueNAS'}
<Show when={app()?.customApp}> - Custom</Show>
</div>
</div>
</div>
</TableCell>
@@ -277,15 +277,20 @@ export const TrueNASNetworkSharesTable: Component<{
title={indicator().label}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
<div
class="truncate font-medium text-base-content"
title={[
name(),
share()?.dataset ||
share()?.path ||
resource.parentName ||
'TrueNAS',
]
.filter(Boolean)
.join(' · ')}
>
{name()}
</div>
<div class="truncate text-[10px] text-muted">
{share()?.dataset ||
share()?.path ||
resource.parentName ||
'TrueNAS'}
</div>
</div>
</div>
</TableCell>
@@ -309,11 +314,6 @@ export const TrueNASNetworkSharesTable: Component<{
title={[access().title, security().title].filter(Boolean).join(' | ')}
>
<span class="block truncate">{access().label}</span>
<Show when={security().label !== '-'}>
<span class="block truncate text-[10px] text-muted">
{security().label}
</span>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
@@ -321,12 +321,12 @@ const DatasetCell: Component<{ point: RecoveryPoint; detailToggle?: JSX.Element
title={getRecoveryOutcomeLabel(normalizeRecoveryOutcome(props.point.outcome))}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
<div
class="truncate font-medium text-base-content"
title={[name(), subtitle()].filter(Boolean).join(' · ')}
>
{name()}
</div>
<div class="truncate text-[10px] text-muted" title={subtitle()}>
{subtitle()}
</div>
</div>
</div>
);
@@ -533,30 +533,22 @@ export const TrueNASProtectionTable: Component<{
/>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="block truncate text-base-content" title={artifact()}>
<span
class="block truncate text-base-content"
title={[artifact(), artifactSecondary()]
.filter(Boolean)
.join(' · ')}
>
{artifact()}
</span>
<Show when={artifactSecondary()}>
<span
class="block truncate text-[10px] text-muted"
title={artifactSecondary()}
>
{artifactSecondary()}
</span>
</Show>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="block truncate text-base-content" title={target()}>
<span
class="block truncate text-base-content"
title={[target(), targetSecondary()].filter(Boolean).join(' · ')}
>
{target()}
</span>
<Show when={targetSecondary()}>
<span
class="block truncate text-[10px] text-muted"
title={targetSecondary()}
>
{targetSecondary()}
</span>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
@@ -265,12 +265,12 @@ const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: J
{props.detailToggle}
<StatusDot size="sm" variant={indicator().variant} title={indicator().label} />
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
<div
class="truncate font-medium text-base-content"
title={[name(), subtitle(props.row)].filter(Boolean).join(' · ')}
>
{name()}
</div>
<div class="truncate text-[10px] text-muted" title={subtitle(props.row)}>
{subtitle(props.row)}
</div>
</div>
</div>
);
@@ -380,15 +380,10 @@ export const TrueNASSystemsTable: Component<{
<div class="min-w-0">
<div
class="truncate font-semibold text-base-content"
title={name()}
title={[name(), systemMeta()].filter(Boolean).join(' · ')}
>
{name()}
</div>
<Show when={systemMeta()}>
<div class="truncate text-[11px] text-muted" title={systemMeta()}>
{systemMeta()}
</div>
</Show>
</div>
</div>
</TableCell>
@@ -443,14 +438,11 @@ export const TrueNASSystemsTable: Component<{
<PlatformTableTemperatureValue value={system.temperature} />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden whitespace-normal text-base-content lg:table-cell`}
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
title={`${storageInventoryPrimary(c())} · ${storageInventorySecondary(c())}`}
>
<div class="leading-tight">
<div class="truncate">{storageInventoryPrimary(c())}</div>
<div class="truncate text-[11px] text-muted">
{storageInventorySecondary(c())}
</div>
<div class="truncate">
{storageInventoryPrimary(c())} · {storageInventorySecondary(c())}
</div>
</TableCell>
<TableCell
@@ -459,14 +451,11 @@ export const TrueNASSystemsTable: Component<{
<PlatformTableNumberValue value={c().shares} />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden whitespace-normal text-base-content lg:table-cell`}
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
title={`${workloadInventoryPrimary(c())} · ${workloadInventorySecondary(c())}`}
>
<div class="leading-tight">
<div class="truncate">{workloadInventoryPrimary(c())}</div>
<div class="truncate text-[11px] text-muted">
{workloadInventorySecondary(c())}
</div>
<div class="truncate">
{workloadInventoryPrimary(c())} · {workloadInventorySecondary(c())}
</div>
</TableCell>
<TableCell
@@ -310,15 +310,20 @@ export const TrueNASVirtualMachinesTable: Component<{
title={indicator().label}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
<div
class="truncate font-medium text-base-content"
title={[
name(),
vm()?.description ||
vm()?.uuid ||
resource.parentName ||
'TrueNAS',
]
.filter(Boolean)
.join(' · ')}
>
{name()}
</div>
<div class="truncate text-[10px] text-muted">
{vm()?.description ||
vm()?.uuid ||
resource.parentName ||
'TrueNAS'}
</div>
</div>
</div>
</TableCell>
@@ -80,7 +80,7 @@ describe('TrueNASAlertsTable', () => {
detail.getByText('Investigate disk health and schedule replacement if degradation continues'),
).toBeInTheDocument();
await fireEvent.click(detail.getByRole('button', { name: 'Close' }));
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('truenas-alert-detail')).not.toBeInTheDocument();
expect(row).toHaveAttribute('aria-expanded', 'false');
@@ -116,7 +116,7 @@ describe('TrueNASProtectionTable', () => {
expect(detail.getAllByText('vault/compliance/tank_apps').length).toBeGreaterThan(1);
expect(detail.getByText('tank/apps@auto-20260331-0600')).toBeInTheDocument();
await fireEvent.click(detail.getByRole('button', { name: 'Close' }));
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('truenas-protection-detail')).not.toBeInTheDocument();
expect(row).toHaveAttribute('aria-expanded', 'false');
@@ -400,16 +400,18 @@ export const VsphereActivityTable: Component<{
<div class="min-w-0">
<div
class="truncate font-medium text-base-content"
title={activity.resourceName}
title={[
activity.resourceName,
formatResourceType(activity.resourceType),
activity.resource.parentName
? `on ${activity.resource.parentName}`
: '',
]
.filter(Boolean)
.join(' · ')}
>
{activity.resourceName}
</div>
<div class="truncate text-[10px] text-muted">
{formatResourceType(activity.resourceType)}
<Show when={activity.resource.parentName}>
on {activity.resource.parentName}
</Show>
</div>
</div>
</div>
</TableCell>
@@ -419,12 +421,17 @@ export const VsphereActivityTable: Component<{
</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="block truncate text-base-content" title={activity.title}>
<span
class="block truncate text-base-content"
title={[
activity.title,
activity.message || activity.description || activity.nativeId,
]
.filter(Boolean)
.join(' · ')}
>
{activity.title}
</span>
<span class="block truncate text-[10px] text-muted">
{activity.message || activity.description || activity.nativeId}
</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span class="text-[11px] font-semibold text-base-content">
@@ -443,13 +450,15 @@ export const VsphereActivityTable: Component<{
>
<span
class="block truncate text-base-content"
title={meta()?.connectionName || meta()?.vcenterHost}
title={[
meta()?.connectionName || meta()?.vcenterHost,
meta()?.datacenterName || meta()?.clusterName,
]
.filter(Boolean)
.join(' · ')}
>
{meta()?.connectionName || meta()?.vcenterHost || '-'}
</span>
<span class="block truncate text-[10px] text-muted">
{meta()?.datacenterName || meta()?.clusterName || '-'}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
@@ -240,16 +240,21 @@ export const VsphereAlertsTable: Component<{
<div class="min-w-0">
<div
class="truncate font-medium text-base-content"
title={incident.resourceName}
title={[
incident.resourceName,
formatPlatformAlertResourceType(
incident.resourceType,
'vmware',
),
incident.resource.parentName
? `on ${incident.resource.parentName}`
: '',
]
.filter(Boolean)
.join(' · ')}
>
{incident.resourceName}
</div>
<div class="truncate text-[10px] text-muted">
{formatPlatformAlertResourceType(incident.resourceType, 'vmware')}
<Show when={incident.resource.parentName}>
on {incident.resource.parentName}
</Show>
</div>
</div>
</div>
</TableCell>
@@ -260,33 +265,40 @@ export const VsphereAlertsTable: Component<{
/>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="block truncate text-base-content" title={incident.summary}>
<span
class="block truncate text-base-content"
title={[incident.summary, incident.label].filter(Boolean).join(' · ')}
>
{incident.summary}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.label}
</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span
class="block truncate text-base-content"
title={meta()?.connectionName || meta()?.vcenterHost}
title={[
meta()?.connectionName || meta()?.vcenterHost,
meta()?.datacenterName || meta()?.clusterName,
]
.filter(Boolean)
.join(' · ')}
>
{meta()?.connectionName || meta()?.vcenterHost || '-'}
</span>
<span class="block truncate text-[10px] text-muted">
{meta()?.datacenterName || meta()?.clusterName || '-'}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
>
<span class="block truncate" title={incident.managedObjectId}>
<span
class="block truncate"
title={[
formatPlatformAlertEntityType(incident.entityType),
incident.managedObjectId,
]
.filter(Boolean)
.join(' · ')}
>
{formatPlatformAlertEntityType(incident.entityType)}
</span>
<span class="block truncate text-[10px] text-muted">
{incident.managedObjectId}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
@@ -99,7 +99,9 @@ describe('VsphereActivityTable', () => {
expect(within(table).getByText('vCenter')).toBeInTheDocument();
expect(screen.getAllByText('warehouse-api-01')).toHaveLength(2);
expect(screen.getByText('Reconfigure virtual machine')).toBeInTheDocument();
expect(screen.getByText('Permission denied while reconfiguring VM')).toBeInTheDocument();
expect(
screen.getByTitle('Reconfigure virtual machine · Permission denied while reconfiguring VM'),
).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('VmPoweredOnEvent')).toBeInTheDocument();
expect(screen.getByText('administrator@vsphere.local')).toBeInTheDocument();
@@ -119,7 +121,7 @@ describe('VsphereActivityTable', () => {
expect(detail.getByText('Description')).toBeInTheDocument();
expect(detail.getByText('Reconfigure virtual machine CPU reservation')).toBeInTheDocument();
await fireEvent.click(detail.getByRole('button', { name: 'Close' }));
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('vsphere-activity-detail')).not.toBeInTheDocument();
expect(row).toHaveAttribute('aria-expanded', 'false');
@@ -89,7 +89,7 @@ describe('VsphereAlertsTable', () => {
const firstRow = document.querySelector('[data-vsphere-alert-row]') as HTMLElement;
expect(firstRow).toHaveAttribute('data-vsphere-alert-row', 'host-alarm:incident:alarm-401:0');
expect(within(firstRow).getByText('lab-vcenter')).toBeInTheDocument();
expect(within(firstRow).getByText('host-101')).toBeInTheDocument();
expect(within(firstRow).getByTitle('Host · host-101')).toBeInTheDocument();
const attentionFilter = screen.getByRole('button', {
name: 'Attention, 1',
@@ -119,7 +119,7 @@ describe('VsphereAlertsTable', () => {
expect(detail.getByText('Action')).toBeInTheDocument();
expect(detail.getByText('Investigate in vCenter')).toBeInTheDocument();
await fireEvent.click(detail.getByRole('button', { name: 'Close' }));
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('vsphere-alert-detail')).not.toBeInTheDocument();
expect(row).toHaveAttribute('aria-expanded', 'false');
+10 -5
View File
@@ -526,11 +526,6 @@
line-height: 1rem;
}
.pulse-wide-data-surface .host-row,
.pulse-wide-data-surface .workload-row {
height: 2.25rem;
}
.pulse-wide-data-surface .host-row > td:first-child > div,
.pulse-wide-data-surface .workload-row [data-workload-col='name'] > div {
gap: 0.25rem;
@@ -1023,6 +1018,16 @@ body,
letter-spacing: 0.025em;
}
/*
* Docker's single-line rows are the canonical operator density. Every
* platform summary row uses the same 32px scan rhythm; inline drawers remain
* content-sized. Secondary context belongs in another column, a tooltip, or
* the expanded detail row rather than making the overview row taller.
*/
.table-scroll-shell > .table-fixed.platform-table > tbody > tr:not([data-inline-detail-for]) {
height: 2rem;
}
.platform-table-label-compact {
display: inline;
}