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.
The "Hosts" tab already rendered the hosts table, the containers
WorkloadsSurface, and the Swarm services table together, so the
separate Containers and Swarm services sub-tabs were strict subset
views of overview. Other platform pages (Proxmox, TrueNAS,
Kubernetes) use sub-tabs for genuinely different data per tab; the
Docker shape was the odd one out.
Drop PlatformSectionTabs from DockerPageSurface and render the
unified hosts + containers + services stack unconditionally. Remove
the now-dead DOCKER_TAB_SPECS / DockerPageTabId / DockerTabSpec /
buildVisibleDockerTabSpecs exports from dockerPageModel. The
DockerPage no longer interprets the URL segment, so legacy
/docker/overview, /docker/containers, /docker/services bookmarks
continue to resolve via the existing /docker/* wildcard route in
App.tsx.
The disconnected branch of ConnectionStatusBadge used the connected
state's min-w-6 h-6 group-hover:px-3 sizing, designed for a circle dot
that expands on hover. But showLabelByDefault() is true for any
non-healthy tone, so the label was rendered without horizontal padding
and spilled against the pill's right edge. Switch to py-1 px-2 to match
the warning and reconnecting branches, which already pad for an
always-visible label.
Extracts the per-disk Storage card introduced for the Docker host drawer
into a shared `DrawerDiskListCard` under `components/Workloads/` and
threads an optional `disks` prop through `NodeDrawer` →
`NodeDrawerOverview`. The Docker drawer now imports the shared component
instead of carrying its own copy, and `NodeDrawerOverview` renders the
shared card whenever the caller supplies two or more disks (falling back
to the existing aggregate Storage rows otherwise).
`ProxmoxNodesTable` is left untouched in this commit because the file is
currently dirty under another agent's lane; wiring the `disks` prop on
its `<NodeDrawer>` call site is a separate one-line follow-up once that
lane settles.
The previous Storage card surfaced only the aggregated host.disk values,
which on multi-drive hosts (Unraid in particular) is just the rootfs and
misses the data drives entirely. Drawer drill-down should be more
informative than the row's hover tooltip, not less.
When the host's agent reports multiple disks, the Storage card now spans
two columns and lists each disk with mountpoint, percent, used/total, and
a threshold-coloured bar that matches the StackedDiskBar tooltip palette.
Single-disk hosts keep the existing aggregate Usage/Total/Free rows.
Replace inline `as { current?: number }` / `as { usage?: number }` casts on
the memory union with a `getNumericField` helper, and drop the stray
`agent()?.connectionState` lookup (that field lives on vSphere metadata,
not the agent telemetry record) so the Connection row reads the host
status directly.
Clicking a Docker host row in the platform-page hosts table now expands an
inline drawer with Overview (System / Runtime / Containers / Memory /
Storage / Telemetry, plus Swarm context when present) and History tabs.
The History tab reuses GuestDrawerHistory against the host's agent
telemetry, so utilisation, network I/O, disk I/O, and thermals share the
same backend and rendering primitives as the Proxmox NodeDrawer.
Drawer placement (inline TableRow with colspan) and keyboard activation
mirror ProxmoxNodesTable so users get the same drill-down affordance on
both platform pages.
Pre-push tsc rejected the discovery test fixtures because ResourceDiscovery
has more required fields than the minimum needed to exercise the new
identified-service helper. Widen the vi.fn return type so
mockResolvedValueOnce accepts partial-record fixtures, and cast the
fixtures through `unknown as ResourceDiscovery` (matching the existing
pattern used for the ports/facts subobjects in the same suites).
Discovery records carry rich identification (service name, category,
ports, cli access, paths) but until now they only rendered inside the
Discovery sub-tab. Operators opening a workload drawer to look at memory
or backups had no signal that Pulse already knows the LXC is "Homepage
Dashboard" or the Docker container is "Proxmox Backup Server" — they
had to switch tabs to see it.
Add an "Identified Service" card to GuestDrawerOverview that shows the
service name, category, port count, CLI access hint, and confidence
percent. The card mirrors the existing System/Guest Info/Backup card
shapes so it slots into the same flex grid.
useGuestDrawerState passively loads the discovery record via getDiscovery
(no scan triggered — manual scans, progress UI, and approval prompts
stay owned by DiscoveryTab and useDiscoveryTabState). A new presentation
helper, getDiscoveryIdentifiedSummary, packages the record into the
display shape and returns null when the record has no meaningful
identification — same gate as the Discovery tab's hasValidDiscovery, so
both surfaces render or hide together.
Contracts: amend frontend-primitives Completion Obligations to fence the
new identified-service reducer to discoveryPresentation.ts, and amend
performance-and-scalability Current State to require that out-of-tab
discovery surfacing stay passive (no scans). Verification via
discoveryPresentation.test.ts and GuestDrawer.test.tsx covers the
populated/empty/null branches and the drawer card render.
Workloads table row surfacing is the natural next step but blocked
behind the parallel agent's in-progress WorkloadPanel/WorkloadsSurface/
useWorkloadsState changes; revisit after those land.
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.
Follow-up to the previous commit: resolveDiscoveryTargetForWorkload now
reads guest.containerId, so the Pick<WorkloadGuest, ...> input type must
include it or tsc rejects the property access.
For app-container workloads, resolveDiscoveryTargetForWorkload was
populating the discovery resourceId with guest.id — the frontend's
synthetic workload hash (e.g. "app-container-ed79032e5852577f"). The
backend wraps that as docker exec <id> sh -c '...', the Docker daemon
has no container by that name, so every scan came back with empty
stdout and the AI returned "Unknown Infrastructure Resource" at
confidence 0. To the user the Discovery sub-tab looked empty after a
"successful" run.
guest.containerId is already populated from resource.docker.containerId
for Docker-managed app-containers (useWorkloads.ts:450). Use it as the
discovery resourceId so the agent can actually docker exec into the
right container. VM and system-container branches were already correct
because they use vmid for pct exec / qm guest exec.
Fix the existing workloadTopology test that locked in the broken
behavior, and add containerId to the workloads.test.ts fixture that
exercises the same resolver branch.
The backend has had DiscoveryModel + GetDiscoveryModel() with the
shared-default fallback alongside ChatModel and PatrolModel, but the
settings UI only surfaced Assistant and Patrol overrides. Discovery
silently used whatever the shared default was set to, with no way to
dial it independently — yet Discovery is the surface where a cheaper
model (e.g. Haiku) is most often the right tradeoff.
Thread discoveryModel through the form state, load, save payload, and
failure analyzer, and render a third picker in the Model Overrides
section mirroring the Assistant/Patrol pattern. Generalize the section
header and shared-default copy so they no longer enumerate surfaces.
Extend the frontend-primitives contract to recognize Discovery alongside
Assistant and Patrol as a per-surface override, and lock the picker
presence into settingsArchitecture.test.ts.
Frontend canonicalizes Docker containers as app-container, but the
Discovery API only accepts docker. Without translation, every Discovery
trigger for a Docker container 400'd at type validation before any agent
dispatch — surfaced to users as "unsupported resource type 'app-container'".
Mirror the existing pod -> k8s translation in toDiscoveryAPIResourceType.
The agent transport layer already handles ResourceTypeDocker via "docker
exec" on the connected agent host; only the URL-path resource type needed
the alias.
The disk column was sized for the old "[bar] N% (used/total)" content
(~150-180px). With vertical micro-bars it only needs ~80-110px, so
the disk weight drops from 15 to 7 on desktop/compact (and the
proportional weights drop on mobile/tablet). Freed space redistributes
to Node/CPU/Memory/Cluster which all have more content to show.
Each vertical bar also gets a cursor-help cursor, a subtle ring on
hover, and a brightness/scale tick so users discover that bars are
individually inspectable — the native browser title tooltip on each
bar (disk name + percent + sizes) was already wired; this just makes
it visually discoverable.
Adds a 'vertical-bars' mode to StackedDiskBar and opts the hosts
table into it when a host has more than one disk. Each disk renders
as one 5px-wide vertical bar (fill height = utilization, color =
threshold), grouped and centred in the cell. All disks get identical
visual weight, no single disk is named or privileged, and 1-10 disks
all render the same way. Hover keeps the full per-disk breakdown.
Single-disk hosts still go through the original single-bar path.
The sub-row layout under each multi-disk host left the leading
columns (node/version/uptime/cpu/memory) blank on every disk row,
which fragmented the table visually and made the page feel empty
even when the disk content itself was readable. Going back to
single rows; the right fix needs more thought.
This reverts c0896fbc9 and 95758b6ef.
Previous version spanned from Disk all the way to Cluster, which gave
the per-disk bar far more room than it needs and visually fragmented
the table grid. Cap the colspan to Disk plus two right neighbours so
the sub-row sits in a sensible ~400px region, and fix the bar at w-20
instead of letting it stretch. Cts/Cluster columns now stay aligned
with the host row above.
The disk column was cramming N inline lanes into a ~197x16 cell, which
fell apart visually past three disks and forced label/percent collisions.
Hosts table is small (a handful of rows), so use the vertical space:
each multi-disk host gets one sub-row per disk beneath it, with the
sub-row colspan'd from the disk column through cluster so name, bar,
percent, and used/total all fit naturally. The host row's disk cell
collapses to a neutral "N disks" pill with a worst-threshold dot, so
nothing is hidden but no single disk is singled out by name.
The narrow-viewport card was rendering the row's status badge, problem
line, and Manage button as siblings inside a single flex-justify-between
row in the card footer. A long problem string ("Agent offline · 4h
ago", "Credentials invalid · 14 days") would squeeze the layout
unpredictably and push the Manage button into wrap territory.
Pull the problem line out of the flex row and onto its own line
beneath the status/Manage row, matching the wide-table treatment.
The SOURCE and ENDPOINT columns each carried a single static fact per
row (which collection method, which URL) that didn't help the user
scan for problems and that pushed the three columns the user actually
needed (System, Coverage, Status) into narrower lanes.
Fold source and endpoint into the System cell as a secondary line
beneath the name and subtitle: a small source badge inline with the
truncated endpoint URL. The cluster row, which has no endpoint, just
shows the source badge. Member rows follow the same pattern under
their indent.
New widths (System / Coverage / Status / Actions): 36 / 32 / 24 /
7rem at standard width, 34 / 34 / 22 / 7rem at xl. Section group
headers and lastError/empty-state rows colspan from 6/5 to 4/3.
Card layout (narrow viewport) is unchanged — it already stacks fields
vertically, not as table columns.
The agent-offline detail line was being produced by synthesizing a fake
FleetGovernanceSignal with key 'agent-attachment' and laundering it
through visibleFleetGovernanceSignals so the chip filter would pick it
up. With the chip stack gone and the row exposing a single
InfrastructureRowProblem, the synthesis is dead weight — it pretended
to be server-emitted governance state when it was really derived from
the agent's connection state.
- Replace agentAttachmentSignal with agentAttachmentProblem, returning
InfrastructureRowProblem directly. The row builder applies it ahead
of the fleet-highlights problem, so an offline agent always wins over
a downstream credential/config drift signal on the same row.
- Remove 'agent-attachment' from FleetGovernanceSignalKey now that no
signal carries it.
- Simplify the setup-summary Live/Needs attention counters to read off
the user-visible row state (statusLabel, problem) instead of
re-deriving via signal predicates. The previous path could
in principle disagree with what the row actually rendered.
- Drop rowFleetSignals, rowHasFleetTone, rowHasVisibleFleetTone — all
now unused.
Each platform overview page (Docker, Kubernetes, TrueNAS, vSphere)
composed its own <TableCardHeader title="Hosts" /> etc. independently,
so dropping the redundant title strips on Proxmox in the previous
commit didn't propagate. The architecturally correct fix is at the
primitive: the header strip only earns the chrome when it has real
content to show.
- TableCardHeader now renders null unless there's an `actions` slot or
an active `showClearAction`. Title-only is a silent no-op — every
platform-table caller stops showing a vestigial label automatically.
- `title` becomes optional on the props type so callers that intend to
use the strip purely for an action button (no caption) don't need to
pass a dummy.
- Title still renders when paired with actions, which keeps
StorageContentCard's "STORAGE / Storage|Physical Disks" header and
RecoveryProtectedInventorySection's "Protection coverage / Recovery
events" back-button strip working as before.
Net effect: Docker, Kubernetes, TrueNAS, vSphere overviews now match
Proxmox without touching any per-platform file, and future platform
tables can't accidentally re-introduce the strip by typing
title="Hosts".
TypeScript can't narrow FleetGovernanceSignal.tone through the find
predicates, so the helper now returns the literal tone for each branch
instead of forwarding the wider signal tone.