a0113b964 moved AlertResourceIncidentsPanel out of HistoryTab and into the
row that asked for it, but left two source-boundary assertions pinning the
old page-level placement:
frontendResourceTypeBoundaries.test.ts:2796
Alerts.helpers.test.ts:429 and the exact-JSX pin at 434
The panel move is correct, so the assertions are what is stale. At page level
the panel opened thousands of pixels above a reader scrolled into the history
and the per-row Resource button read as inert (#1687); the fix mounts it under
the originating row, gated on that row owning the open request so exactly one
panel is open at a time.
Retarget both pins onto AlertHistoryTableAlertRow, where the panel now lives,
and turn the HistoryTab pin negative so a regression back to page-level
placement fails the boundary rather than passing it. The rowKey gate is pinned
alongside the mount because the gate, not the mount, is what makes the button
land in view. The phone card list keeps its behavioural coverage in
__tests__/AlertHistoryMobileList.test.tsx, so it is not duplicated here.
Verified: both boundary suites green (59 tests), full alerts feature suite
green (59 files, 960 tests), prettier clean.
restoreProviderMSPArchiveFile copied each tar entry with an unbounded
io.Copy, so the only limit on what a restore wrote to disk was the size
the archive declared for itself. A gzip bomb, a PAX sparse entry claiming
a huge logical size, or a corrupt stream could fill the target volume.
Bound extraction the way readProviderMSPBackupManifestBytes already
bounds the manifest: a per-entry cap and a cumulative cap across the
whole restore, both enforced against the bytes actually copied rather
than the declared header size. That size comes from the archive, so it
is only good for an early reject, never as the bound. An entry that
overruns fails the restore and its partial file is removed, rather than
being silently truncated into a file that looks complete.
A restore that failed partway had already deleted whatever it replaced,
leaving a half-populated control plane that looks bootable and forcing
the retry to use replace. Roll the partial restore back to an empty
target instead, and say so in the error.
Follows the zip-slip fix in the same function (CodeQL alert 314).
SummaryRowActionButton disclosure buttons and the FilterBar inline select
were resized to 44px mobile touch targets (h-11/min-h-11 with sm:
compact overrides) in the mobile interaction sweep, but these two pins
still asserted the old desktop-only sizes.
25ba5fd63 added the SignPath Foundation acceptance date and the
SignPath.io attribution block to docs/CODE_SIGNING_POLICY.md but did
not mirror it into frontend-modern/public/docs, so the docsLinks
shipped-docs sync test failed. Copy the repo doc over the stale
shipped copy.
Dependabot alert 147: quadratic CPU in js-yaml's !!omap resolution
(same weakness as CVE-2026-59870, unpatched in the 4.x line until
4.3.1). Dev-scoped transitive dep via @eslint/eslintrc; lint suite
run green on 4.3.1.
Contract-Neutral: dev-scoped transitive js-yaml 4.3.0->4.3.1 security bump (Dependabot 147, GHSA-5p4m-2wfm-xmqj); lockfile-only, no contract delta
The #1601 follow-on: per-container alert overrides were keyed by Docker
container ID (docker:{host}/{containerID}), which changes on every
recreate, so each image update silently re-armed alerts the user had
switched off and left a dead entry behind in alerts.json — the unbounded
growth that pushed the reporter's config past the old 64KB body cap
(raised in 38434a513). The v6 thresholds UI additionally wrote keys from
the unified hash id (docker:{host}/app-container-{16hex}), which the
evaluator never read at all.
Overrides now key on stable identity, docker:{host}/{containerName}:
- The evaluator resolves the name key first and falls back to the legacy
container-ID key so pre-migration entries keep working
(evaluateDockerContainer, checkDockerContainerState, the
container-update resolver, and reevaluateActiveAlertsLocked).
- MigrateDockerContainerOverrideKeys runs in the monitor sync next to
MigrateCanonicalOverrideKeys, driven by the unified resource snapshot:
it re-homes live legacy-ID and unified-hash keys onto the name key and
prunes orphaned ID-shaped entries, ending the per-update orphan
accumulation. Name-keyed entries for absent containers are kept so a
recreate under the same name still honours them.
- The UI candidate chain (single implementation in alertOverridesModel)
now leads with docker:{host}/{name} and trails the container-ID, short
ID, unified-hash and slash-tail forms, so rows bind pre-existing
overrides of every historical shape and the next save re-homes them.
Rows carry overrideStorageId/overrideIdCandidates so toggle,
connectivity, offline-state, edit and remove all write the stable key.
- The ignored-containers card copy now documents the wildcard forms
(runner-*, *-dev, *staging*) shipped in b5fa6a9af, under the title
"Ignored container patterns".
Contract deltas: alerts, frontend-primitives, monitoring, and
unified-resources now pin the name-keyed override identity, the single
frontend candidate-chain owner, the sync-cadence migration, and the
resource-facet-backed table identity respectively.
Verified live against a mock instance: a UI toggle persists
docker:{host}/loki and binds back after reload, and seeded
legacy/hash/orphan keys converge to name keys on disk within two sync
ticks. go test ./internal/alerts/... ./internal/monitoring/... green;
recreate survival pinned in
TestDockerContainerOverrideSurvivesContainerRecreate.
Refs #1601
Pure `prettier --write` output, no semantic change. These files were all
last touched by commits made after the 2026-07-20 sweep (d89e3e316) from
worktrees where the pre-commit formatter silently skipped, so they landed
unformatted and `make format` on a clean tree kept re-reporting them.
Verified this is drift and not a formatter version change: prettier 3.9.5
(the tracked lockfile) and 3.9.6 (installed) flag exactly the same 14
files, and today's prettier finds zero differences across all 2099 source
files at d89e3e316 -- so prettier's output is unchanged since the sweep.
`prettier --check` over frontend-modern/src is now clean, which makes
`make format` a genuine no-op on a clean tree again.
Contract-Neutral: prettier-only reformat of already-committed files; zero token changes, no public-contract delta (verified: staged content is byte-identical to prettier(HEAD content) for every path)
A `make format` sweep re-lays-out already-committed files without
changing a single token, so it cannot change what renders. The browser
verification guard still demanded a fresh receipt for it, which would
mean recording routes, viewports, states and interactions nobody
exercised in order to describe a diff with no visual delta. A guard that
can only be satisfied by an untrue receipt teaches people to write
untrue receipts.
Exempt a path only when its new content is byte-identical to prettier's
output for its committed content. That is provable, not a judgement
call: if the two match, the sole difference from HEAD is layout.
Fails closed everywhere else -- added or deleted files, unreadable
blobs, prettier missing, or any non-identical output all fall through
and still require the receipt. A reformat that also changes a value is
covered by a test and still blocks.
The pre-commit frontend formatter resolved prettier at
REPO_ROOT/frontend-modern/node_modules/.bin/prettier, where REPO_ROOT is
derived from the script's own path. In a linked worktree that is the
worktree root, which never runs npm install, so prettier_bin() returned
None and the formatter silently returned 0. Every frontend commit made
from a Claude or Codex agent worktree skipped formatting entirely, and
the drift accumulated in already-committed files until someone ran
`make format` and picked up 14 files of unrelated churn.
The skip path's comment claimed "CI's prettier check still catches drift
that slips through here." No such check existed -- nothing under
.github/workflows referenced prettier -- so there was no backstop at all.
- Fall back to the primary worktree's node_modules, resolved via
`git rev-parse --git-common-dir`, and say so in the hook output so a
version mismatch between the two checkouts stays visible.
- Add the whole-tree "Check frontend formatting" step to the frontend CI
job that the comment already promised. Staged-only formatting cannot
see drift in untouched files; this can.
- Pin prettier exactly. "^3.3.0" let the tracked package-lock.json land
on 3.9.5 while the gitignored pnpm-lock.yaml resolved 3.9.6, so CI and
the dev machine were free to run different formatters.
- Resolve the test suite's prettier the same way, so its two real
coverage tests stop silently skipping in worktrees.
Contract-Neutral: devDependency prettier version pin plus pre-commit/CI formatter tooling; no runtime, API, or deployment-surface delta
The PVE and PBS branches of handleCanonicalAutoRegister each carried a
verbatim copy of the identity-matching ladder — exact host, then TLS
fingerprint conflict rejection, resolved host identity, then DHCP
continuity by node name plus token. golangci-lint's dupl flagged the pair.
Extract one matcher, findCanonicalAutoRegisterMatch, over a
canonicalAutoRegisterCandidate identity view that both instance types
project onto. Behavior is unchanged: the same ladder in the same order,
the same log messages and fields, and the same (index, preserveHost)
result, with the previous "break with preserveHost still false" paths
becoming explicit returns.
golangci-lint run ./... green; full go test ./internal/api/ green.
Contract-Neutral: dupl-only refactor of handleCanonicalAutoRegister: PVE/PBS identity-matching ladder hoisted into one helper, zero public-contract or behavioral delta
Fixes#1685.
The "Last refresh" clock in the app shell was built with a hardcoded en-US
locale and hour12 true, so every reader saw a US 12 hour clock regardless of
their system settings. #1279 already fixed this once in App.tsx; the v6
rewrite that moved the logic into useAppRuntimeState.ts reinstated the
hardcoded form, and it has shipped that way since GA. The reporter spotted
the regression and named the original PR.
Swept the rest of the frontend for the same defect. One other site remained,
the alert history day group full-date label, which rendered "Thursday,
August 6, 2026" to a reader whose own rows are ordered day-month. Both now
pass undefined so the runtime resolves the viewer's locale and clock
convention.
Because this is a regression that already survived one fix, add a
canonical-shared/no-hardcoded-format-locale audit rule covering
toLocaleString, toLocaleDateString, toLocaleTimeString and the Intl
constructors, with an allowFiles escape for any call that genuinely needs a
fixed locale. Confirmed the rule fires on the exact reintroduced regression
and passes once reverted.
Verified in an en-GB browser: the footer now reads 20:52:55 rather than
8:52:55 PM, and the history day header title reads "Thursday, 6 August 2026".
Contract-Neutral: Contract deltas staged where the change actually lands: frontend-primitives.md gains the shared date/number formatting-locale rule and names the audit that enforces it, and alerts.md extends the existing timestamp paragraph to the day group label. Residual demands are inapplicable. cloud-paid.md names useAppRuntimeState.ts for hosted org-context bootstrap and licence boundaries and performance-and-scalability.md names it as an app-shell performance boundary; grepped both and neither documents date formatting, and swapping a locale argument moves neither boundary. The alerts frontend surface proof is the same fixed policy list as a0113b964 and does not name alertHistoryModel.branchcov.test.ts, which is the file that actually covers this model; editing an unrelated listed proof would be fabrication.
The vSphere adapter filled `Resource.Tags` with six fixed strings on every
resource — `vmware`, `vsphere`, `<kind>`, `source:vcenter`,
`connection:<name>`, `power:<state>` — and never read vCenter's own tag and
category system. Every VM in an estate returned a byte-identical set, so the
workload Tags column rendered the same dots on every row and filtering on any
of them selected everything. Commit 6b78feba8 default-hid the column and said
in as many words that the hide was a stopgap awaiting this fix.
`internal/vmware/client_tags.go` reads the CIS tagging service. That is a
different endpoint family from the `/api/vcenter/...` inventory reads, but the
same vSphere Automation API, so it reuses the caller's `/api/session` token
rather than opening and managing a second session. Associations come from one
batched `list-attached-tags-on-objects` POST per bounded object batch, never a
per-object request; tag and category names resolve through a client-scoped
catalog with a 10-minute TTL, so a steady-state refresh of a tagged estate
costs only the association reads while a rename still converges without a
restart. A vCenter without the tagging service, or an account without the tag
read privilege, degrades into a `tags` stage enrichment issue and leaves the
inventory untagged; it never fails the refresh.
The provenance strings stay. `Resource.Tags` is the only keyword set
`resourceSearchMatch.ts`, the `?tags=` resources filter, and saved
report-schedule tag filters read — `collectSearchCandidates` gathers no
`technology`, `type`, or `platformScopes` candidate — so dropping "vmware" or
"vsphere" would silently stop matching searches and saved filters that depend
on them. Real vCenter labels are appended to that set, never substituted for
it.
Because that flat set is deliberately mixed, it is the wrong source for a
per-row Tags cell. Real tags therefore also land on a canonical `VMware.Tags`
facet that carries vCenter's category alongside each name, and
`useWorkloads.ts` maps `WorkloadGuest.tags` from that facet for any resource
carrying VMware metadata — including the empty case, so a vSphere VM nobody
tagged renders an empty cell instead of falling back to the provenance dots.
vCenter tag names are unique only inside their category, so the flat label is
`category:name`: two categories may each hold a "Production".
With the column carrying per-row meaning again, `tags` leaves
VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS and the `defaultHiddenMigrationIds`
retirement list, and the state-model test that pinned the stopgap now pins its
absence. No un-hide migration ships alongside it: 6b78feba8 is on main but no tag
contains it, so the stopgap never shipped and no install carries the
auto-hidden preference. That holds only while the two stay together — the
migration writes the hide into each user's saved preference on first load,
so an rc cut from main carrying the stopgap without this commit would make
an explicit un-hide path necessary.
Mock fixtures carry uneven tag coverage — several categories on some objects,
one on others, none on the rest — because a uniform fixture set would hide
exactly the defect this data exists to catch.
Verified against a mock estate built from this branch: `/api/resources`
returns provenance plus real labels on the flat set and only real labels on
`vmware.tags`; the Tags column renders 2-4 dots per tagged VM and none for
untagged ones; a dot's tooltip reads `Backup:Nightly`, and clicking it
searches `tags:Backup:Nightly` and narrows 18 VMs to the 3 that carry it.
Contract deltas: performance-and-scalability.md Extension Point 17 replaces
the stopgap paragraph with the two-surface tag contract and the bounded
tag-read budget; unified-resources.md states the keyword-union vs facet split
and that a present-but-empty facet means "no operator tags" rather than a
fallback; storage-recovery.md extends its VMware descriptive-only boundary to
`vmware.tags`, because vCenter tag vocabularies read like protection policy
(`Backup:Nightly`) and a label the operator wrote must never satisfy a
coverage or compliance verdict that recovery-owned evidence should decide.
Resolves every open code scanning alert on the repository. Dependabot and
secret scanning were already clear.
SMART temperature truncation (alerts 312, 313). parseRawValue returns a
64-bit raw attribute value, but DiskSMART.Temperature is an int, which is
32 bits wide on the 386 and arm release builds Pulse ships. The range check
ran after the narrowing conversion, so a raw value of 4294967316 truncated
to 20 and was published as a plausible 20 degree reading.
validSMARTTemperature64 now gates the conversion.
Provider MSP restore archive names (alert 314). cleanProviderMSPArchiveName
rejected a leading "../" but not a bare "..", which path.Clean produces from
entries such as ".." and "a/../..". pathIsInside caught the escape
downstream, so this was not exploitable, but the sanitizer now rejects it
outright instead of depending on a second gate.
TrueNAS device paths (alert 315). vdev.Device is supplied by the appliance,
concatenated into a path and published verbatim on ZFSDevice.Path, so values
like "//evil.example.com/share" and "/\evil.example.com" passed straight
through. devicePath now drops traversal segments and backslashes and
collapses a leading double slash. The alert's open-redirect framing does not
apply here, there is no redirect sink on this path, but the value is
untrusted input rendered as a path and is worth normalising.
Patrol readiness cache key (alert 311). The key is persisted to
ai_patrol_model_readiness.json and embedded an unkeyed SHA-256 of the Ollama
Basic Auth username and password. That password is chosen by a human, so
anyone holding the evidence file could recover it offline at two SHA-256
operations per guess. The fingerprint is now HMAC-SHA256 keyed with a
32-byte per-install salt stored beside the evidence at mode 600. Credential
rotation still invalidates the cache and the key still survives a restart.
Each fix carries a regression test confirmed to fail against the previous
implementation.
monitoring.md carries the one warranted contract refinement. It already
required SMART temperature selection to accept only plausible readings, and
that rule now states the width at which plausibility is decided.
Contract-Neutral: CodeQL security fixes with no public-contract delta and no payload change. monitoring.md carries the one warranted refinement (SMART plausibility decided at 64-bit width). Residual demands are inapplicable: ai-runtime readiness prose documents interruption semantics, not cache-key derivation, and the credential-invalidation contract is unchanged; cloud-paid and deployment-installability contracts never name archive-entry sanitisation; agent-lifecycle owns smartctl.go but its SMART temperature prose lives in the staged monitoring.md.
Fixes#1687. Also addresses the locale half of #1685.
The per-row Resource button in Alerts > History rendered its panel as a
page-level sibling in HistoryTab, between the filters card and the table.
Reproduced at 1280px: scrolled 3200px into the history, clicking the button
opened the panel 2595px above the top of the viewport. The only visible
effect was the row list shifting down as the panel was inserted above it, so
the button read as dead and the reporter could not reach the incident detail
or the absolute timestamps it carries.
Render the panel inline under the row instead, matching the neighbouring
Timeline button, in both the desktop table and the phone card list. Because
several alerts can share one resource, the panel state now carries the
originating rowKey and each row renders it only on a match, which also keeps
exactly one panel open at a time. Re-triggering the same row closes it.
The resource resolver moves from a HistoryTab prop onto the history state.
useAlertHistoryState already receives getResource; re-exposing it avoids
threading the lookup through the table section, group row, alert row and
mobile list now that the panel mounts in four places rather than one.
Row timestamps were built with a hardcoded 'en-US' locale while the rest of
this feature already formats through the viewer's locale, so a European
reader saw "05:19 AM" for 05:19. They also showed clock time only, with the
date available just in the day group header, which scrolls out of sight in a
long history. Both surfaces now format through the viewer's locale and carry
the full absolute date and time as a title, with the formatters owned by the
history state so table and mobile cannot drift.
Verified against mock data at 1280px and 390px: the panel opens in view
under the clicked row and card, a non-owning row renders nothing, timestamps
render 23:50 with a "Thursday, 6 August 2026 at 23:50:18" title across all
98 rendered rows, and there is no horizontal overflow at phone width.
Contract-Neutral: Contract deltas staged in alerts.md (inline resource-incident panel placement, rowKey targeting, locale-aware row timestamps) and frontend-primitives.md rule 35 plus its prose (resource resolver moves from the tab prop chain to the history state). Residual demand is an 'alerts frontend surface proof' from a fixed policy list that does not name the three test files this change actually exercises: __tests__/useAlertHistoryState.test.tsx, __tests__/AlertHistoryMobileList.test.tsx and __tests__/HistoryTab.test.tsx, all staged with new assertions covering the panel placement, the toggle, and the timestamp title. Editing an unrelated listed proof file would be fabrication.
Rapid or double clicks on the desktop nav tabs, platform page subtab
rails, shared Subtabs buttons, and mobile nav tabs selected the label
text because those controls render as divs and anchors. Add select-none
to the shared class strings so tab labels behave like controls, not
copy. Data cells stay selectable on purpose.
Verified in-browser on /docker, /docker/images, /docker/networks and
/actions at 1280x800 and 375x812; double-clicks navigate without
selecting.
Contract-Neutral: UI-only styling change: adds select-none utility class to tab label class strings (nav tabs, platform subtab rail, shared Subtabs, mobile nav); no payload, API, or contract surface touched
Follow-up audit after the vSphere Backup fix, sweeping every platform page
and tab for columns that say the same thing on every row.
Avail (both Proxmox and vSphere, default-visible): the cell renders nothing
at all until an availability check is linked to that workload. Availability
checks are opt-in per resource, so any install without one showed an empty
column under an "Avail" header on every row. Gate it on live data via a new
`hasAvailabilityData` accessor rather than on a stored preference: writing a
hidden preference would overwrite the user's own choice the first time a
probe appeared. The column now returns by itself when data arrives, and the
accessor reads the unfiltered guest set so narrowing the table by search or
status never makes it vanish.
Tags (vSphere, default-visible): every VM rendered six dots carrying the
identical tag set, because `internal/vmware/provider.go` fills `Resource.Tags`
with fixed provenance strings (`vmware`, `vsphere`, `vm`, `source:vcenter`,
`connection:<name>`, `power:<state>`) rather than reading vCenter's tag API.
Five are constant across an estate and the sixth restates the power state the
status filter already owns, so filtering on any of them selects everything.
Default-hide it on the vSphere scope, with the same one-time migration used
for `backup` so existing preferences are retired too. Proxmox keeps the column
since its tags are genuine per-guest labels.
The Tags hide is a stopgap, not the canonical fix. vCenter does expose a real
tag/category system; the adapter simply does not read it. The tags stay in the
payload because search and facet counts consume them, and the hide should be
removed once the adapter ingests real vCenter tags.
Also checked and left alone: AI Context on vSphere reports the backend's own
`unsupported` discovery state as "N/A", which is honest and already
default-hidden. Docker, Kubernetes, TrueNAS, Machines and every Proxmox and
vSphere sub-tab had no single-value columns.
Contract-Neutral: Contract delta staged in performance-and-scalability.md Extension Point 17 (vSphere tags stopgap + the hasAvailabilityData gate). Residual demand is a Workloads hot-path perf proof; inapplicable because this only changes which columns are offered at render time and adds no per-row or per-frame work, so no hot-path proof file legitimately changes.
Bare entries keep their historical prefix semantics. Entries may now
also use the wildcard forms already established by the PBS datastore
excluder, so *-dev matches a suffix, *staging* a substring, and
runner-* an explicit prefix. Wildcard-only entries are skipped, that
job belongs to DisableAllDockerContainers.
Suffix matching is what the reporter on #1601 was approximating with
hundreds of per-container disable toggles, which is also what pushed
their config past the old request body cap.
Refs #1601
Contract-Neutral: extend docker ignored-prefix matching with wildcard forms; no schema or payload changes
Alert config grows one Overrides entry per toggled resource, so the
64KB cap on PUT /api/alerts/config rejected saves from instances with
a few hundred disabled containers with 'http: request body too large'.
The 32KB cap on bulk acknowledge/clear failed ack-all during large
alert floods, the exact situation it exists for. Intent policies carry
per-resource rules with the same scaling shape.
All four now share a 1MB bound, which still caps memory per request
but no longer rejects legitimate fleet-sized payloads. Endpoint tests
pin a >64KB config save and a >32KB bulk ack at 200.
Refs #1601
Contract-Neutral: raise alert config and bulk ack request body caps; no payload shape or field changes
The build-and-test run on main went red at 93e4b764f. Three pinned
assertions lagged intentional source changes and one shipped doc copy
lagged its repo source, accumulated across runs that skipped frontend
tests (docs-only commits and a cancelled run).
- statusBadgeModel.branchcov0713: mirror the min-h-11 / sm:min-h-0 base
class from 93e4b764f
- NotFound: settingsActionXs buttons now carry min-h-11
- alertThresholdDefaults: FACTORY_SNAPSHOT_DEFAULTS grew
warningSizeGiB/criticalSizeGiB when size thresholds moved from
Recovery to Snapshot Age
- sync public/docs/CODE_SIGNING_POLICY.md with docs/ after bbd6910f4
switched the workflow link to a relative path
Contract-Neutral: test pins and shipped doc sync only; no runtime or payload changes
The workload table's Backup column reads only `resource.proxmox.lastBackup`
(useWorkloads.ts), so on vSphere it renders "None" on every row forever.
d929ac647 added `backup` to VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS, but a
default only applies to users with no saved preference for that storage scope.
Anyone who had touched the Columns control on the vSphere page before that
commit kept the column and still sees a full column of "None".
useColumnVisibility already carries the mechanism for exactly this case: a
one-time default-hidden migration that hides a newly-defaulted column once for
users with an existing preference, and records a marker so a deliberate
re-show sticks. useWorkloadsControlsState was passing only `aiContext`, so
`backup` never migrated. Add it.
The migration is guarded on the id already being in that scope's
effectiveDefaultHidden set, so Proxmox, the only platform whose adapter
populates lastBackup, keeps the column visible with real backup ages.
Contract-Neutral: Contract delta staged in performance-and-scalability.md Extension Point 17 (defaultHiddenMigrationIds ownership). Residual demand is a Workloads hot-path perf proof; inapplicable because this adds one id to a startup-time default-hidden migration array and no per-row or per-frame work, so no hot-path proof file legitimately changes.
logo.svg hardcoded a single palette, so the browser tab icon always
rendered the dark-mode blue with pure white, including on light-themed
tab strips where the brand file specifies blue-600.
This could not be fixed while the file was also the Login and setup
wizard logo, because an <img> resolves prefers-color-scheme against the
OS while the app's theme comes from pulseThemePreference, and the two
diverge freely. Those two call sites now render PulseBrandMark inline, so
logo.svg is the favicon and nothing else, and the OS colour scheme is the
only signal available to it.
Adopt the palette from docs/images/pulse-logo.svg so all three published
forms of the mark agree. Geometry is unchanged. Browsers that ignore the
media query fall back to the light branch, which is the correct light
appearance rather than a broken one.
The header logo's ring carried no dark variant while its own centre dot
did, so in dark mode the ring rendered pure white against a #dbeafe dot.
Both circles were written in the same commit (672db9113) and the brand
file docs/images/pulse-logo.svg specifies #dbeafe for each, so this was a
slip rather than a deliberate split.
Login and the setup wizard rendered the mark as <img src="/logo.svg">.
That file hardcodes a single palette with no light or dark handling, so
the first mark a new user sees was the dark-mode blue even on a light
theme. Adding a prefers-color-scheme block to logo.svg would not fix it
and would sometimes make it worse, because an <img> resolves that query
against the OS colour scheme while the app's theme comes from
pulseThemePreference in localStorage, and the two diverge freely.
Extract PulseBrandMark alongside PulsePatrolLogo and render it inline at
all three sites so the palette follows the app's theme class. logo.svg
stays single-colour for the favicon, where the OS scheme genuinely is the
only signal available.
The pulse-brand-logo, pulse-bg, pulse-ring and pulse-center hooks are
preserved because .animate-pulse-brand in index.css reaches them through
descendant selectors to run the header heartbeat while the connection is
healthy. Verified in a browser that all four keyframe animations still
attach after the markup moved into a component.
Drops rounded-md and the shadow utilities from the two img call sites.
They styled the square element box around a mark whose corners are
transparent, so they only ever produced faint corner shadows behind a
circle.
Contract-Neutral: presentational brand mark theming fix, no public contract or payload delta
The 25 July GLM swarm generated these and they were never collected, unlike
the 0712 through 0724 batches already in main. They cover apiClient retry and
abort handling, the prompt-secret model boundary sanitizer, AI tool
normalisation, Patrol handoff, threshold table state, the audit log panel, the
connections ledger, licence and resource-badge presentation, and the AI
intelligence store.
Verified against current main before harvesting rather than trusting their
age: 36 Go tests and 197 frontend tests pass, go vet is clean, and both
batches were re-run after formatting.
The installer compares the agent binary it downloaded against the server that
served it, stripping a leading "v" so "v6.0.4" and "6.0.4" match. It did not
strip semver build metadata, so a server built from a working tree reporting
"6.2.0-rc.8+git.46.g98a638e00.dirty" never matched the "v6.2.0-rc.8" agent it
had just served, and the mismatch warning fired on every correct development
install.
This is the warning's whole job, so a false positive is expensive. It is the
only client-side signal that a stale agent was downloaded, and because it
always fired it read as background noise. That is exactly how a genuinely
stale v6.0.5 agent was installed on a live host earlier today: the warning
was there, above the install output, and looked like the one that always
appears.
Strip build metadata from both sides before comparing, keeping the prerelease
suffix because 6.2.0-rc.8 and 6.2.0 are genuinely different releases. This is
the same release-identity reduction the server applies when deciding whether a
local agent artifact is fresh enough to serve; the contracts now state that
one definition governs both ends rather than leaving each side to invent its
own.
Guarded by a test that pins both normalisation steps and exercises the
comparison across the cases that matter: the dev-server shape that used to
warn wrongly, the stale-download shape that must still warn, and a prerelease
against its release. Verified to fail when either strip is removed.
/download/pulse-agent served whatever agent artifact sat on disk with no
relation to the running build. Local agent binaries are build outputs that
nothing refreshes on their own, so they go stale silently: a dev backend was
found serving a four-week-old v6.0.5 agent while reporting 6.2.0-rc.8.
Staleness is not cosmetic. The installer renders its service wrapper from the
server's current template, so an agent predating a flag that template now
passes exits immediately with "flag provided but not defined" and crash-loops
under its watchdog. That is how a real host lost its agent: the version
mismatch was reported only as an installer warning, after download, easy to
read as noise.
Validation already scanned the binary for its report-contract endpoints, so
the version check joins that same single pass and rejects a binary that does
not carry this server's agent version. Refusal is loud where the old warning
was quiet: a dev server answers 404 naming the stale path and the build
command, and a published release falls through to the existing release-asset
proxy and fetches the matching version, which makes production self-healing
rather than silently downgraded.
The expected version resolves through updates.GetCurrentVersion rather than
the compiled-in serverVersion. The first cut of this guard used serverVersion
and was inert on exactly the builds that need it: the enterprise binary
compiles in "dev-pro", no version parser accepts it, and the check disabled
itself. It passed its unit tests and still served the stale binary; only
replaying the real v6.0.5 artifact through the running server exposed it.
"dev-pro" is now pinned in the version table with that reasoning attached.
The shared download-test fixture built a binary carrying the report endpoint
but no version string, which a real agent always has, so it now stamps the
expected version. Verified non-vacuous in both directions: those tests fail
with the guard active and the thin fixture, and pass with a faithful one.
Contract-Neutral: storage-recovery is pulled in only by the broad internal/api/ Extension Points prefix and this change does not move that boundary: it constrains which agent binary /download/pulse-agent serves, touching no storage provider, backup target, recovery repository or protected-workload evidence. The agent-lifecycle, deployment-installability and api-contracts deltas staged here cover every boundary the change actually moves.
Completes the wrapper-teardown rule across the remaining branches. The QNAP
install and both uninstall paths still used a bare pkill -f
"start-pulse-agent.sh" and still stopped the agent before its wrapper.
The bare pattern is narrower than it looks and wider than it should be. It
does NOT match a co-installed agent's supervisor, so the sibling case was
already safe; what it does match is anything where the unescaped dot stands in
for another character and the unbounded tail keeps going, including a .bak
copy of the wrapper and an editor session holding it open. Escaping the dot
and bounding the far end removes both without narrowing the intended match.
Ordering is the more consequential half. A wrapper is a watchdog, so stopping
the agent while its wrapper still loops only races the respawn. QNAP and the
uninstall paths now stop the supervisor first, which is what the contracts
already required of every branch that writes and launches a wrapper.
Uninstall keeps a deliberately broader match than install, with no leading
path separator, so it still reaches a wrapper invoked by a relative path or
stranded at a superseded location. Both contracts now carry that distinction
and the teardown ordering rule, which each had stated only for install.
Guarded by two tests that pin every wrapper kill in the file rather than one
branch: one requires the escaped dot and the bounded tail everywhere, the
other walks each stop block and fails if an agent kill precedes its wrapper
kill. Both were confirmed to fail against the pre-fix QNAP block.
The Unraid install path killed the running agent but never the wrapper
supervising it, then appended a second wrapper at the end of the install. The
survivor and the newcomer both loop trying to own the same agent id, and
because the old wrapper is a watchdog it respawns the agent mid-install with
the previous binary and arguments. Observed on a live Unraid host: a
supervisor from a July install was still running beside the one the reinstall
had just started.
Nothing reports this as a failure. It presents later as an agent that
restarts on its own or reverts to superseded arguments.
Stop the wrapper first, then the agent: killing a supervised agent while its
wrapper still loops only races the respawn. The pattern matches the trailing
path segment so a wrapper left at an older storage location is caught too,
with the dot escaped and the far end bounded so a co-installed agent's
supervisor (start-pulse-agent-prod.sh) is not.
The QNAP branch already stopped its wrapper, which is what made the Unraid
omission visible; the contracts now require every wrapper-writing branch to
own the same teardown.
Guarded by two tests: one pins that the Unraid branch stops the wrapper and
does so before the agent, the other pins that the wrapper pattern spares a
sibling supervisor, with a premise check that the loose pattern really does
match so neither assertion can pass vacuously.
pkill -f matches the whole command line and "^" only anchors the start, so
"^/usr/local/bin/pulse-agent" also matches "/usr/local/bin/pulse-agent-prod".
On a host running a second agent whose binary name shares the prefix, every
install, every upgrade, and every restart of the generated Unraid wrapper
silently killed the other agent too. Confirmed on a live dual-agent Unraid
box: the old pattern matched both the dev agent and the production dogfood
agent, the bounded pattern matches only its own.
The wrapper is the worst of the three because restarting through it is the
documented runbook step, so the collateral kill repeats every time an
operator follows it.
Bound the far end of each binary-anchored pattern with ([[:space:]]|$), and
swap the bare pkill -9 -f "pulse-agent" for -x on the exact process name,
which keeps that site's deliberate path-agnostic intent while excluding the
sibling. The pkill -x sites were already safe and are unchanged.
Guarded by two tests: one pins that no binary-anchored pkill in the installer
is left unbounded, the other exercises POSIX ERE semantics against the two
command lines a dual-agent host presents, including a premise check that the
unbounded pattern really does match the sibling so the assertion cannot pass
vacuously.
The only coverage for the mobile nav rail bringing the active tab into view
was a ?raw assertion that the string scrollIntoView appears in
useMobileNavBarState.ts, which stays green even when the effect never locates
the active element and silently bails.
The scrollIntoView double now records the element each call landed on, so the
two new cases assert the rail centred the ACTIVE tab specifically: once on
mount, and again after the active tab changes. Verified both fail when the
effect's active-element lookup is broken, while the old ?raw assertion keeps
passing.
Mock mode suspends pull-based collection outright, but push-based agent
reports were never given the same treatment, so a real machine still landed
in monitor state while the unified read path substituted the mock snapshot
over the top. The hosts were hidden, everything downstream was not: a real
Unraid box raised a live storage-topology alert next to fixture data, and
its identity persisted through host continuity.
Three vectors, each closed at its source.
Agent ingest now drops real reports while mock mode is on. ApplyHostReport,
ApplyDockerReport and ApplyKubernetesReport acknowledge the report with the
reporting agent's own identity and touch no state, so nothing raises alerts,
persists continuity, records metrics or feeds the online/offline sweep. The
acknowledgement stays a success so a real agent does not read a demo server
as an outage and retry-storm it.
recentStandaloneHostContinuityEntries returns nothing in mock mode. Those
entries are written to disk from real reports and outlive the toggle, and
every consumer injects them after the read path has already substituted the
mock snapshot, so a machine that reported before mock mode was enabled came
back by its real hostname. There is no real-polling exception here: agent
ingest is not gated on PULSE_MOCK_KEEP_REAL_POLLING and the read state is
mock either way.
Active-alert restore is now opt-out, and mock mode opts out. SetMockMode
already clears active alerts when the toggle flips, but a process booting
with mock mode already enabled never ran that path and restored real alerts
from active-alerts.json.
TestHostedTenantAgentInstallTokenCannotReportToOtherTenant used mock mode as
scaffolding. Under the ingest guard both tenants would be empty and its
isolation assertion would pass without exercising the boundary, so it now
runs in real mode. Every new test pairs the mock assertion with a real-mode
one for the same reason.