Commit Graph

2912 Commits

Author SHA1 Message Date
rcourtman b19beb1976 Make vSphere datastores API-native 2026-05-22 07:38:57 +01:00
rcourtman 1118e09fcd Clear server-side CSRF token on logout
clearSession invalidated the session-store record and zeroed both
session/CSRF cookies on the client side, but never deleted the
matching entry from the CSRF store. The orphaned record stuck around
for up to 4 hours (CSRF TTL) after every logout. Not exploitable —
the session itself was gone, so the stale CSRF entry could not be
used — but the asymmetry was real: InvalidateUserSessions (password
change) and InvalidateOldSessionFromRequest (re-login) both already
called DeleteCSRFToken, and logout was the missing path.

Add the same call in clearSession alongside InvalidateSession.

Regression test TestClearSession_DeletesServerSideCSRFToken sets up a
real session + CSRF token pair, calls clearSession with a request
carrying the session cookie, and asserts the CSRF token no longer
validates against the session ID afterward.
2026-05-21 16:26:00 +01:00
rcourtman 77a6a8c695 Read RBAC identity from request context, not response header
RequirePermission was reading the authenticated username from
w.Header().Get("X-Authenticated-User"). The header is set by checkAuth
upstream, but response headers are mutable across the handler chain:
any middleware sitting between checkAuth and RequirePermission that
wrote that header would substitute an arbitrary identity, and the
RBAC authorizer would make its access decision against the
substituted value. Identity belongs on the request context, not in a
mutable response surface.

Read internalauth.GetUser(r.Context()) first — checkAuth already
calls attachUserContext on every auth path that produces a real user,
so the context is the authoritative source. Keep a defensive,
warn-logged fallback to the response header for the one upstream path
that sets the header but skips attachUserContext (the anonymous-user
flow), so the existing test contract is preserved while the warning
flags the source path to plug.

Regression test TestRequirePermissionUsesContextUsername constructs
the attack: a request whose context says "real-authenticated-user"
and whose response header says "evil-spoofed-user". The RBAC
authorizer must observe the context value.
2026-05-21 16:19:25 +01:00
rcourtman 0ac04c8d39 Validate SAML LogoutResponse before clearing session
handleSAMLSLO was an unauthenticated force-logout DoS: any cross-origin
GET or POST to /api/saml/{id}/slo cleared the user's session and
returned a 302 to /?logout=success. No SAML payload was required, no
signature validation, no IdP-identity check.

Add ValidateLogoutResponse to SAMLService wrapping crewjam/saml's
ServiceProvider.ValidateLogoutResponseRequest, which validates the
XML-DSig against the configured IdP certificate and the standard
LogoutResponse temporal and target invariants.

In handleSAMLSLO:
  - extract and validate the provider ID
  - require either a SAMLResponse (response to our SLO request) or a
    SAMLRequest (IdP-initiated SLO) parameter — reject bare requests
    with 400 and do not clear session
  - reject IdP-initiated LogoutRequest with 400: we don't track
    in-flight request IDs to bind one to yet, and silently treating it
    as a force-logout signal is the same bug we are closing
  - on SAMLResponse, call service.ValidateLogoutResponse. On failure
    return 403 and do not clear session; audit-log the failure
  - only when validation passes do we clear the session and redirect

Regression tests:
  - TestHandleSAMLSLO_RejectsEmptyPayload (400, no redirect, no session
    state mutation)
  - TestHandleSAMLSLO_RejectsInvalidLogoutResponse (403 on garbage
    SAMLResponse value)
  - TestHandleSAMLSLO_RejectsIDPInitiatedLogoutRequest (400 on
    SAMLRequest-only payload)

The prior TestHandleSAMLSLO_Redirects test, which asserted the unsafe
"clear session and 302 on any GET" behaviour, is replaced by the
three above.
2026-05-21 16:15:53 +01:00
rcourtman d890904ce6 Stop auto-escalating SameSite to None on proxied requests
getCookieSettings auto-set SameSite=None on the session and CSRF
cookies whenever a request arrived via a trusted proxy with
X-Forwarded-Proto: https. The original intent was "be more permissive
for proxied deployments," but in practice that disabled the
browser-side CSRF defence for every Pulse deployment behind a reverse
proxy — which is nearly all of them.

SameSite=None tells the browser to attach the cookie on arbitrary
cross-site requests. That is only required for cross-origin iframe
embedding scenarios Pulse does not document or support. The
top-level-navigation cases that actually matter (OIDC/SAML callback
landing, Cloudflare-tunnel access from a bookmark) all work under Lax,
which still attaches cookies on top-level navigations and only blocks
cross-site sub-resource requests.

Always return SameSiteLaxMode. The Secure flag still tracks the
actual connection state via isConnectionSecure(r). Together with the
earlier CSRF bypass fix (require token regardless of Authorization
header), the browser-side and server-side defences are now both in
place.

Test expectations updated: three cases that previously asserted
SameSiteNoneMode for the proxied-HTTPS / Cloudflare-tunnel / direct-
TLS-with-Forwarded paths now assert SameSiteLaxMode, with comments
flagging them as regression coverage.
2026-05-21 16:11:30 +01:00
rcourtman bc2adac8b5 Stabilize dev runtime and complete PMG read-state migration
Refs architecture post-RC canonicalization follow-up.
2026-05-21 15:15:23 +01:00
rcourtman 0f4c831fab Reject expired trials in hosted subscription gate
isHostedSubscriptionValid was treating any tenant with a non-nil
TrialEndsAt as a valid bounded trial, never comparing the timestamp to
the current time. A tenant whose trial ended in the past but whose
stored subscription state was still "trial" (e.g. lease refresh failed
or never ran) retained full hosted Cloud access indefinitely.

The validity rule now requires both that a trial end timestamp exists
AND that time.Now() is before it. Refactored the switch to make the
trial branch read explicitly: nil evaluator -> deny, nil end -> deny,
past end -> deny, otherwise allow.

Added TestTenantMiddleware_HostedMode_ExpiredTrial_Blocked as the
regression test (mirrors the existing BoundedTrial_Allowed +
UnboundedTrial_Blocked pair with a past-dated trial end).
2026-05-21 12:33:10 +01:00
rcourtman bb3514e316 Require CSRF token regardless of Authorization header
CheckCSRF was skipping the CSRF check whenever the request carried
Authorization: Bearer, Authorization: Basic, or X-API-Token, without
validating the credential. An attacker on a cross-origin page could
fetch() any state-changing endpoint with credentials: 'include' plus an
arbitrary Authorization header — the browser would auto-attach the
victim's pulse_session cookie, the server would skip CSRF, and the
request would execute as the logged-in user. Full CSRF bypass for every
session-authenticated user.

CSRF protection exists because the browser auto-attaches the session
cookie. That cookie is the only auto-attached credential we issue, so
it is the only correct signal for whether CSRF applies. Header-based
auth is set explicitly per request and is not CSRF-vulnerable, but its
presence does not make a session-cookie-bearing request safe. Skip CSRF
only when no session cookie is present; otherwise require the token
regardless of any Authorization or X-API-Token header.

Tests: the three "header bypasses CSRF" cases in security_test.go were
passing only because they sent no session cookie (so the no-cookie path
returned true). Renamed those to make the no-cookie precondition
explicit. Added TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent
in security_regression_test.go covering X-API-Token, Authorization:
Basic, Authorization: Bearer, and mixed-case Bearer with a session
cookie present — each must require a valid CSRF token.
2026-05-21 12:29:32 +01:00
rcourtman 9ef4e588b7 Add TrueNAS services and stabilize dev startup 2026-05-21 12:08:25 +01:00
rcourtman 179f88d721 Align TrueNAS storage with native queries 2026-05-21 10:39:36 +01:00
rcourtman 6293d6b312 Align TrueNAS protection with native queries 2026-05-21 10:28:50 +01:00
rcourtman e189733434 Align TrueNAS apps with native app.query 2026-05-21 10:23:48 +01:00
rcourtman d4a4bc0c62 Surface native TrueNAS runtime counts
Add VM and share counts to the TrueNAS observed summary contract and systems table.

Keep TrueNAS overview inventory table-first with a platform guardrail.
2026-05-21 09:54:41 +01:00
rcourtman 497390ff95 Scope TrueNAS resources by appliance
Scope TrueNAS child source IDs under the system source key so duplicate pool, dataset, app, VM, share, and disk names from different appliances do not merge.

Use the resulting hierarchy for per-system TrueNAS overview counts and scoped mock metric targets.
2026-05-21 00:59:00 +01:00
rcourtman 0801ede8c3 Add native TrueNAS network share inventory 2026-05-20 23:50:37 +01:00
rcourtman 3fa46aa836 Add native TrueNAS VM inventory to overview
Project TrueNAS vm.query inventory into canonical VM resources and render it on the TrueNAS overview.
2026-05-20 22:32:46 +01:00
rcourtman 2202f36674 Rebuild TrueNAS overview around native app API data
Project TrueNAS app.query data into the unified resource contract and render the TrueNAS overview apps table from that native facet.

Keep Docker metadata as the runtime compatibility fallback for shared container tooling.
2026-05-20 21:54:40 +01:00
rcourtman ddad6108ae Classify runtime lenses in platform manifest 2026-05-20 20:45:53 +01:00
rcourtman aabef9afd5 Add canonical platform scopes for runtime workloads
Teach unified resources to emit platformScopes so runtime workloads can belong to both Docker and their owning platform. Update workload filters to use platform scopes and keep TrueNAS app containers scoped to TrueNAS.
2026-05-20 19:30:55 +01:00
rcourtman ba374da2ee Tighten Discovery drawer signal 2026-05-20 15:55:19 +01:00
rcourtman 142236d797 Seed mock Discovery fixtures 2026-05-20 14:54:19 +01:00
rcourtman 9c55c341e2 Implement Discovery observed context UX 2026-05-20 13:21:55 +01:00
rcourtman 435cf816fb Harden Discovery command-scan gating 2026-05-20 12:40:04 +01:00
rcourtman a191f2ab2b monitoring: surface CrashLoopBackOff pods as degraded on the frontend
monitorFrontendStatus's "pod" branch was re-deriving status from
Kubernetes.PodPhase alone, so a CrashLoopBackOff pod (Phase=Running
with unready containers) was reported as "running" even though
statusFromKubernetesPod had already classified it as StatusWarning.

Drop the unconditional phase=running -> "running" mapping and fall
through to the unified-status switch, which surfaces StatusWarning as
"degraded". Phase-based mappings for pending/unknown/succeeded/failed
are kept since they still cover edge cases where the unified status
is StatusOnline but phase carries useful information.

Adds two regression cases in TestMonitorFrontendAndMetricHelpers:
- pod warning running degraded (CrashLoopBackOff)
- pod online running running (healthy baseline)
2026-05-19 22:37:55 +01:00
rcourtman 11ab890880 mock: extend offline/degraded posture to Docker, PBS, PMG, and host agents
Round out the curated demo estate so every platform page surfaces a
non-healthy entry rather than rendering uniformly green:

- generator.go: always seed a secondary PBS and PMG instance (drop the
  random spawn gates) so the demo PBS and PMG pages reliably show two
  instances.
- demo_scenarios.go (applyDemoDockerScenario): force docker host index 2
  to "field-office-edge-01" with status=offline and exited containers,
  giving the Docker page a disconnected host with stopped workloads.
- demo_scenarios.go (applyDemoKubernetesScenario): mark
  prod-euw1-k8s-03 NotReady in the last cluster so the k8s nodes
  table shows a NotReady worker.
- demo_scenarios.go (applyDemoHostScenario): also force the
  prod-euw1-k8s-03 host agent offline so the Hosts page shows an
  outage that matches the k8s nodes view.
- demo_scenarios.go (applyDemoStorageScenario): mark the dr-vault PBS
  and mail-gateway-us PMG instances as degraded with degraded
  connection health.
- demo_scenarios_test.go: update the curated-posture helpers to expect
  the new offline/degraded entries while still asserting the rest of
  the estate stays healthy after metric refreshes.
2026-05-19 22:03:06 +01:00
rcourtman cd2be53725 mock: simulate offline Proxmox VE host pve5 and stopped workloads
Configure pve5 as offline, cascading to stopped status on all its guests and zeroing metrics to simulate a node outage. Also force standalone offline states on postgres-replica-01 and dev-portal-01 workloads. Update test helpers and platforms coverage checklist in platform_support_contract_test.go.
2026-05-19 19:51:23 +01:00
rcourtman 7652e2833e Promote vSphere from admitted to supported
Flip vmware-vsphere from governance_state=admitted, readiness_stage=
first-lab-ready to supported on both axes. The phase-1 floor (vCenter
client, canonical agent/vm/storage projection, alerts integration,
Assistant read paths, mock fixtures, page surface, automated proof for
the read-only boundary) has been in place; the only remaining gate was
a live vCenter run, and we are taking the platform claim on the strength
of the implementation rather than blocking on that proof.

Add vmware-vsphere to default_infrastructure_source_order, to the
First-class platforms list and Current Support Matrix in
PLATFORM_SUPPORT_MODEL, and to the Pulse primary navigation
(automatically via SUPPORTED_PLATFORM_IDS in the regenerated frontend
manifest). Rename and invert the Go contract test that asserted vSphere
stays admitted, and let the admitted-platform helpers tolerate an empty
set now that nothing currently lives there. Drop the "in first-lab-
ready readiness" line from the vSphere empty state.

PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT used: this commit only flips two
JSON values for one platform; no subsystem contract schema or surface
changed.
2026-05-19 17:27:06 +01:00
rcourtman 882b00652c Add Docker app-container identity coverage 2026-05-19 15:00:14 +01:00
rcourtman 3d500b5af2 Scope app-container registry keys to their docker host
resourceFromDockerContainer only encoded the container name in the
canonical identity, and ingestDockerContainer passed the raw docker
container ID as the source key. Both chooseNewID and the bySource
mapping then hashed only source + sourceID, so any path that briefly
produced the same source ID for two containers across different
docker hosts (a docker ps that returns an empty ID and falls back to
the container name in parseDockerInventoryContainerLine, a future
short-ID truncation, daemon-side identifier reset across recreate
cycles) collapsed both containers into one registry resource. The
second ingest hit mergeInto and overwrote the surviving Docker
payload — including HostSourceID and ParentID — with the colliding
host's data, surfacing on the Docker page as the "frigate@141" host
re-attribution flicker where a container briefly appears under the
wrong docker host before snapping back.

ingestDockerContainer now keys bySource[SourceDocker] by
host.ID/container/ct.ID via dockerContainerSourceID, and
seedSourceIDForResourceLocked produces the same shape for records-
path ingests so both paths resolve to the same resources. The
hashed candidate ID derived from this composite key gives each
(host, container) pair a unique registry entry.

BuildMetricsTarget for ResourceTypeAppContainer prefers
resource.Docker.ContainerID over the bySource SourceID so the
metrics target the API hands out continues to match what the
metrics store writes for container metrics (which still key by
the raw docker container ID).

Add a regression test that ingests two docker hosts each reporting
a container with the same colliding source ID and verifies both
survive as distinct resources with their original HostSourceIDs.
The test fails on the prior implementation (one resource instead
of two).
2026-05-19 14:47:31 +01:00
rcourtman 926906b134 Don't overwrite populated Docker hosts with empty inventory polls
The Proxmox-side LXC Docker inventory poll occasionally produces an
ok=true report with zero CONTAINER lines but a valid PS_OK marker:
docker ps -a inside the LXC genuinely returned an empty list for
that one call, typically while watchtower is briefly recreating a
container or the dockerd is mid-restart. ApplyDockerReport then
wipes the host's container list, every connected WebSocket client
sees every row for that host flash blank, and the next successful
poll 10 s later restores it.

Before calling ApplyDockerReport, check whether the host already
has containers tracked in state. If yes and the new report has
zero containers, log at debug and skip the apply, counting the
poll as skipped rather than collected. A host that is genuinely
empty still applies on first sight (no previous containers means
no previous state to protect).

Add regression tests for both branches: a populated host receiving
an empty report keeps its container list; a freshly-seen empty
host still gets created.
2026-05-19 12:04:51 +01:00
rcourtman 6ce593f474 Skip monitor reload on no-op auto-register
Every ~5 minutes the Pulse agent on a node already covered by an
existing cluster endpoint re-announces itself via POST
/api/auto-register. The handler ran the full add-the-standalone +
consolidate path, which produced an identical persisted-config
state. But it still wrote nodes.enc and called reloadFunc, which
stops the multi-tenant monitor, sleeps 1 s, and rebuilds it from
scratch. During the rebuild the WebSocket-visible resource store
goes empty (nodes:0, vms:0, containers:0, dockerHosts:0), so every
connected client sees every row flash blank and reappear. The
Docker page made the flicker obvious because the rows are dense
and stacked, but the regression hit every page in the UI.

Fingerprint the persisted-config payload before any mutation and
again after consolidation; when the fingerprints match, the
auto-register is a no-op and SaveNodesConfig + reloadFunc are both
skipped. Real changes (a new node, a rotated token, a captured
fingerprint) still flow through unchanged.

Add two regression tests: one drives the warm-up + steady-state
heartbeat pair and asserts the second call does not call
reloadFunc; the other rotates a token and asserts reloadFunc still
fires.
2026-05-19 11:09:14 +01:00
rcourtman 56d5ffca0a Surface LXC Docker hosts with real metrics and an LXC badge
The Proxmox-LXC Docker hosts row was blank (no CPU / MEM-used /
Disk) and the display name appended a noisy "(LXC <vmid>)" suffix,
because the inventory script only collects Docker daemon metadata,
not host resource usage. Pulse already polls the same data for the
underlying LXC via the PVE cluster/resources endpoint, so the
Docker host row should mirror what the rest of Pulse already knows.

Before applying each LXC inventory report, enrich the Host info
with CPU / Memory / Disk / Uptime taken from the source LXC's
existing models.Container. Drop the "(LXC <vmid>)" suffix from
the synthesized display name. On the frontend, detect the
proxmox-lxc-docker: hostSourceId prefix and emit a styled
"LXC <vmid>" badge into the System column instead of the
filtered-out runtime-only "docker" badge.
2026-05-19 10:13:39 +01:00
rcourtman fc72e3d8d1 Fix LXC Docker container flicker from partial inventory polls
The Proxmox-side LXC Docker inventory script masked `docker ps`
failures with `|| true`, so when `pct exec` was truncated, slow, or
`docker ps` itself transiently failed, the parser produced an
ok=true report with `containers=[]` and `ApplyDockerReport` blanked
the host's container list. Containers then reappeared on the next
successful poll, producing the visible flicker on the Docker page.

Emit a `PS_OK` marker only when `docker ps` exits cleanly, and
refuse to apply the report when the marker is absent. A genuine
"host has zero containers" state still applies (PS_OK present,
zero CONTAINER lines), but a truncated or failed poll now skips
apply and lets the previous container list survive the inter-poll
gap. Add regression tests for both paths.
2026-05-19 10:00:26 +01:00
rcourtman 90482607da Bind Proxmox install command tokens on first use 2026-05-19 09:47:05 +01:00
rcourtman 0c1cdf7488 Add opt-in Proxmox LXC Docker inventory 2026-05-18 23:44:24 +01:00
rcourtman f0ad7f1284 Fix TrueNAS fractional uptime decoding
Refs #1469
2026-05-18 23:12:27 +01:00
rcourtman 5a405f7f07 Protect inside-guest Docker visibility boundaries 2026-05-18 14:16:15 +01:00
rcourtman 06fd4fc89e Bypass approval gate for trusted internal Discovery commands
Discovery wraps every probe in `docker exec <container> sh -c '...'`.
The agentexec command policy lists `^docker\s+exec\s` as RequireApproval
(a sound default for user-driven docker exec) and Discovery has no path
to mint or supply an ApprovalID. Result: every probe was rejected, the
scanner returned empty CommandOutputs, and the AI fell back to
"Unknown Infrastructure Resource" at confidence 0. The Discovery sub-tab
rendered empty after a "successful" run.

Add a Trusted bool to ExecuteCommandPayload on both the server-facing
agentexec type and the agent's wire struct. When set, the approval gate
is skipped on both ends and the server does not attempt to auto-mint an
approval grant (which would fail with "approval id is required").
PolicyBlock still applies; this is not a way to run arbitrary commands.

Only the discoveryCommandAdapter sets Trusted=true. The flag is never
populated from a deserialised HTTP body or any user-driven path. Patrol
fixes, Assistant remediation, and AI tool calls continue to flow through
the governed approval-record path with a real ApprovalID.

Contracts: amend agent-lifecycle Completion Obligations and Current
State to document the lone exception to the on-agent approval rail, and
amend ai-runtime to fence the Trusted flag to the discovery adapter
only.
2026-05-17 21:59:39 +01:00
rcourtman 0c9ea7504e Normalize inactive Docker Swarm evidence 2026-05-17 18:45:24 +01:00
rcourtman dc0635606d Keep Pulse runtime boundaries model-owned 2026-05-17 18:24:13 +01:00
rcourtman a26dad0607 proxmox(mail): bespoke drawer with cluster nodes + queues + stats
Mail Gateway rows now expand inline to show the detail the slim
ResourcePMGMeta projection couldn't carry: every cluster node with
status / role / uptime / loadavg / postfix queue (active/deferred/
hold/incoming counts + oldest message age), quarantine breakdown
(spam / virus / attachment / blacklisted), spam-score distribution,
top 8 domains by mail volume, and the mail-flow detail strip
(bounces in/out, greylist, junk, RBL + pregreet rejects, bytes
in/out, average process time).

Drawer fetches /api/pmg/instances?id=<id> on first open so the row
keeps its slim payload; the new endpoint projects
Monitor.GetState().PMGInstances directly under ScopeMonitoringRead
with optional id / name filters.

Same inline expand-row pattern as the ceph and workloads drawers
(TableRow inserted below, colspan all columns).
2026-05-16 16:47:20 +01:00
rcourtman 95171ff7bc proxmox(ceph): bespoke cluster table replaces filtered Storage view
The /proxmox/ceph tab was mounting <StorageSurface forcedView="pools"
forcedSourceFilter="proxmox-pve">. That surface ships its own internal
Pools/Physical-disks tab switcher (StoragePageControls always renders
StorageViewSwitcher), and the forcedView createEffect immediately
reverted any click on those tabs — so the buttons looked interactive
but did nothing, and neither view was Ceph-aware in the first place.

Build a real ProxmoxCephTable backed by the canonical CephCluster
resource (type='ceph') that already flows through /api/resources. Add
ResourceCephMeta/ResourceCephPoolMeta/ResourceCephServiceMeta types on
the frontend Resource (the backend already projects these on
unifiedresources.Resource.Ceph; the frontend type was missing). Render
one row per cluster with health, FSID, MON/MGR quorum, OSD up/in
counts, PGs, pool count + stored bytes, capacity utilisation, daemon
service summary, and the Ceph health message.

Also fix the mock generator: generateCephClusters returns nil unless
some Storage entry has type cephfs/rbd/ceph, and no mock storage was
ever generated with those types — so the Ceph tab has always been
empty under mock mode. Seed a cluster-wide RBD pool (48TB) and CephFS
data pool (24TB) on the mock-cluster instance so the canonical
adapter has real cluster topology to synthesize.
2026-05-16 14:35:22 +01:00
rcourtman 7b821c9cfa mock(pve-backups): seed recent backup-task history
The Proxmox Backups → Recent tasks sub-tab landed empty under mock
mode because PVEBackups.BackupTasks was hard-coded to an empty slice.
Generate 2-6 recent runs per ~70% of guests over the past 7 days,
mostly OK with occasional failures and an in-progress run, so the new
ProxmoxBackupsTable shows realistic data instead of an empty state.
2026-05-16 14:15:53 +01:00
rcourtman 27f35d9819 proxmox(replication+backups): bespoke tables backed by new read-only endpoints
Both sub-tabs were mounting the same RecoverySurface with a forced
platform filter, so /proxmox/replication and /proxmox/backups looked
identical and neither was bespoke to its title.

Replication is a PVE-only concept (zfs send/receive between nodes).
Surface it through a dedicated /api/replication/jobs endpoint that
projects Monitor.ReplicationJobsSnapshot, and render a bespoke
ProxmoxReplicationTable with status, job ID, guest, source→target
nodes, schedule, last sync, duration, fail count and error columns.

PVE backups split into three meaningfully different artifacts:
qm/pct snapshots, vzdump archives on storage, and recent backup-job
tasks. Expose all three through /api/backups/pve (projecting
Monitor.PVEBackupsSnapshot) and render a bespoke ProxmoxBackupsTable
with three sub-tabs, each with columns tuned to that artifact class.

Both endpoints are read-only under ScopeMonitoringRead and accept
optional instance/node/vmid/platform query filters.
2026-05-16 13:57:53 +01:00
rcourtman 3ecc3fd0d1 proxmox(mail): bespoke Mail Gateway table; project uptime on PBS + PMG
Audit pass on the Proxmox Mail sub-tab + PBS rows.

PBS and PMG adapters both had `instance.Uptime` /
`maxPMGUptime(instance.Nodes)` available but never projected to the
top-level `Resource.Uptime` field the canonical table reads. Same
projection pattern as the prior commits in this chain. Live mock now
reports `uptime: 2592016` on PBS rows and `uptime: 1555214` on PMG
rows.

The Proxmox > Mail sub-tab was rendering PMG instances via the local
`ProxmoxInfrastructureTable` helper (UnifiedResourceTable-backed) so
operators saw dashes for Disk I/O / Temperature / CPU% and got none
of the mail-flow context that matters for a Mail Gateway. Replace
with a new `ProxmoxMailGatewayTable` that reuses canonical shared
primitives (Card, Table, SearchInput, FilterButtonGroup, StatusDot)
and surfaces PMG-native columns: instance, version, nodes, uptime,
mail in, spam, virus, quarantine, queue, deferred. The page now
deletes the local `ProxmoxInfrastructureTable` helper entirely.

`ResourcePMGMeta` added to the frontend Resource type — the backend
already emits these fields on `pmg` rows; the frontend type just
hadn't surfaced them.

Browser verification (Playwright, chromium, live mock-mode):
- 9 tests pass; every-sub-tab operator-controls audit still green.

Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set.
Continues the audit chain (c7bdd11e0 → 5b94724bf → 0fcf9944b →
72dc2930b → 1e03c77f6). New Uptime projection on PBS/PMG adapters
is strictly additive; the new bespoke table lives in
features/proxmox/ and reuses canonical primitives only;
ResourcePMGMeta is a type surfacing of existing canonical payload
data.
2026-05-16 13:22:48 +01:00
rcourtman 0fcf9944b3 docker + vsphere: bespoke host tables; project uptime/temp on docker-host adapter
Audit pass on Docker Hosts and vSphere Hosts.

Docker hosts had `docker.uptimeSeconds` and `docker.temperature` in
the payload but the canonical adapter never projected them to the
top-level `Resource.Uptime` / `Resource.Temperature` fields the table
reads, so both columns showed dashes. Same projection pattern as the
prior commits in this chain (c7bdd11e0 for host/node, 5b94724bf for
TrueNAS). Live mock now reports `uptime: 781089, temperature: 44.3°C`
on the Docker host row.

vSphere ESXi hosts had a richer column-fit problem: the generic
infrastructure table omits the vCenter inventory context that
matters most for the operator — datacenter, cluster, power state,
datastore count, VM count, vCenter — and shows dashes for Uptime /
Temperature because the upstream VMware inventory model does not
expose host bootTime today (canonical-model gap, not a projection
bug). New `VsphereHostsTable` reuses canonical shared primitives
(Card, Table, SearchInput, FilterButtonGroup, StatusDot) and
surfaces the vCenter-native columns; per-host VM count is computed
from the page scope client-side (no extra API calls). Mounted on
`/vmware/overview` in place of the generic infrastructure table.

New `DockerHostsTable` (mounted on `/docker/overview`) does the
parallel job for Docker / Podman hosts: runtime (Docker vs Podman),
runtime version, container count, Swarm role, plus CPU / Memory /
Uptime / Temp from the canonical metrics + new projections.

Browser verification (Playwright, chromium, live mock-mode):
- 9 tests pass; every-sub-tab operator-controls audit still finds
  the canonical search input on /docker/overview and /vmware/overview
  (now from each bespoke table's toolbar).

Targeted Go: `go test ./internal/unifiedresources/...` green.

Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set.
Continues the column-fit audit chain (c7bdd11e0 → 5b94724bf →
69f70a3fc). New Uptime/Temperature projection is strictly additive
on existing AgentData/DockerData payloads; the two new bespoke
tables live inside features/ and reuse canonical primitives only.
2026-05-16 13:02:06 +01:00
rcourtman 5b94724bf2 truenas: bespoke Systems + Disks tables; project uptime/temp on the system row
Audit pass on the TrueNAS pages turned up three concrete issues:

1. **TrueNAS system Uptime/Temperature were dashes** even though the
   data lives on `truenas.uptimeSeconds` and on the
   `agent.temperature` max-sensor projection. The system row is
   emitted by `internal/truenas/provider.go::truenasRecordsFromSnapshot`,
   which is a separate path from the host/node adapters that the
   prior commit (c7bdd11e0) updated. Project the same top-level
   `Resource.Uptime` and `Resource.Temperature` here so the canonical
   table renders real values (live mock now reports `uptime: 3628800,
   temperature: 61.5` on the TrueNAS appliance row).

2. **The Systems tab needed NAS-native columns.** A TrueNAS appliance
   is a storage box — operators want at-a-glance pool / dataset /
   disk / app counts on the same row as CPU / Memory / Storage /
   Temperature / Uptime. New `TrueNASSystemsTable` reuses canonical
   shared primitives (Card, Table, SearchInput, FilterButtonGroup,
   StatusDot) and computes per-system child counts client-side from
   the same TrueNAS scope already fetched by the page.

3. **Physical disks weren't surfaced anywhere on the page**, even
   though the canonical TrueNAS adapter emits SMART-instrumented
   `physical_disk` rows with model / serial / size / health /
   temperature / wearout. Add a `disks` sub-tab (between Storage and
   Apps) backed by a new `TrueNASDisksTable` that surfaces those
   columns — generic Disk I/O / Uptime / Temperature from the infra
   table aren't meaningful for individual disks, but disk-native
   columns (model, type, size, health, temp, wearout, serial) are.

Browser verification (Playwright, chromium, live mock-mode dev runtime):
- 9 tests pass. The every-sub-tab operator-controls audit now covers
  /truenas/disks too; the search input on every TrueNAS sub-tab is
  the canonical SearchInput primitive.

Targeted vitest: `src/features/truenas/__tests__/truenasPageModel.test.ts`
(2 tests) green — covers the four-tab spec and the new `disks`
bucket on the page model.

Targeted Go: `go test ./internal/truenas/...` green.

Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set.
This completes the platform-page column-fit audit for TrueNAS. New
top-level `Resource.Uptime` / `Resource.Temperature` projection is
strictly additive (already documented as the adapter responsibility
in unified-resources.md by c7bdd11e0). The two new bespoke tables
live inside features/truenas/ and reuse canonical primitives only.
2026-05-16 12:51:36 +01:00
rcourtman c7bdd11e04 platforms: full column-fit audit — project uptime/temp, native tables for Docker Services and K8s Clusters
Follow-up to the K8s deployments fix (69f70a3fc), done as the full
audit pass the user asked for instead of one-page-at-a-time. Three
distinct column-fit issues remained across the platform pages:

1. **Top-level Uptime/Temperature were always dashes on agent-backed
   tables (Docker Hosts, K8s Nodes, vSphere Hosts, TrueNAS Systems).**
   The backend had the data in `agent.uptimeSeconds`,
   `agent.temperature`, `proxmox.uptime`, and the max-sensor
   `proxmox.temperature` projection, but `unifiedresources.Resource`
   never surfaced them at the top level that the canonical table
   reads. Add `Resource.Uptime` and `Resource.Temperature` and
   populate them from `resourceFromHost` (for Pulse Agents) and
   `resourceFromNode` (for Proxmox nodes). Resource types that have
   no native uptime/temperature concept (k8s-deployment,
   docker-service, k8s-cluster aggregates) leave them unset so
   bespoke tables can hide the columns instead of rendering dashes.

   Live sample: agent rows now expose
   `uptime: 2592000, temperature: 78.95`.

2. **Docker Swarm services had no metrics at all.** Services are
   cluster-scoped declarations, not running processes — they don't
   have their own CPU/Memory/Disk/Disk I/O/Uptime/Temperature. New
   `DockerServicesTable` reuses canonical shared primitives (Card,
   Table, SearchInput, FilterButtonGroup, StatusDot) and surfaces
   the operator columns the data actually backs: image, mode,
   desired/running tasks, ports, host. Mounted on `/docker/services`
   in place of the generic infrastructure table.

3. **K8s Clusters tab was just metric bars.** Clusters are
   control-plane aggregates. Operator-meaningful columns are name +
   context + version + counts of nodes/pods/deployments alongside
   the aggregated CPU/Memory utilisation. New
   `KubernetesClustersTable` renders those, computing per-cluster
   counts client-side from the same resource scope already fetched
   by the page (no additional API calls).

`ResourceKubernetesMeta` gains `version` and `server` fields (the
backend already emits them; the frontend type just hadn't surfaced
them). `ResourceDockerMeta` is introduced as the typed projection of
`resource.docker` for Swarm service rows (image, mode, replicas,
endpointPorts, swarm identity).

Browser verification (Playwright, chromium, live mock-mode dev runtime):
- 9 tests pass. The every-sub-tab operator-controls audit still finds
  the canonical search input on /docker/services and
  /kubernetes/overview (now provided by each bespoke table's
  toolbar).

Targeted tests:
- `tsc --noEmit` clean
- `go test ./internal/unifiedresources/... ./internal/mock/...
  ./internal/monitoring/...` all green

Contract:
- `unified-resources.md` Extension Points: documents the top-level
  `Resource.Uptime`/`Resource.Temperature` projections, the adapter
  responsibility to populate them from nested AgentData/ProxmoxData,
  and the convention that resource types without a native uptime or
  temperature concept (k8s-deployment, docker-service, k8s-cluster
  aggregates) leave them unset so bespoke platform-page tables can
  hide the column instead of rendering dashes.

Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set
because this completes the column-fit audit started in 69f70a3fc —
no public contract shape changes (the new top-level fields are
additive optional projections of existing nested data; the two new
bespoke tables live inside features/ and reuse canonical primitives
only).
2026-05-16 12:36:57 +01:00
rcourtman 60e649a456 k8s(deployments): also synthesize DiskRead/DiskWrite rates so the I/O columns populate
The follow-up to 8fb141c0a: deployment rows still showed dashes for
the Disk I/O columns because `metricsFromKubernetesDeployment`
populated NetIn/NetOut but not DiskRead/DiskWrite. Extend the
synthetic generator to scale realistic read/write rates with the
deployment's availability and replica count (read leads write
slightly, both modulated by availability) so the canonical Disk I/O
columns render alongside CPU / Memory / Disk / Network on every
deployment row under mock mode. Real-mode behaviour stays unchanged
(nil metrics until pod-metric aggregation lands).

Live verification (`/api/resources?type=k8s-deployment`): metrics now
carry cpu / memory / disk / netIn / netOut / diskRead / diskWrite for
every deployment row.

Tests: `go test ./internal/unifiedresources/...` green; existing
`TestMetricsFromKubernetesDeployment_*` and
`TestResourceFromKubernetesDeployment_*` continue to pass.

Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set
because this extends the existing synthetic K8s deployment metrics
helper with two additional rate fields without changing any public
contract shape, ownership, or boundary; the canonical helper
position is already recorded in unified-resources.md from 8fb141c0a.
2026-05-16 12:11:29 +01:00
rcourtman 8fb141c0a4 k8s(deployments): synthesize deployment metrics in mock mode so the table stops showing dashes
The Kubernetes Deployments tab was rendering CPU / Memory / Disk as
"—" for every row because `resourceFromKubernetesDeployment` never set
the Resource.Metrics payload. Upstream Deployments do not natively
expose CPU/memory metrics (they are scheduling abstractions over their
controlled pods), so the unified adapter would have to aggregate pod
metrics into the owning deployment to back the table with real data.
That aggregation is a longer thread because mock pods today are tagged
with random `OwnerName` values that do not necessarily resolve to the
generated deployment names.

For the immediate platform-page quality fix, add
`metricsFromKubernetesDeployment(cluster, deployment)` to the
canonical metrics layer:

- Real-mode: returns nil (preserves current behaviour for live
  clusters; deployments still show dashes until owner-based pod-metric
  aggregation lands).
- Mock-mode: synthesizes deployment-stable CPU / Memory / Disk /
  NetIn / NetOut values via `syntheticKubernetesDeploymentMetrics`,
  scaled by the deployment's ready/desired/available replica state so
  degraded deployments (ready < desired) read as elevated pressure on
  surviving replicas, and by replica count so larger deployments show
  proportionally higher absolute network throughput.

`resourceFromKubernetesDeployment` now calls the helper inline.

Live mock verification: `/api/resources?type=k8s-deployment` rows now
carry populated `metrics.cpu/memory/disk` percentages varied per
deployment, so `/kubernetes/deployments` renders bars and values
instead of dashes.

Tests:
- `go test ./internal/unifiedresources/... ./internal/mock/...
  ./internal/monitoring/...` all green.
- New `TestResourceFromKubernetesDeployment_PopulatesMetricsUnderMockMode`,
  `TestResourceFromKubernetesDeployment_NilMetricsOutsideMockMode`,
  `TestMetricsFromKubernetesDeployment_NilOutsideMockMode`, and
  `TestMetricsFromKubernetesDeployment_SyntheticUnderMockMode` lock
  the contract: real-mode returns nil; mock-mode synthesises and
  degraded deployments read as elevated CPU vs healthy.
- Playwright (9/9) green against the live mock-mode dev runtime.

Contract updated: `unified-resources.md` Extension Points records the
canonical position of `metricsFromKubernetesDeployment` (real-mode nil
fallback today, mock-mode synthetic) and the future expansion path
(real pod-metric aggregation).
2026-05-16 12:05:23 +01:00