Until now, the same column (CPU/Memory/Disk) was aligned differently
depending on which table it appeared in: Proxmox top tables defaulted
to left, Docker/TrueNAS/vSphere/Kubernetes top tables used right, the
shared workloads bottom table used center, and integer-count columns
(VMs/CTs/Containers/Datastores/Desired/etc.) were a mix of right and
center. The user's read: "complete hobbledy codge."
Codify the rule once and for all. The new
frontend-modern/src/features/platformPage/columnAlignment.ts defines
five canonical column kinds with a documented align mapping and the
rationale for each:
- 'name' left primary identifier
- 'text' left readable strings
- 'metric-bar' center bar fills cell, label rides at fill edge
- 'numeric-value' right units/digits line up for column scanning
- 'badge' center pill/icon-only cells
sharedPlatformPage exposes getPlatformTableHeadClassForKind() and
getPlatformTableCellClassForKind() wrappers; every platform top table
plus the Docker services table now consume these instead of passing
literal align strings. The Proxmox host column model carries a kind
field instead of a raw align so the same source of truth drives both
the model file and the JSX.
KubernetesClustersTable.tsx is intentionally not migrated in this
commit: another agent has it staged in their working tree. The same
migration applies once that lands; left for a follow-up.
The WorkloadTableHeader (shared bottom guest-list table) is also
unmigrated here. It center-aligns everything today, and changing it to
canonical means flipping Uptime / Net I/O / Disk I/O from center to
right across every guest list on every platform page — bigger visual
blast radius, so it gets its own scoped commit next.
A canonical-platform-audit rule and a CLAUDE.md/AGENTS.md note will
follow in a third commit, so any future agent that writes a
non-canonical align fails the pre-push hook rather than discovers the
drift visually weeks later.
Kubernetes, vSphere, TrueNAS, and Proxmox all rendered the same
single-axis "+ Filter" combobox the Docker overview just lost, leaving
the page surfaces visually inconsistent. The unified workload search
(workloadSearchCandidates in workloadSelectors.ts:52-70) already covers
node, namespace, contextLabel, clusterName, platformType and
containerRuntime — i.e. every axis the dropdown carried on these pages
— and each surface's search placeholder advertises that coverage
("Search pods by name, namespace, image, cluster, or node",
"Search VMs and LXCs by name, VMID, node, or status", etc.). Suppress
hostFilter and namespaceFilter on all four surfaces so the workload
toolbar is consistently chip-only: Status, Runtime (where applicable),
Group by, Metric display.
Same equal-width problem on /kubernetes/nodes: the Nodes table (9
columns) and the Deployments table (7 columns) both ran under
table-fixed with no per-column desktop widths, so longer values like
runtime "containerd://1.7.20" and capacity "6 cores / 51.0 GB"
truncated, while the four integer-count Deployments columns (Desired /
Updated / Ready / Available) each held 183px for a single-digit value.
Set explicit md:w-[...] on every TableHead so the longer-content
columns (Node, Runtime, Capacity, Deployment, Namespace) get the room
they need, the CPU and Memory bars get a balanced share each, and the
short text and integer-count columns trim to fit their headers and
values. Mobile widths are unchanged.
Leaving KubernetesClustersTable.tsx alone for now: another agent has
it mid-edit. Same fix pattern can be applied to it once their work
lands.
After promoting Runtime to a chip toggle, the "+ Filter" combobox
carried only the Node scope. The Docker workload search box already
matches against host identity candidates (docker.hostname,
agent.hostname, identity.hostname, canonicalIdentity.hostname and
aliases — see filterDockerHosts), so narrowing by host name is
already a search keystroke away. Same-named hosts can be disambiguated
by typing the host's id suffix. Dropping hostFilter from the Docker
page surface only, since other workload surfaces (notably Proxmox
where many nodes are not always visible in a top table) have different
ergonomics and should be evaluated separately.
Same equal-width problem as the platform overview tops: this table sat
on table-fixed with no per-column desktop widths, so all 7 columns
rendered at the same ~183px. Image references like
"registry.example.com/backend-deluge:2025-..." got truncated while the
1-digit Desired and Running columns held the same allocation.
Set explicit md:w-[...] on every TableHead. Service and Image take the
biggest shares (22% and 25%) for their long content, Mode / Desired /
Running trim to short text and integers, and Ports / Host get a middle
slice. Image now renders its full registry path at 1440px instead of
truncating. Mobile widths are unchanged.
The promoted Runtime chip group rendered with a verbose "All runtimes"
label and lowercase, dot-less docker/podman pills, breaking the visual
rhythm of the Status / Group by / Metric display chips it now sits
alongside. Map the runtime options to the same style as Status: concise
"All" for the empty value, capitalized "Docker" / "Podman" labels, and
leading dots in the canonical runtime badge colors (sky for Docker,
violet for Podman) so the chip palette echoes the runtime badges
rendered in the table rows.
The Runtime axis was hidden inside the "+ Filter" combobox under a
"Properties" optgroup with at most two real choices (docker, podman),
sitting next to Status / Group by / Metric display which are already
chip toggles. Render it inline alongside Status so the binary axis is
one click instead of two, and hide the filter entirely when fewer than
two distinct runtimes are present so single-runtime deployments are not
shown a degenerate All/<one-runtime> control. The leftover "+ Filter"
select now carries only the Node scope.
Same equal-width issue as the other platform overview top tables: 9
columns at ~147px each under table-fixed meant FQDN-style host names
(esxi-01.lab.local) and longer cluster names ("Production Cluster") sat
on the same width as the Datastores and VMs integer-count columns, and
the CPU / Memory bars couldn't show their inline "% (used / total)"
labels.
Set explicit md:w-[...] on every TableHead so the Host column gets 18%
(comfortable for FQDNs), CPU and Memory share 13% each as parallel bar
metrics, Cluster / Datacenter / Power / vCenter take what their text
content needs, and Datastores / VMs trim to fit their headers plus
1-2 digit counts. Memory now shows the full size info ("71% (57.2 GB/
80.0 GB)") at 1440px instead of just the percentage. Mobile widths are
unchanged.
Same equal-width issue as the Docker and Proxmox top tables: table-fixed
across 11 columns with no per-column desktop widths gave every column the
same ~120px slice. That squeezed the Version field (where
"TrueNAS-SCALE-24.10.2" is a normal 21-char value) and clipped the size
labels on the CPU / Memory / Storage bars, while the four integer-count
columns (Pools / Datasets / Disks / Apps) sat on 120px each holding
single-digit values.
Set explicit md:w-[...] on every TableHead so the three bar columns get
balanced shares (CPU 11%, Memory 12%, Storage 13%), Version and System
each get 14% for their longer mono-spaced and hostname content, and the
four count columns trim to what their headers need (Pools/Disks/Apps
~5-6%, Datasets 7% for the longer header). Memory and Storage bars now
show their full "% (used / total)" labels at 1440px instead of hiding
the size portion. Mobile widths are unchanged.
The husky pre-commit hook unconditionally ran the full release-control
governance audit chain (`governance_stage_guard.py`, `control_plane_audit.py`,
`status_audit.py`, `registry_audit.py`, `contract_audit.py`, the
`go test ./internal/repoctl` package test, the readiness assertion guard,
and 15 Python helper unit tests) on every single commit, regardless of
what was actually staged. For a one-file frontend TS change, that meant
~15-20 minutes of audits the change couldn't possibly affect.
That cost is amplified by Pulse being driven by multiple agent harnesses
in parallel (Codex lanes, Claude Code interactive sessions, Anti-Gravity).
Each agent's commit serialized behind the same unrelated suite, even
when none of their staged files touched governance.
The hook already had `staged_files_match` and was using it to gate the
golangci-lint and `npm run lint` blocks. Apply the same pattern to the
governance block: run it only when the staged set actually touches
governance-relevant paths (docs/release-control/, scripts/release_control/,
internal/repoctl/, any .go file, go.{mod,sum,work,work.sum}, VERSION,
or the husky hooks themselves so hook changes still validate).
Frontend-only commits now finish in roughly the time the sensitivity
check, gitleaks scan, and `npm run lint` need (tens of seconds), rather
than tens of minutes. Governance-touching commits are unaffected; the
full audit chain still runs whenever any path in the gating regex is
staged.
Smoke-tested the regex against 13 representative paths (frontend TS,
README, internal docs JSON, release_control py, repoctl go, generic go,
go.mod/go.sum, VERSION, .husky/pre-commit, .husky/pre-push). All
classifications matched the intended skip/run behavior. Bash syntax of
the modified hook validated with `bash -n`.
The Docker overview top table was running on table-fixed with no
per-column desktop widths, so all 10 columns rendered at the same ~132px.
That gave CPU / Memory / Disk bars the same allocation as one-digit
badge columns like "Containers" or short-text columns like "System",
and the bars couldn't show their inline "% (size / size)" labels at all.
Set explicit md:w-[...] on every header so the three bar-metric columns
share an equal generous slice (14% each), the Host name column gets
headroom for full hostnames, and the short-content columns (Version,
System, Uptime, Temp, Containers, Swarm role) trim to what their labels
and values actually need. Mobile widths are unchanged.
Disk shared half the weight of CPU and memory while rendering the same
kind of usage bar, so the disk meter was squeezed and the over-allocated
version/uptime/temp/cluster columns wasted space the node column needed
for longer cluster-style names. Equalize CPU/memory/disk to the workloads
table convention (140px each), give node headroom, and trim the
short-content columns to what their labels and values actually need
across desktop, tablet, and mobile layout modes.
(Recommits the change intended for bd18c0f5e, whose message accidentally
landed against an unrelated Kubernetes diff due to a parallel-agent
commit race; that earlier commit was left intact rather than rewritten.)
Disk shared half the weight of CPU and memory while rendering the same
kind of usage bar, so the disk meter was squeezed and the over-allocated
version/uptime/temp/cluster columns wasted space the node column needed
for longer cluster-style names. Equalize CPU/memory/disk to the workloads
table convention (140px each), give node headroom, and trim the
short-content columns to what their labels and values actually need
across desktop, tablet, and mobile layout modes.
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)
The workload-filter search at the top of each platform overview page now
also narrows the Hosts / Nodes / Clusters tables above the workloads
table, mirroring how Docker filters its hosts table from the same input.
Typing "pve1" on the Proxmox overview now reduces the Nodes table to
matching rows; typing a cluster or namespace fragment on Kubernetes
narrows clusters/nodes/deployments. Status / view / runtime filters
remain scoped to the workloads table (they don't apply to nodes).
Adds a shared `resourceMatchesSearch` helper in src/utils that walks the
common Resource identity fields (name, displayName, hostnames, cluster
and namespace names, tags, canonical aliases).
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.
Move the WorkloadsFilter to the top of the Proxmox, Kubernetes, TrueNAS,
and vSphere overview surfaces so every workload page renders the filter
bar above its tables, matching the Docker pattern. The mid-page filter
placement was inconsistent across pages and pushed the search input
below the fold on busy hosts/nodes/clusters tables.
Each page lifts useWorkloadsState to its overview component, renders
WorkloadsFilter directly, and passes the state into WorkloadsSurface
(removing showFilterToolbar and the filter* configuration props from
the surface). Filter scope is unchanged — it still governs the embedded
workloads table only.
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.
PlatformGovernanceState narrowed to 'supported' | 'presentation-only'
once vSphere left the admitted set, so the badge-label helper's
'admitted' branch and the dropped governanceState='admitted' summary
call are unreachable. Hardcode currentAdmissionPath to [] and drop the
unreachable 'admitted'/'first-lab-ready' branches; the function still
accepts the wider type so it stays valid if a future platform is
re-admitted.
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.
Drop the duplicate Disks and Apps tabs that just remounted tables
already rendered in the Overview stack. Rename the default tab from
"Systems" to "Overview" so the section label matches the stacked
content (systems + pools + disks + apps). The Workloads filter inside
Overview owns app search/grouping, and the Storage tab continues to
provide the full StorageSurface (pools + disks toggle, with toolbar)
for richer storage exploration.
Also dress the TrueNAS systems table CPU/Memory columns with the
canonical bar primitives (ResponsiveMetricCell + StackedMemoryBar) so
the Overview stack reads as one consistent surface alongside the
Docker / Proxmox / vSphere / Kubernetes host tables.
Drop the duplicate Nodes / Pods / Deployments tabs that just remounted
the same tables already rendered in the Overview stack. Rename the
default tab from "Clusters" to "Overview" so the section label matches
what the tab actually shows (clusters + nodes + pods + deployments).
The Workloads filter inside Overview owns pod search/grouping; the
standalone tabs added zero navigation value.
Also dress the Kubernetes nodes table CPU/Memory columns with the
canonical bar primitives (ResponsiveMetricCell + StackedMemoryBar) so
the Overview stack reads as one consistent surface alongside the
Docker / Proxmox / vSphere host tables.
Drop the duplicate Virtual Machines tab that just remounted the embedded
Workloads table from Overview. Rename the default tab to Overview and
swap the Storage tab from the bespoke VsphereDatastoresTable to the
canonical StorageSurface (forcedSourceFilter=vmware-vsphere,
forcedView=pools), matching how Proxmox handles its Storage tab and
avoiding the Pools/Physical-disks toggle that previously stranded
operators on an empty-disks state.
Also dress the vSphere hosts table CPU/Memory columns with the canonical
bar primitives (ResponsiveMetricCell + StackedMemoryBar) so the Overview
stack reads as one consistent surface alongside the Docker and Proxmox
host tables.
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).
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.
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.
Switching to List view used to light up the Reset/Clear all button
even though no chip rendered for it, conflating a view-option
preference with active filter state. Grouped/List is now treated like
the other view options (bars/trends, charts toggle, columns): no
contribution to Clear all visibility, no reset by Clear all.
Match the canonical LXC badge that appears throughout the workloads
table and other Pulse surfaces: just "LXC", no trailing VMID. The
VMID stays available via the badge tooltip and the host drawer for
operators who need it, but the at-a-glance scan signal is the
hostname column, not a duplicated identifier in the System pill.
The LXC Docker host badge was hand-coded as amber, but amber is the
canonical color for Pods (workloadTypePresentation.ts). The
canonical LXC color is green, matching the system-container
workload badge that appears throughout the rest of the UI. Import
the workload presentation class rather than hardcoding the green
tokens so the badge tracks the single source of truth.
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.
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.