GlobalUpdateProgressWatcher ran a 5s fallback poll of
UpdatesAPI.getUpdateStatus() for every session, but /api/updates/status is
registered with RequireAdmin + RequireScope(settings:read). An authenticated
non-admin session -- an SSO user with read-only roles, say -- therefore 403'd
on every tick, and each denial logged a warn. The frontend swallowed the
error, so the only symptom was backend log spam every few seconds, which
misled the #1601 rc.9 reporter into diagnosing an RBAC break when the actual
bug was a websocket delta regression.
Gate the watcher on securityStatus detailLevel === 'privileged'. That is the
served mirror of the exact route gate: the security-status handler promotes a
response to 'privileged' if and only if the session passes
canAccessAdminSurface(settings:read), the same predicate RequireAdmin and
RequireScope enforce on the route. Instances that require no auth keep
polling, because there the update routes really are reachable by anonymous
loopback callers while security status is still served at detail level
'public' -- gating on detailLevel alone would have broken the update modal for
the most common self-hosted install.
UpdateProgressModal's own getUpdateStatus poll needs no separate gate: its
only mount site is inside this watcher.
Verified on an isolated instance with proxy auth and two header-injecting
proxies supplying identities that differ only in the role header:
bob-viewer detailLevel=authenticated GET /api/updates/status -> 403
0 requests per 60s window at 1280x800 and at 375x812,
windows that previously held ~13 poll ticks each
alice-admin detailLevel=privileged GET /api/updates/status -> 200
22 requests across both viewports, all 200 (unchanged)
Zero /api/updates/status denials in the backend log across the whole session.
The non-admin shell still renders normally at both viewports; only the poll
is gone.
Contract-Neutral: Frontend-only behavioral fix: gates the GlobalUpdateProgressWatcher fallback poll so non-admin sessions stop issuing GET /api/updates/status requests the RequireAdmin+settings:read route always refused with 403. No public-contract delta - no API/endpoint/type/prop change, no new component, admin behavior byte-identical (22 polls verified), non-admin behavior was already a silent 403. App.tsx is only a high-fanout Extension Points reference for ai-runtime/cloud-paid/storage-recovery; none of those subsystems' surfaces change.
Free self-hosted sessions now see reactive commercial surfaces: paid-feature
nav items stay visible with panel-owned inline gates (Relay precedent extended
to reporting, roles, users, audit log, audit webhooks), gate CTAs render, and
Plans & Billing is discoverable and carries an MSP/provider section with the
free 60-day evaluation. Adds a one-shot business-estate card for authenticated
free sessions whose monitored estate crosses business-scale thresholds
(>=5 PVE nodes, >=10 Docker hosts, or >=3 VMware hosts); the flag rides
sessionCapabilities only, never the pre-auth presentation policy, so estate
size cannot leak to anonymous visitors. Demo mode and white-label runtimes
(including MSP tenant containers) keep full commercial suppression; the
multi_tenant organization nav items deliberately stay hidden.
Decision record supersedes the 2026-04-25 opt-in record:
docs/release-control/v6/internal/records/self-hosted-commercial-surfaces-revision-2026-08-07.md
RA5 summary, contract pins, nav integration pins, and the RA5 browser spec
are updated to the revised invariant in the same commit.
The public repository surface refresh (40d29ca44) hard-wrapped the
Settings → Infrastructure → Install on a host phrase mid-line and
dropped the v5-to-v6 agent upgrades mention from README.md, breaking
RA10's documentation-currentness-tests proof on main.
- documentation_currentness_test.py: match prose fragments against
whitespace-normalized content, the way markdown renders it, so an
editorial rewrap can no longer fail the guard. Structural fragments
(table rows, gate headings) and all assertNotIn checks still match
raw source.
- README.md: restore the v5-to-v6 agent upgrades mention the refresh
dropped; the invariant is still live while v5 installs migrate.
The server now echoes its version on unified-agent report acks, and the
agent nudges its auto-updater the moment an ack carries a newer version.
After a server upgrade, agents converge within one report interval
instead of waiting out the hourly update check, so the "older Pulse
agent" notice self-resolves in seconds once upgraded agents report in.
The hourly loop stays as the retry and backstop path. Nudges dedupe per
server version, refuse downgrades, skip disabled and development-mode
updaters, and never fire from observer destination acks — only the
authoritative server may steer an agent's updater, and a nudged check
re-validates against the server and runs the existing checksum and
self-test pipeline before swapping binaries. Agents deployed before
this change still converge on their old hourly cadence once; every
upgrade after that lands within a report cycle.
Contract deltas recorded in agent-lifecycle and api-contracts, with
boundary notes in security-privacy (no update authority in the echo),
performance-and-scalability (no steady-state work), and
storage-recovery (nothing persisted).
A pulse-agent installed on a Proxmox node merges into one canonical
resource whose primary ID is node:{sourceID}, so the agent:{hostID}
reference the alert evaluator uses (hostResourceID) matched nothing in
the registry. Per-resource grace overrides and operator-state lookups
then silently resolved to factory policy: alerts fired with no grace
while the policy preview, which receives the canonical ID directly,
showed the override working. Index the prefixed agent ref as a
canonical identity alias so runtime resolution lands on the same
resource the override was saved against.
Refs #1497
Contract-Neutral: behavioral fix#1497: index agent: ref as canonical alias, no public contract shape change
Traefik took the whole operator .env via env_file, so the internet-facing
container's environment carried CP_ADMIN_KEY and the entitlement signing
private key, neither of which it needs. It now receives only ACME/DNS
material: the CF token by explicit passthrough plus an optional
dns-credentials.env that setup.sh creates 0600.
The same wiring removes the silent Cloudflare-only narrowing: the resolver
provider is now ACME_DNS_PROVIDER (default cloudflare) via Traefik's env
override, the CF token is required only for the default provider, and any
other Traefik dnsChallenge provider supplies its credential variables through
dns-credentials.env. Pinned by
TestProviderMSPTraefikEnvIsMinimalAndDNSProviderOverridable; contract delta
in deployment-installability.
The MSP page has led with the free two-client evaluation since 08-03,
but nothing outside pulserelay.pro mentioned MSP at all, so only
visitors who already found the site could discover it. The README is
the highest-traffic owned surface; give MSP the same one-bullet
treatment as the other editions, worded to match msp.html's own claims.
The Start free button on the MSP page lands on this section, which described
the self-issued licence but never said how to obtain the bundle. The natural
guess, the latest stable tarball, ships a setup.sh that predates the
evaluation flow and dies asking for a licence. Point evaluators at main and
state the host prerequisites, which were documented nowhere in this guide.
The docs link drift guard (test_repo_docs_link_drift.py) fails CI because
render_release_body.py links docs/CODE_SIGNING_POLICY.md at blob/main.
Release bodies must reference the docs snapshot of the release they
describe, so the installation section now links blob/v{version}, matching
how the rest of the body pins to the version being cut. The unit test pin
follows. Also skip .claude/ in the drift guard so local runs on a shared
tree are not polluted by stale agent worktrees under .claude/worktrees
that CI never checks out.
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.