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.
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.