Commit Graph

6461 Commits

Author SHA1 Message Date
rcourtman 5bffb581bb Extract resource detail summary presentations 2026-05-21 17:45:48 +01:00
rcourtman 7ac97afc32 Use table summaries for inline resource details 2026-05-21 17:11:39 +01:00
rcourtman 433da8537f Render TrueNAS detail sections as tables 2026-05-21 17:05:43 +01:00
rcourtman 1794a83002 Open TrueNAS inline details by default 2026-05-21 16:57:10 +01:00
rcourtman 2e5e54d598 Fit TrueNAS detail tables 2026-05-21 16:50:40 +01:00
rcourtman 64a3fbc07e Fit TrueNAS protection table to overview width
- Remove the redundant Signal column from the TrueNAS protection ledger
- Keep replication state visible in target detail and outcome badge
- Add a guardrail for the fitted protection table width
2026-05-21 16:38:53 +01:00
rcourtman 946ebfde1d Tighten TrueNAS native inventory tables
- Scope the TrueNAS resource fetch to native TrueNAS and agent-origin system rows
- Group overview inventory counts to keep the table scannable without horizontal overflow
- Remove internal service/resource ids from visible TrueNAS service rows
2026-05-21 16:34:38 +01:00
rcourtman 1118e09fcd Clear server-side CSRF token on logout
clearSession invalidated the session-store record and zeroed both
session/CSRF cookies on the client side, but never deleted the
matching entry from the CSRF store. The orphaned record stuck around
for up to 4 hours (CSRF TTL) after every logout. Not exploitable —
the session itself was gone, so the stale CSRF entry could not be
used — but the asymmetry was real: InvalidateUserSessions (password
change) and InvalidateOldSessionFromRequest (re-login) both already
called DeleteCSRFToken, and logout was the missing path.

Add the same call in clearSession alongside InvalidateSession.

Regression test TestClearSession_DeletesServerSideCSRFToken sets up a
real session + CSRF token pair, calls clearSession with a request
carrying the session cookie, and asserts the CSRF token no longer
validates against the session ID afterward.
2026-05-21 16:26:00 +01:00
rcourtman 64505ac25c Tighten DOMPurify config in LLM markdown renderer
Two hardening adjustments to renderMarkdown in aiChatUtils.ts:

  - Pin ALLOWED_URI_REGEXP to ^(?:https?:|mailto:). The previous
    config left href scheme filtering to DOMPurify's library defaults,
    which strips javascript:/data: in current versions but is version-
    dependent. Making the allowlist explicit means the safety property
    survives a future DOMPurify upgrade that loosens the internal
    default.

  - Drop 'class' from ALLOWED_ATTR. The LLM has no legitimate reason
    to apply arbitrary CSS classes, and allowing them on sanitized
    markup is a UI-redress surface: an attacker who can influence the
    chat output (prompt injection, compromised model endpoint) could
    inject something like <div class="fixed inset-0 z-50 bg-black">
    to overlay the page or hide content.

Also expanded the marked.setOptions comment to document that the
sanitizer is the real XSS gate but marked options are kept tight as
defence-in-depth: a future DOMPurify-config loosening shouldn't mean
script tags from the LLM hit the DOM.

Regression tests added in aiChatUtils.test.ts:
  - javascript: hrefs stripped
  - data: hrefs stripped
  - class attribute stripped from <div>
  - https links preserved with target=_blank rel=noopener noreferrer
2026-05-21 16:23:12 +01:00
rcourtman 77a6a8c695 Read RBAC identity from request context, not response header
RequirePermission was reading the authenticated username from
w.Header().Get("X-Authenticated-User"). The header is set by checkAuth
upstream, but response headers are mutable across the handler chain:
any middleware sitting between checkAuth and RequirePermission that
wrote that header would substitute an arbitrary identity, and the
RBAC authorizer would make its access decision against the
substituted value. Identity belongs on the request context, not in a
mutable response surface.

Read internalauth.GetUser(r.Context()) first — checkAuth already
calls attachUserContext on every auth path that produces a real user,
so the context is the authoritative source. Keep a defensive,
warn-logged fallback to the response header for the one upstream path
that sets the header but skips attachUserContext (the anonymous-user
flow), so the existing test contract is preserved while the warning
flags the source path to plug.

Regression test TestRequirePermissionUsesContextUsername constructs
the attack: a request whose context says "real-authenticated-user"
and whose response header says "evil-spoofed-user". The RBAC
authorizer must observe the context value.
2026-05-21 16:19:25 +01:00
rcourtman 0ac04c8d39 Validate SAML LogoutResponse before clearing session
handleSAMLSLO was an unauthenticated force-logout DoS: any cross-origin
GET or POST to /api/saml/{id}/slo cleared the user's session and
returned a 302 to /?logout=success. No SAML payload was required, no
signature validation, no IdP-identity check.

Add ValidateLogoutResponse to SAMLService wrapping crewjam/saml's
ServiceProvider.ValidateLogoutResponseRequest, which validates the
XML-DSig against the configured IdP certificate and the standard
LogoutResponse temporal and target invariants.

In handleSAMLSLO:
  - extract and validate the provider ID
  - require either a SAMLResponse (response to our SLO request) or a
    SAMLRequest (IdP-initiated SLO) parameter — reject bare requests
    with 400 and do not clear session
  - reject IdP-initiated LogoutRequest with 400: we don't track
    in-flight request IDs to bind one to yet, and silently treating it
    as a force-logout signal is the same bug we are closing
  - on SAMLResponse, call service.ValidateLogoutResponse. On failure
    return 403 and do not clear session; audit-log the failure
  - only when validation passes do we clear the session and redirect

Regression tests:
  - TestHandleSAMLSLO_RejectsEmptyPayload (400, no redirect, no session
    state mutation)
  - TestHandleSAMLSLO_RejectsInvalidLogoutResponse (403 on garbage
    SAMLResponse value)
  - TestHandleSAMLSLO_RejectsIDPInitiatedLogoutRequest (400 on
    SAMLRequest-only payload)

The prior TestHandleSAMLSLO_Redirects test, which asserted the unsafe
"clear session and 302 on any GET" behaviour, is replaced by the
three above.
2026-05-21 16:15:53 +01:00
rcourtman 1dfc4ee6d3 Require .sshsig sidecar for install.sh --archive path
The --archive PATH flag (and the Proxmox LXC bootstrap which
propagates --archive into the container) reached install_pulse_archive
without going through download_release_archive, which is the only
place that previously verified the cryptographic signature on a
Pulse release tarball. That made --archive a clean signature-
verification bypass: anyone who could swap a local tarball
between download and install (shared /tmp on a Proxmox host, a
maintainer testing a swapped local file, etc.) would get
arbitrary root code execution at extract time.

install.sh:
- install_pulse_archive now requires ${archive_path}.sshsig and
  verifies it via the existing verify_release_signature helper
  before tar runs. Missing or invalid signature is a hard
  fail-closed.
- download_release_archive now keeps the .sshsig alongside the
  archive at ${archive_path}.sshsig instead of stashing it in a
  mktemp file that got deleted post-verify. install_pulse_archive
  re-verifies it; no behavior change for the curl|bash path beyond
  the extra integrity check at extract time.
- Proxmox LXC bootstrap now pct push'es ${container_archive_source}.sshsig
  alongside the archive into the container so the in-container
  install_pulse_archive run can verify. Missing sidecar fails the
  bootstrap (cleanup_on_error tears down the half-provisioned
  container).
- Cleanup paths that remove the temp archive on success/failure
  now also remove the sidecar.
- --help advertises the .sshsig requirement.

install-sh-smoke.yml:
- Smoke test now pulls ${tarball}.sshsig alongside the tarball
  before running install.sh --archive inside the test container.

Trade-off: maintainer flows that did `install.sh --archive
/path/to/local.tar.gz` against an unsigned local tarball will now
fail with a clear "Required signature sidecar not found" error.
Maintainers can either pull the corresponding .sshsig from the
release alongside the tarball, or skip --archive in favour of the
canonical download path.
2026-05-21 16:13:57 +01:00
rcourtman d890904ce6 Stop auto-escalating SameSite to None on proxied requests
getCookieSettings auto-set SameSite=None on the session and CSRF
cookies whenever a request arrived via a trusted proxy with
X-Forwarded-Proto: https. The original intent was "be more permissive
for proxied deployments," but in practice that disabled the
browser-side CSRF defence for every Pulse deployment behind a reverse
proxy — which is nearly all of them.

SameSite=None tells the browser to attach the cookie on arbitrary
cross-site requests. That is only required for cross-origin iframe
embedding scenarios Pulse does not document or support. The
top-level-navigation cases that actually matter (OIDC/SAML callback
landing, Cloudflare-tunnel access from a bookmark) all work under Lax,
which still attaches cookies on top-level navigations and only blocks
cross-site sub-resource requests.

Always return SameSiteLaxMode. The Secure flag still tracks the
actual connection state via isConnectionSecure(r). Together with the
earlier CSRF bypass fix (require token regardless of Authorization
header), the browser-side and server-side defences are now both in
place.

Test expectations updated: three cases that previously asserted
SameSiteNoneMode for the proxied-HTTPS / Cloudflare-tunnel / direct-
TLS-with-Forwarded paths now assert SameSiteLaxMode, with comments
flagging them as regression coverage.
2026-05-21 16:11:30 +01:00
rcourtman 2305131d8a Move eval-model-matrix off self-hosted runner
Self-hosted runners attached to a public repo are a known
severe-risk pattern: any workflow that gains the ability to run
PR-controlled code escalates to RCE on the runner host. Even
though this workflow is workflow_dispatch only today, the runner
itself sits one workflow edit away from arbitrary code execution
on whatever machine it's pinned to.

Changes:

- runs-on: self-hosted -> ubuntu-24.04. Reachability of base_url
  from the GitHub-hosted runner is now the dispatcher's
  responsibility. Add tailscale/* steps (like deploy-v6-preview-
  demo.yml or deploy-demo-server.yml already do) if pointing at
  a private Pulse instance.

- Drop the admin/admin fallback on PULSE_EVAL_USER and
  PULSE_EVAL_PASS. The previous default was
  ${{ secrets.PULSE_EVAL_USER || 'admin' }} which silently ran
  the eval as admin/admin if the secrets were missing. Added a
  fail-closed precheck that refuses to proceed unless both
  secrets are set.

- Route inputs.scenario, inputs.models, inputs.providers, and
  inputs.base_url through env: indirection instead of inline
  ${{ }} interpolation into shell. Defense-in-depth against
  script injection from the dispatch payload.

Net result: kills the audit's only CRITICAL finding and the
companion MEDIUM (admin/admin fallback). Eval can still run
exactly as before once secrets are set and base_url is reachable.
2026-05-21 16:09:09 +01:00
rcourtman 226f0bdf72 Report restricted hot-dev probes as unavailable
Render managed runtime status probes as unavailable instead of 000 when a restricted shell can see loopback listeners but cannot run local HTTP probes. Refs architecture post-RC canonicalization follow-up.
2026-05-21 15:51:40 +01:00
rcourtman a77420e88a Make hot-dev local-only by default
Keep the managed dev runtime on loopback unless PULSE_DEV_LAN=true is set, so installed LAN agents cannot accidentally attach to a developer session. Harden managed status checks for restricted local shells. Refs architecture post-RC canonicalization follow-up.
2026-05-21 15:26:53 +01:00
rcourtman bc2adac8b5 Stabilize dev runtime and complete PMG read-state migration
Refs architecture post-RC canonicalization follow-up.
2026-05-21 15:15:23 +01:00
rcourtman 2c51890d01 Harden installer extraction and demo workflow inputs
install.sh:
- find_pulse_binary_in_dir now rejects symlinks, so a malicious
  tarball that smuggled a symlink at the expected binary path
  cannot redirect the cp to $INSTALL_DIR/bin/pulse onto an
  arbitrary file outside the extract dir.
- tar -xzf calls in install_pulse_archive now pass
  --no-same-owner --no-overwrite-dir, defending against archive
  entries that try to chown extracted files or rewrite existing
  directory metadata.

Demo deploy workflows:
- deploy-demo-server.yml routes inputs.target through env: so the
  workflow_dispatch input cannot break out of the case statement,
  even though type: choice constrains it client-side.
- update-demo-server.yml routes inputs.tag, inputs.target, and
  github.event.release.tag_name through env: for the same reason.

All defense-in-depth: download_release_archive still signature-
verifies the tarball, and the demo workflows are gated to write-
access dispatchers. The argument-vs-env distinction matters once
the trigger surface or trust boundary changes.
2026-05-21 15:04:35 +01:00
rcourtman bce500732a Stabilize managed dev runtime recovery 2026-05-21 13:36:36 +01:00
rcourtman 0f4c831fab Reject expired trials in hosted subscription gate
isHostedSubscriptionValid was treating any tenant with a non-nil
TrialEndsAt as a valid bounded trial, never comparing the timestamp to
the current time. A tenant whose trial ended in the past but whose
stored subscription state was still "trial" (e.g. lease refresh failed
or never ran) retained full hosted Cloud access indefinitely.

The validity rule now requires both that a trial end timestamp exists
AND that time.Now() is before it. Refactored the switch to make the
trial branch read explicitly: nil evaluator -> deny, nil end -> deny,
past end -> deny, otherwise allow.

Added TestTenantMiddleware_HostedMode_ExpiredTrial_Blocked as the
regression test (mirrors the existing BoundedTrial_Allowed +
UnboundedTrial_Blocked pair with a past-dated trial end).
2026-05-21 12:33:10 +01:00
rcourtman bb3514e316 Require CSRF token regardless of Authorization header
CheckCSRF was skipping the CSRF check whenever the request carried
Authorization: Bearer, Authorization: Basic, or X-API-Token, without
validating the credential. An attacker on a cross-origin page could
fetch() any state-changing endpoint with credentials: 'include' plus an
arbitrary Authorization header — the browser would auto-attach the
victim's pulse_session cookie, the server would skip CSRF, and the
request would execute as the logged-in user. Full CSRF bypass for every
session-authenticated user.

CSRF protection exists because the browser auto-attaches the session
cookie. That cookie is the only auto-attached credential we issue, so
it is the only correct signal for whether CSRF applies. Header-based
auth is set explicitly per request and is not CSRF-vulnerable, but its
presence does not make a session-cookie-bearing request safe. Skip CSRF
only when no session cookie is present; otherwise require the token
regardless of any Authorization or X-API-Token header.

Tests: the three "header bypasses CSRF" cases in security_test.go were
passing only because they sent no session cookie (so the no-cookie path
returned true). Renamed those to make the no-cookie precondition
explicit. Added TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent
in security_regression_test.go covering X-API-Token, Authorization:
Basic, Authorization: Bearer, and mixed-case Bearer with a session
cookie present — each must require a valid CSRF token.
2026-05-21 12:29:32 +01:00
rcourtman adb0f483bf Route GITHUB_TOKEN through env in validate-release-assets
Five steps in the release-asset validation workflow built curl
commands like:

    curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" ...

That renders the token into the step's shell command line, where it
is visible to any process able to read /proc on the runner during
the curl call. Convert all nine such call sites to pass the token
via env: GH_TOKEN and reference $GH_TOKEN in the header instead, so
the token only lives in the step env, never on argv.

No behavior change; the token value is identical.
2026-05-21 12:28:43 +01:00
rcourtman 9ef4e588b7 Add TrueNAS services and stabilize dev startup 2026-05-21 12:08:25 +01:00
rcourtman 179f88d721 Align TrueNAS storage with native queries 2026-05-21 10:39:36 +01:00
rcourtman 6293d6b312 Align TrueNAS protection with native queries 2026-05-21 10:28:50 +01:00
rcourtman e189733434 Align TrueNAS apps with native app.query 2026-05-21 10:23:48 +01:00
rcourtman d4a4bc0c62 Surface native TrueNAS runtime counts
Add VM and share counts to the TrueNAS observed summary contract and systems table.

Keep TrueNAS overview inventory table-first with a platform guardrail.
2026-05-21 09:54:41 +01:00
rcourtman cb461a7304 Split TrueNAS API inventory into native tabs
Move TrueNAS apps, VMs, and network shares out of the overview stack and into first-class platform sub-tabs.

Update route and shell proof coverage for the expanded native TrueNAS tab set.
2026-05-21 09:20:14 +01:00
rcourtman 511d88f83d Show native TrueNAS protection activity
Replace the embedded recovery workspace with a TrueNAS-owned snapshots and replication table.

Keep the platform shell proof aligned to the native TrueNAS row contracts.
2026-05-21 09:12:04 +01:00
rcourtman f453d56607 Show native TrueNAS storage topology
Replace the generic embedded storage surface with a TrueNAS-owned topology table for pools, datasets, and physical disks.

Keep pool child inventory visible by default and route rows through the shared platform detail drawer.
2026-05-21 08:48:19 +01:00
rcourtman 497390ff95 Scope TrueNAS resources by appliance
Scope TrueNAS child source IDs under the system source key so duplicate pool, dataset, app, VM, share, and disk names from different appliances do not merge.

Use the resulting hierarchy for per-system TrueNAS overview counts and scoped mock metric targets.
2026-05-21 00:59:00 +01:00
rcourtman 0fb0287e86 Surface TrueNAS health alerts on overview 2026-05-21 00:34:00 +01:00
rcourtman 58a2ad8cc6 Add TrueNAS protection tab 2026-05-21 00:13:21 +01:00
rcourtman 0801ede8c3 Add native TrueNAS network share inventory 2026-05-20 23:50:37 +01:00
rcourtman 6d55ca531c Expose native TrueNAS details in resource drawer 2026-05-20 23:00:55 +01:00
rcourtman 3fa46aa836 Add native TrueNAS VM inventory to overview
Project TrueNAS vm.query inventory into canonical VM resources and render it on the TrueNAS overview.
2026-05-20 22:32:46 +01:00
rcourtman 2202f36674 Rebuild TrueNAS overview around native app API data
Project TrueNAS app.query data into the unified resource contract and render the TrueNAS overview apps table from that native facet.

Keep Docker metadata as the runtime compatibility fallback for shared container tooling.
2026-05-20 21:54:40 +01:00
rcourtman 557c5151f2 Share app-container column config between Docker and TrueNAS
TrueNAS apps are Docker containers running under the embedded daemon, so
the apps table on the TrueNAS overview should default to the same column
set as the Docker / Podman containers table: writable layer hidden by
default, tags hidden by default, and Net I/O and Disk I/O hidden when no
container in the set reports telemetry. Previously the TrueNAS surface
rendered the workload table with raw defaults, which exposed an empty
"Disk" column and an always-visible "Disk I/O" column that no real
TrueNAS app populates.

Extract APP_CONTAINER_BASE_DEFAULT_HIDDEN_COLUMNS,
APP_CONTAINER_COLUMN_LABEL_OVERRIDES, and the telemetry-aware
buildAppContainerDefaultHiddenColumnIds into a shared
appContainerColumns module under features/platformPage. The Docker page
keeps its context: 'Host' relabel on top of the shared overrides; the
TrueNAS page now scopes its column-visibility prefs under
truenas-app-containers and pulls the same hidden-column defaults from
its own app list. buildDockerContainerDefaultHiddenColumnIds is
preserved as an alias so the existing docker page tests still pass.
2026-05-20 21:27:40 +01:00
rcourtman 6708c6a2c7 Surface host OS family in Docker SYSTEM column
getInfrastructureSystemIdentityBadges never consulted resource.docker.os,
so plain Docker hosts (no Pulse host-agent overlay) fell through the
agent-identity and source paths to the runtime-only "Docker" badge, which
getDockerHostSystemBadge filters out — leaving the SYSTEM column blank on
the Containers overview for every host whose OS the docker-agent already
reported via info.OperatingSystem.

Add getDockerHostOsIdentityBadge: when resource.docker.os is present,
match it through the same host-OS label patterns used for agent.osName
and emit a Debian / Ubuntu / Alpine / etc. badge with the full OS string
as the hover title. Inserted in the resolver chain after the agent
fallback so a Pulse host-agent still wins when both signals exist.
2026-05-20 21:20:01 +01:00
rcourtman ddad6108ae Classify runtime lenses in platform manifest 2026-05-20 20:45:53 +01:00
rcourtman 4a5873d49c Rename primary navigation model for runtime lenses
Rename the shared shell navigation contract from platform-only terminology to infrastructure navigation so it can represent both owning platform pages and the Docker / Podman container-runtime lens. Update mobile nav primitives, command palette, shortcuts, and governed docs to keep Docker as a Containers runtime lens without changing the /docker route.
2026-05-20 20:28:01 +01:00
rcourtman 16205bdd44 Align container runtime navigation with platform scopes
Prefer platformScopes for primary navigation evidence so TrueNAS app Docker metadata does not expose the container runtime lens. Label the Docker / Podman route as Containers in shell navigation while preserving the /docker route and runtime-specific copy.
2026-05-20 20:03:10 +01:00
rcourtman aabef9afd5 Add canonical platform scopes for runtime workloads
Teach unified resources to emit platformScopes so runtime workloads can belong to both Docker and their owning platform. Update workload filters to use platform scopes and keep TrueNAS app containers scoped to TrueNAS.
2026-05-20 19:30:55 +01:00
rcourtman 2f77e2680d Soften Duration column tone to canonical Pulse metric palette
The amber-500 / amber-400 / emerald-500 fills on the Duration bar
were all fully opaque, which read as a wall of saturated orange on
any tab with many slow tasks. Switch to the canonical bg-metric-*-bg
tokens that Storage and Ceph row bars already use — same 60%-alpha
palette, immediately calmer in the same column.

Also cap at `warning` (soft yellow) rather than going to `critical`
(red) for the worst case. A slow backup task is a perf outlier, not
a failure; failure is already conveyed by the Status column. Two
tiers — normal / slow — keeps the column from looking alarmed on
every long-running VM backup.

No-data slots stay neutral via slate-500/30 instead of slate-400, so
empty rows don't draw the eye.
2026-05-20 19:04:53 +01:00
rcourtman 350a913ea4 Add click-sort to all three Proxmox Backups tabs
Was a gap, not an intentional decision. Storage has full click-sort
with arrow indicators; Replication and Ceph don't, but those have
8 jobs / 1 cluster respectively — easy to eyeball. Backups has 184
tasks and 189 archives, which is past the scannable threshold for
"which backup is biggest? which task ran slowest? which guest is
hoarding snapshot disk?" — exactly the questions a sysadmin opens
the page to answer.

Reuse Storage's pattern: per-column sortKey + sortDirection signals,
default direction per key (desc for numeric/timestamp, asc for
strings, desc for booleans), `aria-sort` on the TH, idle / asc /
desc arrow trio rendered the same way as StoragePoolsTable.tsx.

Numeric/string comparators put undefined values last regardless of
direction — flipping a running task with no finished duration to
the top of a desc sort would never be what the user wants. Boolean
comparator follows direction so Protected/Verified sort cleanly.

Default sort per tab preserves the current behaviour (newest first):
- Snapshots: latest desc
- Backup files: created desc
- Recent tasks: started desc

Sortable columns:
- Snapshots: guest, node, latest, snapshot count, total size
- Backup files: volume, guest, storage, node, format, created,
  size, protection, verified
- Recent tasks: status, guest, node, started, duration, size
2026-05-20 18:41:37 +01:00
rcourtman 09bf209afd Use canonical ProgressBar for backup row metric bars
The Backups Size and Duration columns were rendering a bespoke
`InlineProgressBar` — a small fixed-width bar floating next to text.
Every other row metric in Pulse (Storage usage, Ceph pool usage,
Workloads CPU/Memory/Disk) is rendered by `ProgressBar` from
`@/components/shared`: full-cell-width fill with the value text
overlaid on top, via a foreignObject-clipped label. Introducing a
second pattern just for these two columns was a needless divergence
called out in the second consistency pass.

Drop the bespoke component. Wrap shared `ProgressBar` in a
RowMetricBar helper that matches the Storage/Ceph convention
(`STORAGE_BAR_LABEL_WRAP_CLASS` shape) and use it for both columns.
The Size and Duration headers move to the `metric-bar` column kind
so alignment lines up with the bar's natural centring. Bars now
share the same shape, animation curve (320ms width transition from
`progress-fill`), and label-clipping behaviour as the rest of the
app — same code path, same look.
2026-05-20 18:21:03 +01:00
rcourtman 806a2331a3 Align inline progress bars so rows are visually comparable
The Size and Duration columns were rendering the bar to the LEFT of
the value text with `justify-end` on the whole flex group. Because
the text width varies row-to-row ("8 KB" vs "131 GB", "48m 0s" vs
"1h 23m 5s"), the bar's right edge shifted with the text, so two
bars in the same column did not share a common baseline. That
defeats the point of a comparison bar — you can't read relative
length when the bars are positioned differently per row.

Put the bar AFTER the text instead. Since the bar has a fixed width
and is now the rightmost element in the cell, every bar's left and
right edges land at the same x in every row, and the column reads
as a proper mini-chart. Always render the bar slot (empty track when
the underlying value is 0) so the column rhythm stays constant when
data is missing. Documented the invariant on the InlineProgressBar
helper.
2026-05-20 18:07:12 +01:00
rcourtman 2abb55f63f Second consistency pass on Proxmox backup tabs
Smaller details that diverged from sibling pages:

- Tone-colour the Recent tasks STATUS word per state (emerald / red /
  amber / muted), matching Replication's `text-emerald-600 dark:
  text-emerald-300` per-state tone. Was rendering everything in
  default base-content white.

- Move the Snapshots row age dot from the GUEST column to the LATEST
  column so the timestamp-and-its-freshness-dot live together — same
  pattern Backup files already uses in its Created column. The
  leftmost GUEST cell is now just chevron + label.

- Tighten counter wording. "189 entries" / "184 entries" → "189
  archives" / "184 tasks", matching Replication's "8 jobs" and Ceph's
  "1 clusters" domain-noun convention.

- Pill-style the Verified column in Backup files (emerald chip) so it
  reads as a sibling of the existing amber Protected chip rather than
  dot+text. Both columns now signal an explicit positive state with
  the same chip shape.

- Tighten inner snapshot-drilldown table header padding to py-0.5 to
  match the outer table's getPlatformTableHeadClass (was py-1).
2026-05-20 17:57:03 +01:00
rcourtman 01618589fe Align Proxmox backup tabs with sibling page conventions
Consistency pass after the initial backup-pages rework. Row-dot
colours, coverage-strip segments, and filter-chip labels were
encoding the same data through three different threshold schemes;
all three now share the per-tab SLA buckets so a 14d archive shows
amber in the row, sits in the amber stale (7–30d) segment of the
strip, and lands under the "Stale" filter chip.

Other tightening: hide the single-segment chart legend when there's
nothing to disambiguate (Snapshots and Backup files); always render
the Error column on Recent tasks (matching Replication, where it
shows "—" rather than collapsing); reword the Backup-files strip
tail from the ambiguous "guests covered" to "guests with archives";
strip the redundant inner-table border in the snapshot drilldown so
it reads like Ceph's borderless sub-tables; terse "X guests" or
"X of Y guests" counter on the Snapshots tab matching Replication's
"8 jobs" style.

Adds classifyArchiveRowAge / classifySnapshotRowAge in the summary
module with unit coverage for the threshold breakpoints, and drops
a dead archiveMetricMode() reference left over from the earlier
patch.
2026-05-20 17:50:25 +01:00
rcourtman 9730460f8a Bring Proxmox backup sub-pages up to Storage/Ceph visual density
The three Backups tabs (Snapshots, Backup files, Recent tasks)
enumerated artifacts in flat tables with almost no graphic encoding,
while sibling Proxmox pages (Overview, Storage, Ceph) lead with
coverage-shaped summaries and per-row visual signal. Aligns them.

Snapshots gains a per-day chart, filter chips (All / Recent ≤7d /
Stale >30d / With RAM), a coverage strip splitting guests by newest-
snapshot bucket, and a guest-grouped table whose rows expand to show
the per-guest snapshot history (same drilldown pattern as Ceph).

Backup files gains a coverage strip (current / stale / uncovered
guests against a 7-day SLA), a Count/Volume toggle so the chart can
answer "how much disk did backups take" not just "how many files",
an age dot on the Created column, and an inline size bar in the Size
column (relative to the largest file in view).

Recent tasks gains a coverage strip (OK / failed / running plus
median duration), an inline duration bar tone-coded against the
median, and the Error column is hidden when no task in view has an
error. Shared row-level age presentation lives in the new
proxmoxBackupSummaryPresentation module with unit coverage.
2026-05-20 17:37:46 +01:00