The v5-parity audit found data reaching the browser and rendering
nowhere, same disease as the #1471 disks table:
- Per-container status (state, reason, readiness, restarts, image) was
only ever aggregated into x/y ready counts; v5 listed each container.
- Node OS image / kernel / architecture and the capacity vs allocatable
budget (cores, memory, pods) had zero consumers. For nodes without a
linked Pulse host agent the generic host section reads agent data
only, so the OS identity surfaced nowhere at all.
- The cluster API server endpoint was searchable but rendered nowhere;
pendingUninstall reached the browser and no surface used it.
Add a Kubernetes section to the resource detail drawer (pods: QoS +
per-container rows; nodes: OS identity + capacity/allocatable +
cordon state; clusters: API server / context / agent version / pending
uninstall), reusing the platform-neutral section shape introduced for
TrueNAS. Show pod capacity in the nodes table Capacity column and badge
pending-uninstall clusters in the clusters table.
The agent collects QoSClass and the legacy models carry it, but
resourceFromKubernetesPod dropped it, so no v6 surface could render
what v5's pod expansion showed. Additive optional field; proof added
to the kubernetes registry ingest test.
Found by the v5-parity audit of the Kubernetes page.
Every v5 NodeSummaryTable column header sorted; the v6 rebuild shipped
static headers. Restore click-to-sort on all columns except the Web
link: identity/text columns default ascending, metric columns default
descending, repeat clicks flip then clear back to the snapshot order,
and missing values (offline metrics, absent temperatures) stay last in
either direction. Flagged by the Proxmox overview parity audit.
The v6 nodes table dropped four glanceable signals its v5 ancestor
(NodeSummaryTable) carried: alerting rows lost their red/yellow accent
and background tint, pending apt updates lost the escalating badge in
the name cell, uptime was rounded to whole days, and offline nodes
rendered at full opacity. Flagged by the Proxmox overview parity audit.
- Wire getAlertStyles over the websocket alert store (alerts key on the
canonical unified resource id) and reuse the workload-row accent CSS
through a host-row variant.
- Pending-updates badge (orange at >=10, yellow below) beside the name.
- Full '26d 4h' uptime via the shared formatUptime; <1h warning kept.
- opacity-60 on offline rows, matching guest-row stopped dimming.
The v6 unified-resource node shim never read the PVE URLs the backend
ships (proxmox.guestUrl / proxmox.host): guestURL came from a
nonexistent customURL field and host carried the bare node name, so the
workloads group-header link resolved relative to the SPA
(/proxmox/pve1 in a new tab) and the nodes table had no link at all.
v5's dedicated link column (NodeSummaryTable) was silently dropped in
the rebuild. Flagged by the Proxmox overview parity audit.
- Map proxmox.guestUrl into guestURL with the proxmox.host API URL as
fallback in nodeFromResource; host stays a hostname label because the
node drawer renders it as one.
- Add getNodeExternalUrl: only URL-shaped values become link targets,
bare hostnames fall through to the canonical https://<node>:8006.
- Point NodeGroupHeader at the helper.
- Add a Web column (Machines-table pattern) to the Proxmox nodes table
at compact and wide layouts.
The v6 alerts history regressed against its v5 ancestor (same audit
pattern as the physical-disks table in 809e2c900, flagged from the
alerts-surface parity audit):
- The Type column silently swapped which-alert-fired (cpu, backup,
powered-off) for the unified resource kind (VM, LXC); the humanized
alert-type label was computed into HistoryItem.title and never
rendered. Render title in the Type column, colored by resource kind
with the kind as hover title - the exact v5 scheme.
- The #1218 stable id tiebreaker was dropped from both the overview
and history sorts, so alerts fired in the same polling cycle scramble
on re-render. Restore it in useAlertOverviewState and
applyAlertHistoryWindow, with regression tests.
- Duration, Status, and Node were hidden below lg with no recovery
path; v5 kept all columns reachable via horizontal scroll. Un-hide
them and give the table the v5 min-w-[800px] inside the shared
scroll wrapper.
- Drop the Source column: buildAlertHistoryItems only ever emits
source 'alert', so the badge was identical on every row (the 'ai'
Patrol merge never happens). Remove the dead presentation helper;
the source field stays on the model for the action-button guards.
- Treat LXC like CT/Container in the resource-kind badge palette
(live mock showed LXC rows falling to the gray bucket).
The canonical Kubernetes adapter reports every unhealthy-but-alive state
(cordoned node, not-ready container, pending pod, partially available
controller) as `warning`, but the platform page's filter triad only
accepted degraded/paused, so those rows matched none of the Healthy /
Degraded / Offline chips and were findable only under All. Flagged by
the v5-parity audit: v5's Unhealthy filter classified all of these.
- Accept `warning` into the page's degraded filter bucket.
- Declare `unschedulable` on ResourceKubernetesMeta (the adapter has
projected it all along) and map ready-but-cordoned nodes to an amber
Unschedulable indicator instead of green Ready. NotReady still wins.
- Render the mapped status label in the pods table status column. The
raw phase reads Running while a container is in CrashLoopBackOff;
the reason was previously visible only as the dot's hover title.
- Add `warning` to the frontend ResourceStatus union to match what the
backend emits.
OpenCode shows how full the model's context is next to the composer;
Pulse showed raw token counts with no sense of scale. The done event
now carries context_limit_tokens (providers.ContextWindowTokens for
the selected route) and the drawer's last-turn summary renders input
tokens as a percentage of the window: '8,500 tokens (6% of context)',
with the full breakdown in the hover detail. Without the limit the
summary renders exactly as before. Pinned in the plain-answer corpus
scenario.
Every Ceph-backed storage appeared twice on the pools table: once as the
PVE storage entry (online, mounted capacity) and once as the
cluster-internal pool row synthesized from Ceph telemetry (degraded, raw
pool accounting) - same name, conflicting state and usage, no visual
relationship. Found by the v5-parity audit; v5 rendered one row with a
cluster-health badge.
consolidateCephClusterPoolRecords now collapses the synthesized pool
rows (type ceph on the cluster pseudo-node) into the PVE rows that
mount them, matched by backing-pool detail or name. The pool row's
worse health is lifted onto the survivor (state cell, status detail,
issue summary) so a degraded cluster stays visible on the table, and
unmounted pool rows are kept so standalone-monitored clusters do not
lose their only capacity row. Raw per-pool accounting stays on the
Ceph tab's cluster drawer. Registry synthesis and pool alerting are
untouched.
Also pins the zfsSummary devices field in useStoragePoolDetailModel's
test, missed by b53e1a3e0.
While the model writes tool arguments the pending row showed only a
generic placeholder plus 'Receiving tool input.' for what can be tens
of seconds on slow routes. OpenCode streams the forming call into the
mutable tool part; Pulse now shows the live tail of the raw args in
the pending row whenever they are still unparsable, and drops it the
moment the input parses into a real summary or command preview. The
raw input was already carried by tool_progress events; this only
renders it.
After a tool round, the next model turn can reason server-side for tens
of seconds without streaming a single event. The footer kept the last
tool's status counting up ('Reading inventory. (40s)') while the model
was already composing the answer — observed live on the OpenRouter
reasoning route. OpenCode always shows a thinking state here; Pulse now
emits a model_processing workflow state at the start of every post-tool
turn ('Working on the response with the gathered results.'), which
model_thinking upgrades when reasoning deltas actually arrive.
Pinned by a new interaction-quality corpus scenario.
The unified-resource adapter flattened models.ZFSPool to four scalars
(state + R/W/C totals), so scan status and the per-device report never
reached the frontend. The storage detail's ZFS card, the usage bar's
scrub/resilver overlay and ZFS tooltip, and ZFS-device-to-disk matching
were all dead code on the unified path: a degraded pool rendered as a
one-word State cell with no way to see which device failed or that a
resilver was running. Found by the v5-parity audit of the pools table
(same pipeline disease as #1471 on the disks table).
- Add StorageMeta.ZFSPool and populate it in resourceFromStorage
(normalized copy, not aliased).
- Serialize it in monitorStoragePlatformData so the /api/state path
carries it alongside the existing flat scalars.
- Map it through normalizeStorageMeta and the storage record adapter
(meta first, flat platformData fallback).
- Render the per-device report (name, vdev type, state, R/W/C errors,
message) in the detail's ZFS Pool card - the v6 home for what v5
showed via the health-map dots and the degraded-pool warning row.
- Pin the end-to-end path in code_standards_test and update the
unified-resources, monitoring, storage-recovery, and
frontend-primitives contracts.
Verified live in mock mode: degraded pool now shows the resilver scan,
pool error totals, per-device states, and the bar pulse overlay.
The tab was gated on replicationChanges, recentChanges keyword-matched
for replication/replica. No production path emits such a change: change
emission produces generic reasons, ChangeKind has no replication value,
and no replication alerts exist. So when 2315d8330 removed the
hasPveEstate clause the tab became unreachable on every estate,
including ones with active pvesr jobs (rc.6 shipped this way). The
backend was intact throughout: monitor_pve_replication.go collects and
/api/replication/jobs serves; only the gate was dead. The vitest that
covered the gate passed by fabricating a change reason ("Replication
job completed") that nothing in internal/ generates.
Replication deliberately bypasses the unified-resource pipeline, so the
tab is now gated on its canonical source instead: ProxmoxPageSurface
owns the jobs fetch, shows the tab when jobs exist, and feeds the same
data to a now-presentational ProxmoxReplicationTable.
Also restore v5's Next sync column (countdown with overdue/imminent
tones), which the v6 rebuild dropped while the payload kept carrying
nextSyncTime/nextSyncUnix, and type the *Time fields as the RFC3339
strings the Go model actually serializes. The page model keyword
machinery and its fabricated-change test are removed; tab visibility
tests pass the job count explicitly.
Session titles were the truncated first prompt, so the picker filled
with 'Reply with exactly: OK' and five identical 'hows esphome' rows.
OpenCode background-generates a short title after the first exchange;
Pulse now does the equivalent: once the done event has reached the
client, the chat runtime asks the configured chat model for a <=50-char
title from the first user message and reply, then renames the session.
- Runs only on the first exchange; any customized title is left alone,
with a re-check before persisting to lose races against a concurrent
rename. Failure or timeout keeps the placeholder.
- Title requests pass the model-boundary sanitizer like normal turns
and record usage as assistant_session_title.
- Synchronous after done (no user-visible latency, no goroutine racing
service teardown), bounded at 20s for slow reasoning routes.
Verified by tests end-to-end (hook through Rename); live verification
on a real provider route pending while the dev backend sits in mock
assistant mode, which intercepts turns before the agentic path.
Nodes that set up SSH temperature monitoring before v6.0.0-rc.6 have an
authorized_keys entry locked to `sensors -j`, so the pulse-sensors wrapper
never runs and SMART (SATA/SAS) disk temperatures silently never arrive;
only NVMe shows via kernel hwmon (what the #1471 reporters hit). The server
already detected the legacy payload but only logged it.
- models.Temperature gains LegacySensorsFormat, set by parseSensorsJSON
when the payload is raw sensors -j, and preserved across the host-agent
merge; it rides the existing temperatureDetails projection on unified
node resources.
- New PlatformOutdatedSensorSetupNotice on the Proxmox page, data-gated:
collection succeeded AND payload was legacy AND the node has SATA/SAS
disks without temperatures, so nodes whose disk temps arrive via a host
agent stay clean. Copy points at re-running the node setup script, which
replaces the locked key entry and installs the wrapper.
- monitoring.md contract extended with the legacy-format marker behavior.
The v6 physical disks table regressed against its v5 ancestor: device
path was demoted to a tooltip (identical models became indistinguishable),
SSD wearout vanished from the surface entirely despite flowing all the way
into the presentation layer, the Belongs column rendered empty for every
disk without a ZFS pool mapping, and a Source badge spent a column saying
PVE on every row. Flagged from the side-by-side screenshots in #1471.
- Add a Device column (mono devPath, md+).
- Add a Life column rendering wearout with v5-style thresholds (md+).
- Fall back Belongs to Proxmox's usage string (ZFS / ext4 / BIOS boot)
when no pool mapping exists; searchable via the existing haystack.
- Demote the Source badge to the row expansion header next to the
serial; drop the table column.
- Render missing temperatures as muted dashes instead of green ones
(getTemperatureTextClass(0) returned the healthy tone).
- Tighten rows from 38px to 32px to match the platform table standard.
A preview/tooling Vite instance on a non-default port gets its
WebSocket upgrades rejected by the backend's dev origin allowlist
(http://localhost:5173,7655). PULSE_DEV_PROXY_ORIGIN rewrites the
proxied Origin header to an allowed one; unset, nothing changes.
The dedup landed in isRenderableStreamEvent, but that predicate only
feeds the has-any-events gate; the model_switch Match rendered on its
own condition, so live turns still showed the duplicate row. Extract
shouldRenderModelRouteEvent, use it at the Match itself, and compare
display labels instead of raw ids — the done event rewrites
message.model to a provider-reported id that can differ in raw form
while reading identically to the user.
The liveness handler called SchedulerHealth(), which refreshes provider
instance caches under the monitor's write lock and snapshots queues and
breakers on every probe — it only needed the dead-letter count. Add
Monitor.DeadLetterCount() (queue-local mutex only) and use it.
The hot-dev watchdog killed the backend after 2 missed 3s probes; on a
dev box running race-detector test suites and builds that is routine
CPU starvation, and each kill destroyed any in-flight Assistant chat
stream (observed repeatedly during the chat-feel audit). Default the
miss threshold to 4 (~20s) — genuine wedges still die quickly.
Every assistant turn rendered a 'Using <route>' status row that repeats
the route badge in the turn header, so a restored session read as a
column of duplicate route chips. Keep the row only when it adds
information: the header has no route attribution, the selected route
differs from the turn's final model, or a later mid-turn switch makes
the starting route part of the story.
The completed tool row measured from tool_start, which providers emit
when the model begins streaming tool arguments. On slow reasoning
routes that ran minutes ahead of execution: the live audit showed
'QUERY completed 2m 8s' for a tool the backend executed in under a
second, billing the model's thinking time to Pulse's tools.
Track runningAt (first 'running' phase, which the agentic loop emits at
actual execution) on pending tools, carry it onto the completed stream
event, and prefer it for the duration stamp. The live pending row keeps
counting from arg-streaming start because its phase label ('Receiving
tool input') correctly names what is being timed.
The retest-comment workflow fired on 'reopened' as well as 'opened', and
canPostRetestComment gated on the issue author's association, not the
reopen actor's. Since non-collaborator reporters cannot reopen
maintainer-closed issues, a reopened event is in practice always a
deliberate maintainer decision, and the bot would post version-retest
boilerplate that contradicts the maintainer's own comment while planting
the needs-retest auto-close marker on an issue they chose to keep open
(seen on #1471).
Restrict the trigger and the guard to 'opened' and pin the reopened skip
with a test.
The availability map computes a fall-through reason string for every
command whether or not it is disabled, and commandWithAvailability
copied it through unconditionally. Enabled menu items then displayed
false statements: '/fork ... Forking is already running.', '/copy ...
Requires transcript content.' on a session with a full transcript.
Carry the reason only when the command is actually disabled.
Live audit against the OpenCode bar found two ways the Assistant could
pin a permanent 'Generating response' footer with nothing in flight:
- loadSession left isStreaming undefined on restored messages, which the
activity dock reads as 'still streaming' (isStreaming !== false). Any
restored session whose transcript ends in an assistant message showed
a phantom active turn; new prompts then queued behind it forever and
stop could not recover. Restored messages are now always settled.
- A stream that closed without a terminal event (backend restart, proxy
drop) synthesized a silent 'done', pretending the turn finished; and
the 300s inactivity ceiling meant a dead connection counted up under
'Generating response' for five minutes. The close now surfaces as a
recoverable interruption error, and inactivity trips at 30s, backed
by the backend's 5s SSE heartbeats.
GET /api/ai/sessions fully re-parsed every session transcript on every
call, roughly three times over (handoff refresh List, per-session action
loads, final List). On a long-lived install the endpoint took tens of
seconds, pegged the CPU, and the dev watchdog kill-looped the backend.
- SessionStore.List() now serves summaries from a per-file cache
validated by (modTime, size); only changed files are re-read, with a
raw-skip decode that never deep-parses message payloads.
- The cache persists as .sessions_index.json next to the session files,
so the first list after a restart is index-speed too (18ms on a 662MB
legacy directory vs 23s before).
- writeSession/Delete keep cache and index coherent; external edits
self-heal via the (modTime, size) check.
- ListSessions refreshes handoff action status only for the 20 newest
sessions that carry actions, matching what the recent-sessions UI can
surface; the send path already refreshes the active session on load.
- ai-runtime contract updated: listing-cost clause and bounded-refresh
clause replace the unbounded refresh promise.
A full local go test run on 2026-06-11 failed pkg/metrics while vite
builds were saturating the machine. Reproduced under synthetic 8-core
load: only the TestSLO_* wall-clock p95 assertions failed, never the
functional tests. A latency budget measured on a shared dev machine
cannot distinguish host contention from a code regression; load storms
inflated medians up to ~4x with no code change, so no absolute
threshold separates the two.
Route the latency SLO assertions (pkg/metrics, internal/api,
internal/monitoring) and the load/stress perf assertions (internal/api)
through helpers that keep strict enforcement on GitHub Actions runners
(controlled environment, existing hosted-runner envelopes unchanged)
and skip locally on overrun, printing the full p50/p95/p99
distribution in the skip message. A local pass still means the budget
was genuinely met. Error-response and correctness checks remain hard
failures. CI -race runs are unaffected: these tests already skip under
the race detector.
Verified: pkg/metrics, internal/api, and internal/monitoring all pass
with 8 CPU burners saturating the host, the scenario that previously
turned TestSLO_RollupTierBatchedFleet and four other budget tests red.
TestLogHandlers_HandleStreamLogs_SendsEvents raced the handler
goroutine against a broadcaster write and a 10ms sleep. The global
broadcaster is shared with every concurrently running test and drops
messages to full subscriber channels, so under CI load the test's
line could be dropped and the body stayed empty; it never failed
locally because the flood only exists during a full package run on a
slow runner (first CI execution of the Go steps in ~100 runs exposed
it).
Write the line into broadcaster history first and run the handler
synchronously with a pre-cancelled context: Subscribe replays history
before the live select loop, so subscription, SSE formatting, and
context cancellation are all exercised with no goroutine, no sleep,
and no dependence on concurrent traffic. Live-channel delivery stays
covered by the broadcaster's own package tests.
Two failures surfaced once CI reached the Go steps for the first time
in ~100 runs:
- TestParityStorageFields predates models.Storage.LastSeen (e2a036ce2,
zero = never seen, no fabricated stamps), so the fixture ingested as
never-seen and LastSeen() was honestly zero. Stamp the fixture like
the real pollers do and assert exact round-trip, matching the docker
host parity test.
- internal/api alone takes ~10m under -race on a fast machine, so the
10m per-binary budget set when the package was smaller times out on
CI runners. Raise to 25m. The package has no sleep-debt (all test
sleeps are milliseconds); it is legitimately heavy.
The bundle-size checker groups dist chunks by basename, so the lazy
AI Chat chunk (src/components/AI/Chat/index.tsx) and the SetupWizard
chunk (index.ts) were summed into the entry's 'index' budget line.
The budget breach that surfaced once the unit tests were fixed was
that conflation, not an entry regression: since the Jun 1 baseline
(d2639fada) the real entry grew 81.4 -> 90.7 kB gzip (+11.5%, within
the 15% threshold) while the lazy Chat chunk grew 22.2 -> 61.8 kB
with the deliberate Assistant UX modernization.
Name dynamic-entry chunks after their facade directory (Chat,
SetupWizard) so 'index' means only the real entry, and refresh the
baseline to record current per-chunk truth, including Docker's
deliberate platform-page growth (15.9 -> 18.8 kB).
Mock fixtures never set LastSeen on PVE nodes, VMs, LXC containers, or
storage. The registry used to paper over that by replacing zero sightings
with ingest time; since 53faa4e46 preserves zero ("never seen") and stamps
those sources "unknown", mock mode rendered its whole PVE estate with dash
last-seen and unknown source freshness.
Stamp sightings in updateFixtureStateMetricsAt, which runs at generation
and on every refresh tick, before the RandomMetrics gate so static-metrics
fixtures stay fresh too. Online nodes and everything they host get the
refresh time (a poll delivers its full inventory, stopped guests
included); anything on an offline node keeps its old stamp, with zero
backdated ten minutes so the UI shows a stale sighting rather than
"never".
workloadSummaryStatusIsRunning treated every status except StatusOnline as
not running, so a guest at StatusWarning landed in the Stopped bucket.
Warning is an attention state on a running workload: statusFromGuest maps
degraded guest states to it (power-off maps to StatusOffline), and since
53faa4e46 the registry stale pass degrades online resources with stale
source sightings to it as well. That stale pass surfaced the bug as
provider-backed (VMware) VMs counting Total:1 Running:0 Stopped:1 in
TestContract_WorkloadChartsUseCanonicalWorkloadIDsForProviderBackedVMs and
TestHandleWorkloadsSummaryCharts_UsesCanonicalWorkloadIDsForVMwareVMs,
which seeded sightings older than the VMware stale threshold. Both pass
again with warning counted as running.
26c7ca910 added the Pulse for MSPs entry to docs/README.md and the
deprecated PORT alias row landed in docs/CONFIGURATION.md without
re-syncing the shipped copies under frontend-modern/public/docs,
which the docsLinks vitest contract enforces byte-for-byte.
Four Assistant refactors (3f8525a7c, 3c3ca42c2, 29ebaf788, 1fc36a790)
moved code to new owner files without updating the source-contract
tests, and the hooks never run vitest, so CI has been red since Jun 4:
- formatIdentifierLabel moved from MessageItem.tsx to activeTurnStatus.ts
and from ToolExecutionBlock.tsx to toolPresentation.ts; the boundaries
contract now asserts on the new owners (ad-hoc-regex guards on the
components remain).
- The model-selector empty state moved to shared AIModelPicker.tsx;
the contract follows it and keeps the no-hardcoded-copy guard on both.
- useGuestDrawerState's discovery query was prettier-wrapped across
lines; the workloads contract now uses a whitespace-tolerant toMatch.
- ModelSelector's empty-name fallback deliberately renders a humanized
'Anthropic: Claude Test' route label since the OpenCode alignment;
the test expectation now matches the shipped behavior.
resourceFromStorage and resourceFromDockerContainer stamped LastSeen with
time.Now() at conversion because their source models carried no poll
timestamp. The registry rebuilds from the retained state snapshot every
cycle, so those resources re-reported a fresh sighting each rebuild even
after their upstream source (PVE instance, docker host agent) stopped
delivering, and their per-source SourceStatus could never go stale via
markStaleLocked. 53faa4e46 fixed this fabrication at the ingest layer but
left these two adapter-level stamps.
- models.Storage gains LastSeen (omitzero), stamped where entries are
built: the PVE storage poll (poll start time, including synthesized
cluster-shared entries; preserved entries for unpolled nodes keep their
old stamp), the PBS datastore conversion (PBS instance sighting), Ceph
pool projection (cluster LastUpdated), and the mock generator (offline
mock nodes get a backdated stamp so the stale path renders).
- resourceFromStorage passes storage.LastSeen through; zero stays zero
("never seen") instead of becoming conversion time.
- resourceFromDockerContainer uses host.LastSeen: containers are delivered
wholesale with each host report, so the host report timestamp is the
container sighting. This matches every other docker sub-resource adapter
(services, tasks, volumes, networks, images already use host.LastSeen).
- ingestStorage routes PBS-poller datastore entries (instance "pbs-<name>",
type pbs) to SourcePBS, parented to the PBS instance. Keying them
SourceProxmox would judge their freshness against the 60s Proxmox stale
threshold while PBS polls every 60s by default, flapping healthy
datastores stale between polls; SourcePBS carries the cadence-matched
120s threshold. PVE-reported pbs-typed storage.cfg backends stay
SourceProxmox. Side effect: syncUnifiedStorageMetrics no longer skips
PBS datastore storage, so those entries gain usage history.
- storageFromReadStateView round-trips LastSeen so the legacy storage API
reports the honest sighting; mock refresh re-stamps available storage on
each simulated poll.
The parent host/node staleness was already honest, so platform pages
reflected outages at the parent level; this makes the per-resource source
freshness honest too.
Ingest stamped every resource's SourceStatus "online" and replaced a zero
LastSeen with time.Now(), so a source that never delivered a resource
(synthesized offline PVE placeholders, never-polled instances) reported a
fresh sighting and a healthy source forever: markStaleLocked skips zero
LastSeen entries, so the stamp was permanently exempt from stale-marking.
That fabricated freshness fed monitor previous-state reads (the
resurrection loop mitigated in 8372a22c5), the frontend lastSeen display,
the monitored-systems ledger, and AI/agent context.
- ingest/mergeInto/IngestResources/presentation coalesce: stamp per-source
delivery status from the actual sighting ("unknown" when the source has
never delivered, "online" otherwise) and preserve zero LastSeen instead
of substituting ingest time.
- replaceRegistry: run MarkStale over the fully assembled registry. The
IngestSnapshot stale pass ran before record sources (TrueNAS, VMware)
were ingested, leaving them stamped "online" at any age.
- monitorLastSeenUnix: report 0 for never-seen instead of time.Now(), so
the frontend renders a dash rather than "just now".
- connected infrastructure: clamp zero LastSeen to 0 ms instead of the
negative UnixMilli of the zero time.
preserveOrExpireNodes keeps requiring a real online sighting for grace;
the registry no longer manufactures the timestamps that made that guard
necessary, so the 8372a22c5 failure mode is impossible by construction.
A configured PVE instance that was already down when Pulse started had
no node state, so the unreachable-poll path returned without writing
anything and the instance was invisible on the Proxmox overview until
its first successful poll. Synthesize offline node entries from the
instance config in that case: cluster configs get one row per
discovered member endpoint with the same node ID a live poll would
assign, standalone instances get a single row named after the
configured instance. The next successful poll replaces them wholesale.
Also stop the offline grace policy from resurrecting such placeholders.
Previous node state round-trips through the unified registry, which
stamps zero LastSeen values with the ingest time, so a never-seen
placeholder came back "recently seen" every cycle and flipped to a
permanent online/degraded state. Grace now requires a real online
sighting: a nodeLastOnline entry from this process, or a previous
snapshot that itself reports the node online with a recent poll
timestamp.
Verified live on the dev stack: a synthetic instance pointing at an
unreachable TEST-NET address appears as an offline node row on the
Proxmox overview immediately after a fresh-state backend start and
stays offline past the grace and stale windows.
Fixes#1433
The only surface explaining an unresolved v5→v6 license migration was an
inline notice buried in Settings → Billing → Plan, so a paying customer
whose migration stalled discovered it as silently missing Pro features.
Mount a global banner in the app shell (same slot as the demo and update
banners) whenever commercial posture carries a pending or failed
commercial_migration state. It reuses getCommercialMigrationNotice for
copy, links to the licence panel, dismisses per session and per state
(a pending→failed transition resurfaces it), re-checks posture every
minute while pending so the backend's background retry clears it
without a reload, and loads commercial posture itself — the app shell
only loads posture when upgrade prompts are enabled, and a migration
warning is service degradation, not an upgrade nag.
Verified with vitest (component + presentation suites) and live against
the dev instance with an undecryptable license.enc fixture.
Two silent-downgrade paths in the v5→v6 migration are now visible and
self-healing:
License load/decrypt failure (was: one log line, no UI state): when
license.enc exists but cannot be read, getTenantComponents now persists
a terminal commercial_migration state with reason
persisted_license_unreadable, so the licence panel and global banner
tell the customer to re-enter their v5 key instead of leaving them to
discover missing Pro features.
Failed startup exchange (was: once per process, Community until manual
restart): exchange failures classified as pending — license-server
blips, DNS failures, rate limits — now schedule a background retry loop
with backoff (30s → 30m cap) that re-attempts the exchange until it
succeeds, hits a terminal classification, or the org's service activates
through another path. The loop stops cleanly on manual activation and
StopAllBackgroundLoops.
Registers the new files in the subsystem registry (cloud-paid
commercial-migration verification policy + shared ownership) and pins
both behaviors in the cloud-paid and api-contracts contracts.
v5.0.0 derived the license.enc AES key as sha256("pulse-license-" +
material) where material was the raw /etc/machine-id file content —
trailing newline included — with hostname and a fixed dev fallback as
alternates. v6 trims machine-id before hashing and dropped both
fallbacks, so licenses activated on v5.0.0 (before .license-key shipped
in v5.0.1) could never be decrypted and the December 2025 lifetime
cohort silently booted as Community.
Collect the raw v5 key materials at construction and try them verbatim
as legacy sha256 candidates during decrypt, alongside the existing
trimmed HKDF/legacy derivations. The write path is unchanged: persistent
random key + HKDF only. Pins the cloud-paid persistence contract to the
full v5.0.x compatibility-loader material set.
Go half of the licensing-policy client work from the v6 upgrade sweep
(license findings 5 and 6); the copy and docs landed in 3a51429a5 and
the license-server side lands in pulse-pro.
The grant refresh loop treated every HTTP 401 as revocation and wiped
the licence and activation file immediately, but the server returns 401
for token-not-found and token-expired as well as revocation, so a
single spurious 401 dropped a paying customer to Community until a
successful re-exchange. Activation state is now cleared only when the
401 carries an explicit revocation code (TOKEN_REVOKED,
INSTALLATION_REVOKED, LICENSE_REVOKED); any other 401 keeps the licence
and retries, letting the grant's 72h expiry plus the 7-day grace window
govern degradation. The bump_license_version immediate-refresh path in
the revocation poller had the same any-401 wipe and gets the same
guard.
ClassifyLegacyExchangeError now maps a 409 MAX_INSTALLATIONS exchange
error to failed/exchange_installation_limit with the
free_installation_slot action instead of the retryable
pending/exchange_conflict state, since retrying can never succeed until
a slot is freed server-side. Other 409s keep the pending conflict
semantics.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
Canonical IDs for merged-source hosts (PVE node + pulse-agent) were minted
from whichever identity keys the creating record happened to carry: the
agent record knows the machine ID, the Proxmox node record only knows
cluster+hostname. The registry rebuilds from scratch every tick, so a boot
window where the agent had not checked in yet minted a cluster-keyed ID
(agent-7a62... for delly) while steady state minted a machine-keyed one
(agent-bdd4...). Every restart re-ran the race, fragmenting the
resource_changes journal into per-boot eras (9.4k vs 6.1k rows for the
same host) and silently truncating report availability and UI timelines.
Fix, in the layer that owns identity:
- Persist identity pins (canonical_id <-> machine_id/dmi/cluster/hostname)
in the previously schema-only resource_identities table, written by the
store-backed registry after monitor-adapter rebuilds, diff-aware so
steady-state ticks cost no writes.
- Complete weak incoming identities from the pins before matching and ID
derivation, so a node-only boot window derives the same machine-keyed
canonical ID as steady state. Derivation itself is unchanged; ephemeral
nil-store registries behave exactly as before.
- Expand change-journal reads (Get/Count families, SQLite and memory) to
the full era set recomputed from the pinned identity keys, healing
historical journals at query time with no row migration. Reads keyed by
a stale era ID resolve to the same merged timeline.
Regression tests cover both ingest orders, restart simulation via the
monitor adapter, era ID derivation, and era-merged journal reads on both
store implementations. Contracts updated: unified-resources obligation 25
(durable identity pins), monitoring obligation 10 (adapter rebuild
persistence).
Docs and frontend half of the licensing-policy client work from the v6
upgrade sweep (license findings 5 and 6); the Go classifier and grant
refresh fix land separately once the shared pre-commit guard unblocks.
A 409 MAX_INSTALLATIONS during v5 key migration rendered as 'another v6
activation handoff is still settling. Retry activation from this
instance.' Retrying can never succeed until a slot is freed server-side,
so that copy stranded users. The notice now says the key is already
active on its maximum number of v6 installations and points at
support@pulserelay.pro to free a slot, via the new
exchange_installation_limit reason and free_installation_slot action.
docs/UPGRADE_v6.md gains a 'Breaking Change: Paid Licensing Requires
Connectivity' section: v5 validated keys fully offline, v6 needs
periodic connectivity to license.pulserelay.pro, with roughly 10 days of
offline tolerance (72 hour grant plus 7 day grace) before paid features
drop to Community, plus air-gap guidance and the migrated-key
installation cap. docs/releases/V6_CHANGELOG.md gains the matching
breaking-change bullet.
Note: the two frontend files also carry two small additive hunks from
the parallel persisted-license-unreadable slice (a copy case and its
test, inert until its Go side lands); they share these files and are
included to avoid splitting them mid-flight.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
Two GA-blocking bugs in the auto-update install path:
The pulse-update.service unit was rendered through an unquoted heredoc
containing $${PULSE_SERVICE_NAME}, which bash expands to the installer's
PID, so the unit shipped with ExecCondition checking a service like
"24757{PULSE_SERVICE_NAME}". The condition always failed and systemd
silently skipped every scheduled run, for fresh installs and upgrades
alike. The heredoc now renders the detected service name directly:
ExecCondition=/bin/sh -c 'systemctl is-active --quiet pulse'.
Upgrades also never refreshed the updater assets: a v5 box with
auto-updates enabled keeps pulse-update.timer and autoUpdateEnabled=true,
so the update/reinstall flows never re-ran setup_auto_updates and the
v5.1-pinned /usr/local/bin/pulse-auto-update.sh stayed in place, logging
"Already running latest version" forever instead of selecting v6
releases. The asset-install half of setup_auto_updates is now a shared
install_auto_update_assets(), and a new refresh_auto_updates() rewrites
the helper script and units unconditionally whenever the timer already
exists, without touching system.json or the timer's enabled state. All
five install flows (update, reinstall, --version, --source, fresh) are
wired.
Tests now render the real unit and execute the rendered ExecCondition
against a recording systemctl stub instead of asserting source-text
fragments, pin refresh_auto_updates behavior (stale helper replaced,
system.json and enablement untouched), and pin the call-site wiring.
The deployment-installability contract records both invariants.
Note: rewritten message only — the original local commit carried a
parallel agent's licensing commit message due to a shared-index race;
the tree is byte-identical to the hook-verified original.
v5 documented PORT as a legacy alias for FRONTEND_PORT. v6 dropped the
fallback silently, so an install configured with PORT=8080 upgrades to
v6 and comes up on 7655 with no explanation, unreachable behind its
existing port mapping or reverse proxy.
Honor PORT again at lowest precedence (FRONTEND_PORT wins when both are
set) and log a deprecation warning at startup, mirroring the existing
legacy DISABLE_AUTH handling. Document PORT as deprecated in
CONFIGURATION.md and note the compatibility behavior in the v6
changelog.
Two regressions surfaced by generating a real-mode agent report after a
backend restart:
- Report subject lookups used Monitor.GetUnifiedResources (the raw
resource store), but the raw store's canonical IDs depend on per-boot
ingest order for merged-source hosts: after a restart the same host
resolved to a different agent-<hash> than the one the UI and
/api/state advertise, so reports lost the resource name, availability,
and metrics translation entirely. Subject enrichment now reads
Monitor.UnifiedResourceSnapshot and MetricsTargetForResource resolves
through GetUnifiedReadStateOrSnapshot first (raw store as fallback) -
the same re-ingested registry every other read surface uses.
- The performance summary card grid positions cards absolutely and never
paginated: an agent host reporting 8+ metric families walked off the
page bottom, fought fpdf's auto page break, and scattered one orphan
element per page (a 7-day delly report rendered 18 pages, ten of them
near-blank). The grid now starts a new page before a row that will not
fit; the same report renders 9 pages with intact cards.
Verified live: real-mode delly report shows name, 288 data points,
availability, charts, and correctly paginated cards. The underlying
canonical-ID instability (raw store vs re-ingested view, and the
resource_changes journal fragmenting across boot eras) is a separate
root issue tracked for its own fix.