mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Compose retained Web and backup-safety work with current reviewed main
Preserve exact reviewed upstream, Core and Web histories, Tailwind 4, deterministic tags and additive browser-receipt governance. Resolve only the retired shared receipt by keeping its deletion. Combined frontend validation and fresh content receipts follow; native thaw and release acceptance remain separate. Change-source: pulse-maintainer
This commit is contained in:
@@ -3236,3 +3236,25 @@ The monitor delegates recovery eligibility to persisted occurrence/destination
|
||||
receipts even when an old retry remains or restart has lost RAM cooldown state.
|
||||
The partial-destination monitor control establishes a real local HTTP 200/503
|
||||
split and preserves the newer firing and the old recipient's recovery.
|
||||
|
||||
### Notification activity reports each evidence source independently
|
||||
|
||||
The existing Notifications activity card distinguishes an initial or pending
|
||||
read from a confirmed empty window. Held/deferred event reads have their own
|
||||
loading and unavailable state; a failed read withdraws their prior rows and
|
||||
keeps the warning until a later successful read. Only the current refresh owns
|
||||
those states, and disposed readers cannot publish late completions. A slow
|
||||
held-event read does not delay delivery attempts or disable their refresh.
|
||||
|
||||
A delivery-attempt failure withdraws only attempt rows, not independently
|
||||
readable held events. Conversely, missing held evidence does not hide readable
|
||||
attempts or imply that no notifications were held. The combined empty state
|
||||
requires both sources to be readable and no refresh pending. Retention, event
|
||||
limits, queue actions, permissions and notification delivery policy are unchanged.
|
||||
|
||||
`useNotificationDeliveryLog.test.tsx`, `useAlertDestinationsTabState.test.tsx`
|
||||
and `AlertDeliveryLogCard.test.tsx` exercise independent failure, recovery,
|
||||
initial loading and current-request ownership; the production Notifications
|
||||
tab browser fixture verifies the connected read/retry states at desktop and
|
||||
phone widths. These are synthetic frontend controls, not recipient delivery
|
||||
or containing-release acceptance.
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Update History consent does not infer the backup's restore scope
|
||||
|
||||
The confirmation names the observed running server separately from the selected
|
||||
entry's pre-/post-update versions. No version threshold or free-form notes stand
|
||||
in for actual scope: the current History API does not describe the manifest or
|
||||
active-store layout. Explain that installation-only recovery leaves active stores
|
||||
in place but may restore independent installation files; legacy recovery can
|
||||
also replace install-local configuration/data. Do not promise a settings/alerts
|
||||
rewind. Full-state recovery still requires a stopped service and a consistent
|
||||
backup of every active store, via the existing shipped recovery instructions.
|
||||
|
||||
Opening/cancelling consent sends no rollback request, pending consent rejects
|
||||
duplicate actions and cancellation, and a rejected request retains the warning
|
||||
and refreshes history. Mounted UpdateHistory tests and the production-CSS offline
|
||||
browser fixture exercise those states; neither proves a binary/data rollback.
|
||||
|
||||
|
||||
### Demo transactions share the service host, not just a workflow queue
|
||||
|
||||
Stable demo update and recovery keep their existing publication/activation,
|
||||
|
||||
@@ -20,6 +20,61 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Guest disk deferral and retained observations
|
||||
|
||||
The existing workload row, disk breakdown tooltip/list and guest Overview use
|
||||
one presentation for the fixed guest-read reason vocabulary. Operation locks,
|
||||
unverified lock clearance, an in-flight request, cooldown, incomplete response
|
||||
and capacity deferrals are not evidence that QGA is missing. Invalid VM identity
|
||||
remains distinct. A timeout describes uncertain completion and must not recommend
|
||||
restarting QGA during a backup or changing freeze settings.
|
||||
|
||||
`prev-` marks last-known disk values, not a fresh sample. Keep those values and
|
||||
their identity visible, with the explanation beside the disk evidence, including
|
||||
the breakdown tooltip and the phone-readable Overview. Show readable status even
|
||||
before the first filesystem sample; do not hide it behind an empty disk list or
|
||||
expose only a raw reason. A same-VM fresh observation withdraws the notice and
|
||||
updates values without remounting the row. Permission, disabled-agent and actual
|
||||
unavailable cases retain their own explanations.
|
||||
|
||||
Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`,
|
||||
`DiskList`, `StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
|
||||
and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no
|
||||
previous data and fresh resumption with production components and CSS on desktop
|
||||
Chromium and touch WebKit. Synthetic observations do not establish the native
|
||||
command-ID cause, guest thaw, fresh History collection or containing release.
|
||||
|
||||
|
||||
### Composed operator tables and conservative rollback consent
|
||||
|
||||
Phone storage tables use the canonical weighted-width helper and matching
|
||||
header/cell gutters. Compact labels shorten disk verdicts and capacity values,
|
||||
not their underlying health or measured units; full reasons stay in the real
|
||||
disk header and tooltips. Structured ZFS scan activity supplies a compact badge
|
||||
only during reported rebuild activity; its complete provider summary remains
|
||||
available. Stable disk History catalog/organisation/access ownership is unchanged.
|
||||
|
||||
Controller expansions carry the supplied kind-specific targets, absolute times,
|
||||
duration and cluster/namespace when the phone row omits those columns. Proxmox
|
||||
coverage distinguishes independent backups from guest-local snapshots; By date
|
||||
retains archive identifiers in hover/search while showing the format as detail.
|
||||
Phone identity weights leave every configured metric column visible.
|
||||
|
||||
UpdateHistory consent names both the requested target and the observed running
|
||||
version, separately from the version on the selected history entry. The API does
|
||||
not expose backup restore scope: do not infer it from a version cutoff, notes or
|
||||
a retained path. Explain installation-only versus legacy recovery and stopped,
|
||||
consistent full-state recovery without promising later settings will be reverted
|
||||
or preserved. Opening/cancelling never starts a write, rejection retains consent,
|
||||
and a pending request cannot be submitted again or cancelled.
|
||||
|
||||
Verification: the mounted UpdateHistory regression tests, existing kind-specific
|
||||
controller/storage/backup/workload tests and production-CSS browser fixture
|
||||
`frontend-modern/browser-tests/operator-tables-consent.cjs` exercise the composed
|
||||
source at desktop, intermediate and phone widths. Synthetic HTTP/snapshots do
|
||||
not establish native collectors, tenancy, installation recovery or release delivery.
|
||||
|
||||
|
||||
### Organisation-scoped single-metric History
|
||||
|
||||
Fetched HistoryChart observations belong to the active organisation as well as
|
||||
@@ -775,6 +830,28 @@ that manual-channel consequence must stay visible at desktop and narrow widths.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
### Transport-independent operator rows
|
||||
|
||||
Proxmox/Storage and Docker presenters retain the shared table/detail, responsive
|
||||
column weighting, relative-time and governed action primitives. Display names
|
||||
for Proxmox storage plugins preserve their operator vocabulary (LVM-Thin, NFS,
|
||||
SMB/CIFS and the other supported acronyms); only explicitly shared storage on a
|
||||
synthetic cluster/shared location gets the shared label. That label does not
|
||||
alter canonical identity, parent, History target or pool-to-disk ownership.
|
||||
|
||||
The Docker image phone projection pairs hidden headers and cells and gives
|
||||
measured size and update verdict room without hiding identity or dropping the
|
||||
existing detail disclosure. The container projection retains its shared 30%
|
||||
phone identity reservation and five scan signals; off-row restart/host/consumer
|
||||
evidence remains in detail. Swarm rollout tokens read as words, with compact
|
||||
Running headings and wrapping rollout states on phones; raw rollout and
|
||||
placement evidence remain reachable through keyboard/touch row disclosure.
|
||||
`platformOverviewLayout.guardrails.test.ts`, mounted
|
||||
Docker table tests and the desktop/touch-phone production-component browser
|
||||
fixture check these priorities, rather than treating an old percentage as an
|
||||
immutable primitive contract. No new public route or navigation entry is added.
|
||||
|
||||
|
||||
### Maintenance schedule copy
|
||||
|
||||
Operator maintenance banners show local absolute start and end timestamps,
|
||||
@@ -7856,3 +7933,21 @@ kind normalisation, the kind switch, the email-only payload, and the plain
|
||||
cover the gating, the reduced form, the pinned fields, and the saved payload.
|
||||
This is a presentation and form-state boundary; the schedule API contract,
|
||||
report rendering, and email delivery are unchanged.
|
||||
|
||||
### Notifications activity has independent evidence-read states
|
||||
|
||||
The Notifications tab composes the delivery-log card with both attempt-read
|
||||
and held/deferred-event loading and unavailable state from its owned reader.
|
||||
Pending or failed evidence is never rendered as a confirmed empty activity
|
||||
window. Each failed source withdraws its own rows without hiding independently
|
||||
readable rows from the other source. Source-specific warnings have `role="alert"`
|
||||
and loading text has `role="status"`; a slow held-event read does not block the
|
||||
existing attempt-refresh button. Recovery clears a warning only after a
|
||||
successful read, without remounting the destination editors or changing their
|
||||
unfinished input.
|
||||
|
||||
The connected Notifications browser fixture uses the real tab/state/API/card
|
||||
chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS,
|
||||
403/503 controls and pending recovery. Unit controls separately cover current
|
||||
request ownership and disposal. No mutation, route, navigation entry, queue
|
||||
policy, permission or recipient-delivery contract is added by these states.
|
||||
|
||||
@@ -17,6 +17,47 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Guest-agent coordination around backups — issue #2439
|
||||
|
||||
All PVE QEMU guest-agent reads, including disk diagnostics and legacy meminfo
|
||||
callers, share fail-fast per-guest admission within this Pulse process. Clients
|
||||
for the same endpoint and configured cluster aliases coordinate across methods;
|
||||
VMIDs remain serial through node migration. Other VMs and independent endpoints
|
||||
remain independent. No guest payload or credentials enter coordination state.
|
||||
Before each command and before returning its fully received bounded payload,
|
||||
Pulse reads the operation lock from VM config. Any nonempty lock (not just
|
||||
backup), missing/malformed config or an unverifiable lock defers the command.
|
||||
Known locks in node inventory, cluster resources or detailed status suppress
|
||||
polling enrichment even when status fails and recent agent evidence exists.
|
||||
|
||||
Admission is held through the complete response, not just headers. A transport
|
||||
failure, timeout, wrong-command-ID response or incomplete body defers every
|
||||
command for that guest for a minute; callers neither immediately retry nor
|
||||
replay through another cluster endpoint. Coordination retains at most 4,096
|
||||
active/cooling endpoint-guest entries and expires cooldowns. Explicit API
|
||||
permission refusals and unsupported commands remain errors, not successes.
|
||||
|
||||
Deferrals retain the original metadata/memory cache timestamps and supported-OS
|
||||
failure evidence. Locked guests retain labelled last-known disks/identity and
|
||||
previous-snapshot memory, or unavailable memory without trustworthy evidence.
|
||||
Current non-QGA memory, CPU and I/O may still be observed. History never writes
|
||||
retained disk values or deferred cached memory as newly observed telemetry;
|
||||
normal unlocked polling resumes and replaces them with fresh data.
|
||||
|
||||
`TestGuestAgentBackupMonitoringContract` exercises healthy → status lock →
|
||||
resource lock with failed status → node fallback → unlocked resumption through
|
||||
the real client/builders and History. The client controls cover every read,
|
||||
unknown lock state, diagnostic overlap, aliases/source/VM isolation, in-flight
|
||||
lock changes, incomplete bodies, cooldown and absence of failover replay.
|
||||
These reproduce missing Pulse safety controls, **not** the reported native
|
||||
PVE/QGA command-ID collision or successful backup thaw. HTTP lock checks cannot
|
||||
atomically reserve PVE's serial channel against a backup beginning afterwards,
|
||||
cancel work already running in pvedaemon, detect a guest left frozen after its
|
||||
lock clears, or coordinate separate Pulse processes. Native freeze/thaw acceptance
|
||||
on a containing build remains required. Until then, stopping Pulse before a
|
||||
freeze-enabled backup and restarting only after verified thaw remains the safe
|
||||
workaround, with monitoring and alerts unavailable during that window.
|
||||
|
||||
### Exact TrueNAS subscription termination — issue #2396
|
||||
|
||||
JSON-RPC stream readers and the subscription-acknowledgement wait recognise
|
||||
|
||||
@@ -15,6 +15,26 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Guest disk status updates retain row ownership
|
||||
|
||||
Disk deferral copy follows reactive same-VM props in the existing row and drawer,
|
||||
without new guest commands, client polling, History writes or row remounts. Retained
|
||||
values are labelled rather than replaced with invented fresh percentages; fresh
|
||||
observations update values and remove the status. Mounted row/list/drawer controls
|
||||
and `browser-tests/guest-disk-deferral.cjs` verify same-row ownership in bar and
|
||||
sparkline modes with desktop and phone CSS. This is client behaviour proof, not
|
||||
native backup coordination, new History samples or a fleet performance result.
|
||||
|
||||
### Phone workload identity weights
|
||||
|
||||
The existing phone guest projection allocates 38 percent to the name cell,
|
||||
which includes disclosure, status and backup controls, while normalising every
|
||||
remaining visible metric weight. Narrow/mobile/desktop modes and windowing
|
||||
ownership remain unchanged. Guest column regressions verify the sums and the
|
||||
production composed-table browser fixture checks identity and all configured
|
||||
metric tracks at narrow width; this is readability, not a throughput claim.
|
||||
|
||||
|
||||
### Bounded confidence/priority work in broadcast grouping — issue #2199
|
||||
|
||||
The general identity matcher retains every confidence, reason, ordering and
|
||||
@@ -301,6 +321,21 @@ the existing bounded HTTP client and artifact-size checks.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
### Multi-disk Bars summary
|
||||
|
||||
The existing vertical-bars workload/node disk cell shows the fullest measured
|
||||
disk's percentage beside equal-weight per-disk micro-bars. Its title retains
|
||||
the actual mount and percentage. Unknown/invalid usage cannot produce a new
|
||||
summary; an aggregate fallback must not masquerade as measured member usage.
|
||||
The displayed maximum uses the existing shared disk measurement and threshold
|
||||
logic; no collector, sorting key, metric units, History request, resampling or
|
||||
performance threshold changes. `StackedDiskBar.test.tsx` and the model branch
|
||||
controls cover real/unknown measurements and unchanged per-member bars.
|
||||
`browser-tests/proxmox-docker-rows.cjs` checks the actual label, bars and title
|
||||
at desktop and phone widths; this is presentation proof, not native collection
|
||||
or a measured fleet performance improvement.
|
||||
|
||||
|
||||
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
|
||||
@@ -21,6 +21,28 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Backup identity, phone storage and History consent composition
|
||||
|
||||
The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE
|
||||
backup files, never guest-local snapshots. Guest snapshots remain listed as
|
||||
restore evidence but cannot make an old independent backup look current.
|
||||
Archive detail uses its format (or filename fallback), preserving the full
|
||||
provider volid for hover and search in both Coverage and By date.
|
||||
|
||||
Phone pool/disk rows preserve health and identity while using weighted widths,
|
||||
consistent gutters and compact capacity/health labels. Full disk risk reasons
|
||||
move into the existing expanded header; the ZFS activity badge uses supplied
|
||||
scan data only when rebuild is reported and keeps the full summary as its title.
|
||||
Collection-independent, organisation-owned disk History remains intact.
|
||||
|
||||
The UpdateHistory confirmation is a version-rollback consent, not a full-state
|
||||
recovery promise. Its current API exposes no verified restore-scope metadata;
|
||||
copy must retain installation-only and legacy differences and direct full-state
|
||||
recovery to the stopped-service procedure. Regressions cover these distinctions,
|
||||
archive/snapshot attribution and storage values; the composed browser fixture
|
||||
checks real table/drawer rendering, not native or installed recovery.
|
||||
|
||||
|
||||
### Disk History is independent of current collection
|
||||
|
||||
The existing physical-disk History catalog is selected by disk family, not by
|
||||
@@ -378,6 +400,20 @@ command-capable profile.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
### Operator storage vocabulary without identity changes
|
||||
|
||||
Storage topology names preserve Proxmox plugin vocabulary, including LVM-Thin,
|
||||
NFS, SMB/CIFS, iSCSI, ZFS and BTRFS. Explicit topology overrides still win.
|
||||
Only a storage explicitly marked shared whose retained location is the synthetic
|
||||
cluster/shared marker reads Shared, with the supplied reporting-node count when
|
||||
more than one node is present. Node-local storage and shared storage with a
|
||||
real native location keep that location. This is host-label presentation, not
|
||||
resource consolidation: IDs, parent/node hints, ownership evidence, metrics
|
||||
targets, alerts, health and recovery authority are unchanged. Adapter/presentation
|
||||
regressions and the production storage table browser fixture verify labels
|
||||
without treating shared capacity as a backup or demonstrating native recovery.
|
||||
|
||||
|
||||
- After password configuration has been persisted and the runtime local-admin identity changes, authorizer synchronisation must also occur if subsequent API-token persistence fails. Development first-run reset must clear the configured-admin bypass only on successful auth reset, not on its persistence-failure rollback path. No role-store deletion or migration is part of this recovery.
|
||||
|
||||
### PBS host history correlation
|
||||
|
||||
@@ -23,6 +23,36 @@ and sort the complete canonical change table while startup and ingestion wait.
|
||||
|
||||
## Purpose
|
||||
|
||||
### Guest disk freshness presentation
|
||||
|
||||
Existing workload disk evidence retains its source reason through the row,
|
||||
breakdown and Overview. `prev-` values are explicitly last-known, while a
|
||||
deferral with no previous disks explains the missing observation without a
|
||||
measured percentage. Lock/uncertain-completion deferrals must not become an
|
||||
install/restart recommendation. Human-readable Overview status remains visible
|
||||
without filesystem rows and is cleared by a same-identity fresh observation.
|
||||
The shared presentation and mounted regressions plus the production-component
|
||||
`browser-tests/guest-disk-deferral.cjs` fixture verify this client behaviour;
|
||||
they do not prove native QGA coordination, History freshness or delivery.
|
||||
|
||||
|
||||
### Narrow controller and backup summaries retain their source facts
|
||||
|
||||
Kubernetes controller row expansions expose supplied kind-specific targets,
|
||||
counts, absolute timestamps, valid elapsed durations and namespace/cluster when
|
||||
responsive rows hide those columns. Missing facts are omitted and the ordinary
|
||||
pluralisation model handles singular targets. Job/CronJob timestamp regressions
|
||||
and the production composed-table browser fixture verify this existing summary.
|
||||
|
||||
Proxmox Coverage uses latestBackup for completed independent backup evidence,
|
||||
with guest-local snapshots retained separately. Responsive backup-server, host
|
||||
and guest rows allocate space through existing canonical weighted-width helpers;
|
||||
archive format is presentation only, with the provider identifier retained in
|
||||
hover and search. The recovery-model, table and guest regressions plus desktop/
|
||||
phone browser rendering verify attribution, visible facts and source composition.
|
||||
These frontend improvements change no collector, API authority or native status.
|
||||
|
||||
|
||||
### Resource evidence fallback access boundaries
|
||||
|
||||
When the existing resource-facet reader receives a final HTTP 401/403, its
|
||||
@@ -600,6 +630,29 @@ about the same disk cannot diverge.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
### REST facets and container table projection
|
||||
|
||||
The unified-resource REST mapper carries PMG metadata, per-source status,
|
||||
server action-readiness refusals and technology at the same top-level paths
|
||||
used by canonical websocket rows. Existing platformData compatibility paths
|
||||
remain; transport order must not decide whether mail counters or lifecycle
|
||||
refusal explanations are present. A replaced REST snapshot clears removed
|
||||
facets instead of retaining old counters or refusal reasons. Mapping does not
|
||||
invent capabilities or bypass planning, review, server authorisation or execution.
|
||||
`useUnifiedResources.test.ts` checks actual lifecycle refusal consumers for stale,
|
||||
failed and server-denied observations alongside a no-invented-grant control.
|
||||
|
||||
The Docker phone container projection keeps identity, state when applicable,
|
||||
CPU, memory and update; restart counts remain in the existing detail expansion
|
||||
and return at mobile width. Images retain image, size and update verdict on
|
||||
phones; host and consumers stay reachable in detail. Hidden header/body columns
|
||||
are symmetric. Swarm rollout tokens render as words with raw state retained in
|
||||
the title; task starts use the shared relative-time primitive with their exact
|
||||
timestamp retained. Native table/model/guardrail tests and the production
|
||||
`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing
|
||||
a container action or claiming native appliance or release acceptance.
|
||||
|
||||
|
||||
### PBS host history correlation
|
||||
|
||||
PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target.
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/guest-disk-deferral-proof';
|
||||
const runtime = [
|
||||
'frontend-modern/src/utils/workloadGuestPresentation.ts',
|
||||
'frontend-modern/src/components/Workloads/GuestRow.tsx',
|
||||
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
|
||||
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
|
||||
'frontend-modern/src/components/Workloads/DiskList.tsx',
|
||||
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
|
||||
];
|
||||
const deferrals = [
|
||||
['vm-locked', 'VM operation lock'],
|
||||
['lock-unverified', 'cannot verify that the VM is unlocked'],
|
||||
['agent-busy', 'earlier guest request is still in progress'],
|
||||
['agent-cooldown', 'after the cooldown'],
|
||||
['agent-response-incomplete', 'previous response was incomplete'],
|
||||
['agent-capacity', 'guest-read capacity'],
|
||||
['invalid-guest-key', 'VM identity is invalid'],
|
||||
['agent-timeout', 'Do not restart the guest agent during a backup'],
|
||||
];
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
assert.equal(
|
||||
playwrightVersion,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
const hashes = Object.fromEntries(
|
||||
runtime.map((p) => [
|
||||
p,
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join('/workspace', p)))
|
||||
.digest('hex'),
|
||||
]),
|
||||
);
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
server: { host: '127.0.0.1', port: 5294, strictPort: true },
|
||||
});
|
||||
const results = [],
|
||||
screenshots = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark] of [
|
||||
['desktop', chromium, 1365, false],
|
||||
['phone', webkit, 390, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
isMobile: width === 390,
|
||||
hasTouch: width === 390,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
page.setDefaultTimeout(15000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const errors = [],
|
||||
checks = [];
|
||||
page.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
process.stderr.write(`BROWSER_PAGE_ERROR ${e.message}\n`);
|
||||
});
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error')
|
||||
process.stderr.write(`BROWSER_CONSOLE ${m.text().slice(0, 1500)}\n`);
|
||||
});
|
||||
page.on('response', (r) => {
|
||||
if (r.status() >= 400)
|
||||
process.stderr.write(`BROWSER_HTTP ${r.status()} ${new URL(r.url()).pathname}\n`);
|
||||
});
|
||||
await page.route('http://127.0.0.1:5294/api/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: [],
|
||||
alerts: [],
|
||||
anomalies: [],
|
||||
config: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.goto('http://127.0.0.1:5294/browser-tests/guest-disk-deferral.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
try {
|
||||
await page.waitForFunction(() => window.__guestDiskEvidence, null, { timeout: 15000 });
|
||||
} catch (error) {
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'failure.json'),
|
||||
JSON.stringify({ phase: 'fixture-readiness', errors }, null, 2),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]');
|
||||
const cell = row.locator('[data-workload-col="disk"]');
|
||||
await row.click();
|
||||
const drawer = page.getByRole('region', { name: 'Guest Overview' });
|
||||
await drawer.waitFor({ state: 'visible' });
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(window.__originalDiskRow = document.querySelector(
|
||||
'[data-guest-id="fixture-pve:pve-a:101"]',
|
||||
)),
|
||||
);
|
||||
const update = async (reason, data, usage = 50) => {
|
||||
await page.evaluate((value) => window.__guestDiskEvidence.update(value), {
|
||||
reason,
|
||||
data,
|
||||
usage,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') ===
|
||||
window.__originalDiskRow,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__guestDiskEvidence.identity()),
|
||||
'fixture-pve:pve-a:101',
|
||||
);
|
||||
};
|
||||
const capture = async (suffix) => {
|
||||
const p = path.join(output, `${name}-${suffix}.png`);
|
||||
await page.screenshot({ path: p, fullPage: true });
|
||||
screenshots.push({
|
||||
path: path.relative('/workspace', p),
|
||||
sha256: crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'),
|
||||
});
|
||||
};
|
||||
for (const [reason, expected] of deferrals) {
|
||||
await update(`prev-${reason}`, true);
|
||||
await page.waitForFunction(
|
||||
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
|
||||
expected,
|
||||
);
|
||||
const title = await cell.getAttribute('title');
|
||||
assert(title.startsWith('Using last known disk stats. '));
|
||||
assert(title.includes(expected));
|
||||
assert(
|
||||
!title.includes('may not be installed') && !title.includes('may need to be restarted'),
|
||||
);
|
||||
assert((await drawer.innerText()).includes(title));
|
||||
const statusBox = await drawer.getByText(title, { exact: true }).boundingBox();
|
||||
assert(
|
||||
statusBox.width >= 100 && statusBox.height < 220,
|
||||
'status explanation fits its card',
|
||||
);
|
||||
assert((await drawer.innerText()).includes('/data'));
|
||||
assert(
|
||||
(await page.getByRole('region', { name: 'Guest disk list' }).innerText()).includes(title),
|
||||
);
|
||||
if (name === 'desktop') {
|
||||
// The disclosure click can land on this bar and dismiss its hover
|
||||
// overlay on pointerdown. Leave it before exercising a new hover.
|
||||
await page.locator('h1').hover();
|
||||
await cell.locator('[data-stacked-disk-trigger]').hover();
|
||||
await page.locator('[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
|
||||
assert((await page.locator('[data-tooltip-portal="true"]').innerText()).includes(title));
|
||||
await page.locator('h1').hover();
|
||||
}
|
||||
checks.push(`${reason}: retained row, tooltip, drawer, list and identity`);
|
||||
if (reason === 'vm-locked') await capture('retained-backup');
|
||||
await update(reason, false);
|
||||
await page.waitForFunction(
|
||||
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
|
||||
expected,
|
||||
);
|
||||
assert(!(await cell.getAttribute('title')).includes('Using last known'));
|
||||
assert((await drawer.innerText()).includes(expected));
|
||||
assert(!(await drawer.innerText()).includes('/data'));
|
||||
assert.equal(await cell.locator('[data-stacked-disk-trigger]').count(), 0);
|
||||
checks.push(`${reason}: no previous disk values and no fresh percentage`);
|
||||
if (reason === 'agent-timeout') await capture('uncertain-timeout');
|
||||
}
|
||||
for (const [reason, expected] of [
|
||||
['permission-denied', 'Permission denied'],
|
||||
['agent-disabled', 'disabled in VM configuration'],
|
||||
['agent-not-running', 'Guest agent not running'],
|
||||
['no-data', 'No disk data available'],
|
||||
['vm-stopped', 'VM is stopped'],
|
||||
['no-status', 'could not read the VM status'],
|
||||
['agent-error', 'Error communicating with guest agent'],
|
||||
]) {
|
||||
await update(reason, false);
|
||||
await page.waitForFunction(
|
||||
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
|
||||
expected,
|
||||
);
|
||||
assert((await drawer.innerText()).includes(expected));
|
||||
checks.push(`${reason}: existing unavailable case remains distinct`);
|
||||
}
|
||||
await update('prev-agent-cooldown', true);
|
||||
await page.evaluate(() => window.__guestDiskEvidence.mode('sparklines'));
|
||||
assert((await cell.getAttribute('title')).includes('Using last known'));
|
||||
await update('', true, 75);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-workload-col="disk"]').hasAttribute('title'),
|
||||
);
|
||||
assert(!(await drawer.innerText()).includes('Using last known'));
|
||||
assert(!(await drawer.innerText()).includes('Guest reads'));
|
||||
assert.equal(await drawer.getByRole('progressbar').getAttribute('aria-valuenow'), '75');
|
||||
await page.evaluate(() => window.__guestDiskEvidence.mode('bars'));
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') ===
|
||||
window.__originalDiskRow,
|
||||
),
|
||||
true,
|
||||
);
|
||||
checks.push(
|
||||
'same-VM fresh resumption removes notice and updates values in both row modes without remount',
|
||||
);
|
||||
await capture('fresh-resumption');
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
results.push({
|
||||
name,
|
||||
browser_version: browser.version(),
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
checks,
|
||||
page_errors: errors,
|
||||
});
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
const receipt = {
|
||||
version: 1,
|
||||
result: 'passed',
|
||||
verified_at: new Date().toISOString(),
|
||||
content_sha256: hashes,
|
||||
playwright_version: playwrightVersion,
|
||||
results,
|
||||
screenshots,
|
||||
limitation:
|
||||
'Synthetic same-VM observations through production components and CSS; no native QGA/backup, History sample, installation or release acceptance.',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'browser-result.json'),
|
||||
JSON.stringify(receipt, null, 2) + '\n',
|
||||
);
|
||||
process.stdout.write(JSON.stringify(receipt) + '\n');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((e) => {
|
||||
process.stderr.write(e.stack + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Guest disk deferral verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./guest-disk-deferral.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
// Production row, disk tooltip, list and disclosed Overview with synthetic
|
||||
// same-VM observations. No collector, QGA, native backup or release proof.
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { GuestRow } from '../src/components/Workloads/GuestRow';
|
||||
import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview';
|
||||
import { DiskList } from '../src/components/Workloads/DiskList';
|
||||
import type { VM } from '../src/types/api';
|
||||
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||
import '../src/index.css';
|
||||
|
||||
function Fixture() {
|
||||
const [observation, setObservation] = createSignal({
|
||||
reason: 'prev-vm-locked',
|
||||
data: true,
|
||||
usage: 50,
|
||||
});
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
const [mode, setMode] = createSignal<'bars' | 'sparklines'>('bars');
|
||||
const guest = (): VM => ({
|
||||
id: 'fixture-pve:pve-a:101',
|
||||
vmid: 101,
|
||||
name: 'backup-guest',
|
||||
node: 'pve-a',
|
||||
instance: 'fixture-pve',
|
||||
status: 'running',
|
||||
type: 'qemu',
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
|
||||
disk: {
|
||||
total: 10 * 1024 ** 3,
|
||||
used: observation().data ? (observation().usage / 100) * 10 * 1024 ** 3 : 0,
|
||||
usage: observation().data ? observation().usage : -1,
|
||||
},
|
||||
disks: observation().data
|
||||
? [
|
||||
{
|
||||
mountpoint: '/data',
|
||||
type: 'ext4',
|
||||
total: 10 * 1024 ** 3,
|
||||
used: (observation().usage / 100) * 10 * 1024 ** 3,
|
||||
usage: observation().usage,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
diskStatusReason: observation().reason,
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: observation().reason.endsWith('vm-locked') ? 'backup' : '',
|
||||
backupInProgress: observation().reason.endsWith('vm-locked'),
|
||||
lastSeen: '2026-10-03T14:00:00Z',
|
||||
});
|
||||
(window as any).__guestDiskEvidence = {
|
||||
update: setObservation,
|
||||
mode: setMode,
|
||||
identity: () => guest().id,
|
||||
};
|
||||
return (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Guest disk observation</h1>
|
||||
<table class="w-full table-fixed">
|
||||
<tbody>
|
||||
<GuestRow
|
||||
guest={guest()}
|
||||
visibleColumnIds={['name', 'disk']}
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
isExpanded={expanded()}
|
||||
metricDisplayMode={mode()}
|
||||
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={expanded()}>
|
||||
<section aria-label="Guest Overview">
|
||||
<GuestDrawerOverview
|
||||
guest={guest()}
|
||||
guestOsSummary="Linux"
|
||||
agentHeading="Guest agent"
|
||||
agentLabel=""
|
||||
agentTitle=""
|
||||
hasAgentInfo={false}
|
||||
hasFilesystemDetails={guest().disks!.length > 0}
|
||||
hasNetworkInterfaces={false}
|
||||
hasOsInfo={false}
|
||||
hasWorkloadActionAgent={false}
|
||||
showInGuestAgentInstallCue={false}
|
||||
ipAddresses={[]}
|
||||
networkInterfaces={[]}
|
||||
normalizedTags={[]}
|
||||
backupPresentation={null}
|
||||
workloadActionAgentTitle=""
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<section aria-label="Guest disk list" class="max-w-xl">
|
||||
<h2 class="text-sm font-medium">Filesystems</h2>
|
||||
<DiskList disks={guest().disks!} diskStatusReason={guest().diskStatusReason} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={() => (
|
||||
<DarkModeContext.Provider
|
||||
value={() => document.documentElement.classList.contains('dark')}
|
||||
>
|
||||
<Fixture />
|
||||
</DarkModeContext.Provider>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,383 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
const output = '/workspace/tmp/notification-evidence-proof';
|
||||
const root = '/workspace/frontend-modern';
|
||||
const runtime = [
|
||||
'frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx',
|
||||
'frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts',
|
||||
'frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts',
|
||||
'frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx',
|
||||
'frontend-modern/src/utils/alertDestinationsPresentation.ts',
|
||||
];
|
||||
const log = (entries = []) => ({
|
||||
entries,
|
||||
window_days: 30,
|
||||
completed_retention_days: 7,
|
||||
dead_letter_retention_days: 30,
|
||||
});
|
||||
const sent = {
|
||||
notificationId: 'synthetic-attempt',
|
||||
type: 'email',
|
||||
outcome: 'sent',
|
||||
alertIds: ['synthetic-disk-alert'],
|
||||
alertCount: 1,
|
||||
attempts: 1,
|
||||
success: true,
|
||||
timestamp: '2026-10-03T10:00:00Z',
|
||||
};
|
||||
const held = [
|
||||
{
|
||||
id: 42,
|
||||
type: 'notification_deferred',
|
||||
alertId: 'synthetic-held-alert',
|
||||
resourceName: 'synthetic-nas',
|
||||
alertType: 'usage',
|
||||
reason: 'quiet_hours:performance',
|
||||
occurredAt: '2026-10-03T10:01:00Z',
|
||||
},
|
||||
];
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
assert.equal(
|
||||
playwrightVersion,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
const hashes = Object.fromEntries(
|
||||
runtime.map((p) => [
|
||||
p,
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join('/workspace', p)))
|
||||
.digest('hex'),
|
||||
]),
|
||||
);
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
server: { host: '127.0.0.1', port: 5291, strictPort: true },
|
||||
});
|
||||
const results = [],
|
||||
screens = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark] of [
|
||||
['desktop', chromium, 1365, false],
|
||||
['phone', webkit, 390, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
isMobile: width === 390,
|
||||
hasTouch: width === 390,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
page.setDefaultTimeout(20000);
|
||||
const errors = [],
|
||||
offOrigin = [],
|
||||
writes = [],
|
||||
requests = [],
|
||||
checks = [],
|
||||
releases = [];
|
||||
let attemptMode = 'hold-empty',
|
||||
heldMode = 'hold-empty';
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.route('**/*', async (route) => {
|
||||
const request = route.request(),
|
||||
url = new URL(request.url());
|
||||
if (url.origin !== 'http://127.0.0.1:5291') {
|
||||
offOrigin.push(url.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (request.method() !== 'GET') {
|
||||
writes.push({ method: request.method(), path: url.pathname });
|
||||
return route.abort();
|
||||
}
|
||||
if (
|
||||
url.pathname === '/api/notifications/delivery-log' ||
|
||||
url.pathname === '/api/alerts/events'
|
||||
) {
|
||||
const isHeld = url.pathname === '/api/alerts/events';
|
||||
const mode = isHeld ? heldMode : attemptMode;
|
||||
requests.push({ path: url.pathname, query: url.search, mode });
|
||||
if (mode.startsWith('hold-'))
|
||||
await new Promise((resolve) => releases.push({ isHeld, resolve }));
|
||||
if (mode.endsWith('denied'))
|
||||
return route.fulfill({
|
||||
status: isHeld ? 403 : 503,
|
||||
json: { error: 'Synthetic unavailable evidence' },
|
||||
});
|
||||
return route.fulfill({
|
||||
json: isHeld
|
||||
? mode.endsWith('known')
|
||||
? held
|
||||
: []
|
||||
: log(mode.endsWith('known') ? [sent] : []),
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/notifications/health')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
overall_healthy: true,
|
||||
queue: {
|
||||
pending: 0,
|
||||
sending: 0,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
dlq: 0,
|
||||
healthy: true,
|
||||
status: 'healthy',
|
||||
attention_required: 0,
|
||||
reason_codes: [],
|
||||
completed_retention_days: 7,
|
||||
dead_letter_retention_days: 30,
|
||||
counts_are_retention_bounded: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/license/entitlements')
|
||||
return route.fulfill({ json: { entitlements: [] } });
|
||||
if (url.pathname === '/api/security/status')
|
||||
return route.fulfill({ json: { hasAuthentication: true, requiresAuth: false } });
|
||||
return route.fulfill({ json: [] });
|
||||
});
|
||||
const check = async (label, action) => {
|
||||
await action();
|
||||
checks.push({ label, passed: true });
|
||||
};
|
||||
const activity = page.locator('#notification-delivery-activity');
|
||||
const empty = activity.getByText(/No alert deliveries were attempted/);
|
||||
const heldWarning = activity.getByText(/could not read held or deferred notifications/);
|
||||
const attemptWarning = activity.getByText(/could not read the delivery log/);
|
||||
const refresh = activity.getByRole('button', { name: 'Refresh delivery status' });
|
||||
const release = (isHeld) => {
|
||||
for (const pending of releases.splice(0).filter((item) => {
|
||||
if (item.isHeld === isHeld) {
|
||||
item.resolve();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}))
|
||||
releases.push(pending);
|
||||
};
|
||||
const capture = async (state) => {
|
||||
const filename = `${name}-${state}.png`;
|
||||
await activity.screenshot({ path: path.join(output, filename) });
|
||||
screens.push({
|
||||
file: filename,
|
||||
state,
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
sha256: crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(output, filename)))
|
||||
.digest('hex'),
|
||||
});
|
||||
};
|
||||
try {
|
||||
await page.goto('http://127.0.0.1:5291/browser-tests/notification-evidence.html');
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
dark,
|
||||
);
|
||||
await check('Initial reads are loading, not empty', async () => {
|
||||
await activity.getByText('Loading delivery attempts...').waitFor();
|
||||
await activity.getByText('Loading held and deferred notifications...').waitFor();
|
||||
assert.equal(await empty.count(), 0);
|
||||
});
|
||||
await check(
|
||||
'A slow held read does not block the successful attempts read or its refresh',
|
||||
async () => {
|
||||
release(false);
|
||||
await activity.getByText('Loading delivery attempts...').waitFor({ state: 'hidden' });
|
||||
assert.equal(await refresh.isEnabled(), true);
|
||||
assert.equal(await empty.count(), 0);
|
||||
await activity.getByText('Loading held and deferred notifications...').waitFor();
|
||||
},
|
||||
);
|
||||
await capture('held-pending');
|
||||
await check('Only two completed empty reads establish the empty window', async () => {
|
||||
release(true);
|
||||
await empty.waitFor();
|
||||
assert.equal(await activity.getByRole('status').count(), 0);
|
||||
});
|
||||
await check(
|
||||
'Keyboard or touch refresh shows attempts and held events from their real API adapters',
|
||||
async () => {
|
||||
attemptMode = heldMode = 'known';
|
||||
if (width === 390) await refresh.tap();
|
||||
else {
|
||||
await refresh.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await activity.getByText('Delivered', { exact: true }).waitFor();
|
||||
await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
|
||||
assert.equal(await empty.count(), 0);
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'Held-event 403 withdraws held rows and warns without hiding delivered evidence',
|
||||
async () => {
|
||||
heldMode = 'denied';
|
||||
await refresh.click();
|
||||
await heldWarning.waitFor();
|
||||
assert.equal(
|
||||
await activity.getByText('synthetic-nas (usage)', { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1);
|
||||
assert.equal(await empty.count(), 0);
|
||||
},
|
||||
);
|
||||
await capture('held-unavailable');
|
||||
await check('The held warning persists during a pending retry', async () => {
|
||||
heldMode = 'hold-empty';
|
||||
await refresh.click();
|
||||
await activity.getByText('Loading held and deferred notifications...').waitFor();
|
||||
assert.equal(await heldWarning.count(), 1);
|
||||
await refresh.waitFor({ state: 'visible' });
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('#notification-delivery-activity button').disabled,
|
||||
);
|
||||
assert.equal(await empty.count(), 0);
|
||||
release(true);
|
||||
await heldWarning.waitFor({ state: 'hidden' });
|
||||
});
|
||||
await check(
|
||||
'An attempt-read failure keeps independently readable held evidence',
|
||||
async () => {
|
||||
attemptMode = 'denied';
|
||||
heldMode = 'known';
|
||||
await refresh.click();
|
||||
await attemptWarning.waitFor();
|
||||
await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
|
||||
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 0);
|
||||
assert.equal(await empty.count(), 0);
|
||||
},
|
||||
);
|
||||
await capture('attempts-unavailable');
|
||||
await check('Both failed sources are reported, not called empty', async () => {
|
||||
heldMode = 'denied';
|
||||
await refresh.click();
|
||||
await heldWarning.waitFor();
|
||||
assert.equal(await attemptWarning.count(), 1);
|
||||
assert.equal(await activity.getByRole('listitem').count(), 0);
|
||||
assert.equal(await empty.count(), 0);
|
||||
});
|
||||
await check(
|
||||
'Both successful reads clear the warnings and restore a genuine empty state',
|
||||
async () => {
|
||||
attemptMode = heldMode = 'empty';
|
||||
await refresh.click();
|
||||
await empty.waitFor();
|
||||
assert.equal(await activity.getByRole('alert').count(), 0);
|
||||
assert.equal(await activity.getByRole('status').count(), 0);
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'No horizontal clipping, JavaScript page error, off-origin request or mutation',
|
||||
async () => {
|
||||
assert.equal(
|
||||
await activity.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(offOrigin, []);
|
||||
assert.deepEqual(writes, []);
|
||||
assert(
|
||||
requests
|
||||
.filter((r) => r.path === '/api/alerts/events')
|
||||
.every(
|
||||
(r) => r.query.includes('limit=100') && r.query.includes('notification_deferred'),
|
||||
),
|
||||
);
|
||||
assert(
|
||||
requests
|
||||
.filter((r) => r.path === '/api/notifications/delivery-log')
|
||||
.every((r) => r.query === '?limit=200'),
|
||||
);
|
||||
},
|
||||
);
|
||||
results.push({
|
||||
name,
|
||||
browser_version: browser.version(),
|
||||
dark,
|
||||
checks,
|
||||
requests,
|
||||
errors,
|
||||
offOrigin,
|
||||
writes,
|
||||
});
|
||||
} finally {
|
||||
for (const pending of releases) pending.resolve();
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
playwright_version: playwrightVersion,
|
||||
content_sha256: hashes,
|
||||
changed_paths: runtime,
|
||||
results,
|
||||
screenshots: screens,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
passed: true,
|
||||
checks: results.reduce((n, r) => n + r.checks.length, 0),
|
||||
screenshots: screens.length,
|
||||
playwright_version: playwrightVersion,
|
||||
results: results.map((r) => ({ name: r.name, browser_version: r.browser_version })),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'failed.json'),
|
||||
JSON.stringify(
|
||||
{ passed: false, error: error.message, results, screenshots: screens },
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notification evidence verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./notification-evidence.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
// The production Notifications tab and API readers with synthetic read responses.
|
||||
// No notification is sent, retried or dismissed by this fixture.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { DestinationsTab } from '../src/features/alerts/tabs/DestinationsTab';
|
||||
import type { UIEmailConfig, UIAppriseConfig } from '../src/features/alerts/types';
|
||||
import '../src/index.css';
|
||||
|
||||
function Fixture() {
|
||||
const [emailConfig, setEmailConfig] = createSignal<UIEmailConfig>({
|
||||
enabled: false,
|
||||
provider: 'smtp',
|
||||
server: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from: '',
|
||||
to: [],
|
||||
tls: true,
|
||||
startTLS: true,
|
||||
replyTo: '',
|
||||
maxRetries: 3,
|
||||
retryDelay: 5,
|
||||
rateLimit: 60,
|
||||
});
|
||||
const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>({
|
||||
enabled: false,
|
||||
mode: 'cli',
|
||||
targetsText: '',
|
||||
cliPath: '',
|
||||
timeoutSeconds: 20,
|
||||
serverUrl: '',
|
||||
configKey: '',
|
||||
apiKey: '',
|
||||
apiKeyHeader: 'X-API-KEY',
|
||||
skipTlsVerify: false,
|
||||
hasApiKey: false,
|
||||
});
|
||||
return (
|
||||
<main class="mx-auto max-w-4xl p-3 text-base-content">
|
||||
<h1 class="mb-4 text-xl font-semibold">Notifications</h1>
|
||||
<DestinationsTab
|
||||
emailConfig={emailConfig}
|
||||
setEmailConfig={setEmailConfig}
|
||||
appriseConfig={appriseConfig}
|
||||
setAppriseConfig={setAppriseConfig}
|
||||
setHasUnsavedChanges={() => {}}
|
||||
configLoadError={() => null}
|
||||
isRetrying={() => false}
|
||||
isLoadingDestinations={() => false}
|
||||
onRetryLoad={() => {}}
|
||||
webhooks={() => []}
|
||||
deadManPingUrl={() => ''}
|
||||
setDeadManPingUrl={() => {}}
|
||||
pushMinimumSeverity={() => 'all'}
|
||||
setPushMinimumSeverity={() => {}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/*" component={Fixture} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,419 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/operator-tables-proof';
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
process.chdir(root);
|
||||
const playwright = require('playwright/package.json').version;
|
||||
assert.equal(
|
||||
playwright,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json')));
|
||||
const hashes = Object.fromEntries(
|
||||
paths.map((p) => [
|
||||
p,
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join('/workspace', p)))
|
||||
.digest('hex'),
|
||||
]),
|
||||
);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
server: { host: '127.0.0.1', port: 5271, strictPort: true },
|
||||
});
|
||||
const results = [],
|
||||
screens = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width] of [
|
||||
['desktop', chromium, 1365],
|
||||
['phone', webkit, 390],
|
||||
['intermediate', chromium, 768],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
isMobile: width === 390,
|
||||
hasTouch: width === 390,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
|
||||
const errors = [],
|
||||
offOrigin = [],
|
||||
checks = [],
|
||||
requests = [];
|
||||
let rollbackMode = 'reject',
|
||||
finishRollback;
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.setDefaultTimeout(20000);
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5271') {
|
||||
offOrigin.push(url.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/updates/history')
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{
|
||||
event_id: 'fixture-update-1',
|
||||
timestamp: '2026-10-02T12:00:00Z',
|
||||
action: 'update',
|
||||
channel: 'stable',
|
||||
version_from: '6.4.5',
|
||||
version_to: '6.4.6',
|
||||
deployment_type: 'systemd',
|
||||
initiated_by: 'user',
|
||||
initiated_via: 'ui',
|
||||
status: 'success',
|
||||
duration_ms: 30000,
|
||||
backup_path: '/private/fixture-backup',
|
||||
notes: 'untrusted restore-everything note',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (url.pathname === '/api/version')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
version: '6.6.0-dev',
|
||||
build: 'synthetic',
|
||||
runtime: 'community',
|
||||
isDocker: false,
|
||||
isSourceBuild: true,
|
||||
isDevelopment: false,
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/updates/rollback') {
|
||||
requests.push({ method: route.request().method(), body: route.request().postDataJSON() });
|
||||
if (rollbackMode === 'hold')
|
||||
await new Promise((resolve) => {
|
||||
finishRollback = resolve;
|
||||
});
|
||||
return rollbackMode === 'accept'
|
||||
? route.fulfill({
|
||||
json: { status: 'accepted', message: 'Synthetic acceptance, not recovery' },
|
||||
})
|
||||
: route.fulfill({
|
||||
status: 409,
|
||||
json: { error: 'Synthetic incompatible backup scope' },
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/license/entitlements')
|
||||
return route.fulfill({ json: { entitlements: [] } });
|
||||
if (url.pathname.includes('/facets'))
|
||||
return route.fulfill({
|
||||
json: { facets: [], capabilities: [], relationships: [], recentChanges: [] },
|
||||
});
|
||||
if (url.pathname === '/api/security/status')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: true },
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/metrics-store/history')
|
||||
return route.fulfill({ json: { points: [], metrics: {}, source: 'store' } });
|
||||
return route.fulfill({
|
||||
json: {
|
||||
enabled: false,
|
||||
data: [],
|
||||
alerts: {},
|
||||
facets: [],
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
const check = async (label, run) => {
|
||||
try {
|
||||
await run();
|
||||
checks.push({ label, passed: true });
|
||||
} catch (e) {
|
||||
checks.push({ label, passed: false, error: e.message });
|
||||
}
|
||||
};
|
||||
const select = async (name) => {
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Fixture views' })
|
||||
.getByRole('button', { name, exact: true })
|
||||
.click();
|
||||
};
|
||||
const capture = async (state) => {
|
||||
// Await the production entrance animation instead of capturing translucent frames.
|
||||
if (await page.getByRole('dialog').count()) {
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.querySelector('[role=dialog]');
|
||||
return panel && Number(getComputedStyle(panel).opacity) >= 0.999;
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(100); // ResizeObserver layout, not freshness proof.
|
||||
const p = `${name}-${state}.png`;
|
||||
await page.screenshot({ path: path.join(output, p), fullPage: true });
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
width: innerWidth,
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
screens.push({ file: p, state, browser: name, width, dimensions });
|
||||
};
|
||||
await page.goto('http://127.0.0.1:5271/browser-tests/operator-tables-consent.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120000,
|
||||
});
|
||||
if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await check('Pool state and measured capacity survive the phone projection', async () => {
|
||||
const pool = page.getByRole('region', { name: 'Pool table' });
|
||||
await pool.getByText('archive-tank', { exact: true }).waitFor();
|
||||
await pool.getByText('Degraded', { exact: true }).waitFor();
|
||||
const state = pool.locator('tbody td[data-storage-column="state"]').first();
|
||||
const bounds = await state.evaluate((e) => ({
|
||||
width: e.clientWidth,
|
||||
scroll: e.scrollWidth,
|
||||
text: e.textContent,
|
||||
}));
|
||||
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
|
||||
if (width >= 544) {
|
||||
await pool.getByText('Resilvering 45%', { exact: true }).waitFor();
|
||||
await pool.getByText('Scrubbing 45%', { exact: true }).waitFor();
|
||||
assert.ok(
|
||||
await pool
|
||||
.locator('[title="ZFS pool archive-tank resilver is running (45.2%)"]')
|
||||
.count(),
|
||||
);
|
||||
} else {
|
||||
assert.ok((await pool.getByText('59%', { exact: true }).count()) >= 2);
|
||||
}
|
||||
await capture('pools');
|
||||
});
|
||||
if (width !== 768) {
|
||||
await select('Disks');
|
||||
await check('Disk row and expanded header retain the full SMART reason', async () => {
|
||||
await page.getByText('Archive HDD', { exact: true }).waitFor();
|
||||
await page
|
||||
.locator('[data-row-id="disk-sda"]')
|
||||
.getByText('Archive HDD', { exact: true })
|
||||
.click();
|
||||
const health = page.getByTestId('disk-detail-health');
|
||||
await health.getByText('Replace Now', { exact: true }).waitFor();
|
||||
assert.ok(
|
||||
(await health.innerText()).includes('SMART failed. The full reason remains readable'),
|
||||
);
|
||||
const bounds = await health.evaluate((e) => ({
|
||||
width: e.clientWidth,
|
||||
scroll: e.scrollWidth,
|
||||
}));
|
||||
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
|
||||
await capture('disk-reason');
|
||||
});
|
||||
await select('Controllers');
|
||||
await check(
|
||||
'Job target and absolute started/completed times are reachable from a narrow row',
|
||||
async () => {
|
||||
await page.getByText('nightly-import', { exact: true }).click();
|
||||
const details = page.getByTestId('resource-kubernetes-controller-section');
|
||||
await details.getByText('Started', { exact: true }).waitFor();
|
||||
await details.getByText('Completed', { exact: true }).waitFor();
|
||||
await details.getByText('5m', { exact: true }).waitFor();
|
||||
await details.getByText('1 completion', { exact: true }).waitFor();
|
||||
assert.ok((await details.innerText()).includes('2026'));
|
||||
await capture('job-times');
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'CronJob retains last-run, last-success, schedule and suspension in its expansion',
|
||||
async () => {
|
||||
await page.getByText('billing-rollup', { exact: true }).click();
|
||||
const details = page.getByTestId('resource-kubernetes-controller-section');
|
||||
await details.getByText('Last run', { exact: true }).waitFor();
|
||||
await details.getByText('Last success', { exact: true }).waitFor();
|
||||
await details.getByText('*/5 * * * *', { exact: true }).waitFor();
|
||||
await details.getByText('Suspended', { exact: true }).waitFor();
|
||||
await capture('cron-times');
|
||||
},
|
||||
);
|
||||
}
|
||||
await select('Backups');
|
||||
await check(
|
||||
'Coverage last backup remains older than a fresh local guest snapshot',
|
||||
async () => {
|
||||
const coverage = page.locator('[data-proxmox-backups-table="coverage"]');
|
||||
await coverage.waitFor();
|
||||
const columns = await coverage
|
||||
.locator('colgroup col')
|
||||
.evaluateAll((els) => els.map((e) => e.dataset.proxmoxBackupsColumn));
|
||||
const row = coverage.locator('tr[data-proxmox-backup-row=coverage]');
|
||||
const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText();
|
||||
assert.ok(backupAge.includes('13d'), backupAge);
|
||||
assert.ok(!backupAge.includes('30m'), backupAge);
|
||||
if (columns.includes('snapshot')) {
|
||||
assert.ok(
|
||||
(await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes(
|
||||
'30m',
|
||||
),
|
||||
);
|
||||
}
|
||||
await capture('backup-attribution');
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'By date keeps archive format visible and full identifier available at wide widths',
|
||||
async () => {
|
||||
const byDate = page.locator('[data-proxmox-backups-table="recoverable"]');
|
||||
await byDate.waitFor();
|
||||
if (width >= 1120) {
|
||||
await byDate.getByText('tar.zst', { exact: true }).waitFor();
|
||||
assert.ok(
|
||||
await byDate
|
||||
.locator('[title="local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst"]')
|
||||
.count(),
|
||||
);
|
||||
}
|
||||
assert.ok((await page.getByText('artifact-cache-310', { exact: true }).count()) >= 2);
|
||||
},
|
||||
);
|
||||
if (width !== 768) {
|
||||
await select('Consent');
|
||||
await check(
|
||||
'Consent separates running/backup versions and does not promise active-data restoration',
|
||||
async () => {
|
||||
const action = page.getByRole('button', { name: 'Roll back', exact: true });
|
||||
await action.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog', { name: 'Confirm rollback' });
|
||||
await dialog.getByText('Roll back to Pulse v6.4.5?', { exact: true }).waitFor();
|
||||
await dialog.getByText('Running now: Pulse v6.6.0-dev.', { exact: true }).waitFor();
|
||||
const text = await dialog.innerText();
|
||||
assert.ok(text.includes('Restore scope is not reported for this backup.'));
|
||||
assert.ok(text.includes('Older recovery can also replace install-local data'));
|
||||
assert.ok(text.includes('Do not assume this will undo settings or alerts.'));
|
||||
assert.ok(!text.includes('/private/fixture-backup'));
|
||||
assert.ok(!text.includes('untrusted restore-everything note'));
|
||||
assert.equal(
|
||||
await dialog
|
||||
.getByRole('link', { name: 'Read the recovery instructions' })
|
||||
.getAttribute('href'),
|
||||
'/docs/AUTO_UPDATE#manual-rollback',
|
||||
);
|
||||
assert.equal(requests.length, 0);
|
||||
await capture('consent');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(await page.getByRole('dialog').count(), 0);
|
||||
assert.equal(requests.length, 0);
|
||||
assert.equal(await action.evaluate((e) => e === document.activeElement), true);
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'Rejection retains consent and pending submission cannot be duplicated or cancelled',
|
||||
async () => {
|
||||
await page.getByRole('button', { name: 'Roll back', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
|
||||
await page
|
||||
.getByText('Synthetic incompatible backup scope', { exact: true })
|
||||
.first()
|
||||
.waitFor();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.ok(
|
||||
(await page.getByRole('dialog').innerText()).includes(
|
||||
'Restore scope is not reported',
|
||||
),
|
||||
);
|
||||
await capture('consent-rejected');
|
||||
rollbackMode = 'hold';
|
||||
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('button:disabled')?.textContent,
|
||||
);
|
||||
assert.ok(
|
||||
await page
|
||||
.getByRole('button', { name: 'Starting rollback...', exact: true })
|
||||
.isDisabled(),
|
||||
);
|
||||
assert.ok(await page.getByRole('button', { name: 'Cancel', exact: true }).isDisabled());
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.getByRole('dialog').count(), 1);
|
||||
while (!finishRollback) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(requests.length, 2);
|
||||
rollbackMode = 'accept';
|
||||
finishRollback();
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
assert.deepEqual(requests, [
|
||||
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
|
||||
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
await check('No uncaught application errors or external requests', async () => {
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(offOrigin, []);
|
||||
});
|
||||
results.push({
|
||||
name,
|
||||
width,
|
||||
browser: browser.version(),
|
||||
checks,
|
||||
errors,
|
||||
offOrigin,
|
||||
rollbackRequests: requests,
|
||||
});
|
||||
await page.close();
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
const result = {
|
||||
result: results.every((r) => r.checks.every((c) => c.passed)) ? 'passed' : 'failed',
|
||||
playwright,
|
||||
base_sha: '697ad83e19fa5e8afee634c212c945600d2292b4',
|
||||
source_content_sha256: hashes,
|
||||
results,
|
||||
screens,
|
||||
};
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
result: result.result,
|
||||
checks: results.flatMap((r) => r.checks).length,
|
||||
failed: results.flatMap((r) => r.checks.filter((c) => !c.passed)),
|
||||
screens: screens.length,
|
||||
}),
|
||||
);
|
||||
if (result.result !== 'passed') process.exitCode = 1;
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Operator tables and rollback consent verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./operator-tables-consent.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,399 @@
|
||||
// Production components and CSS with synthetic observations. No native, released or
|
||||
// installed recovery claim; the current history API does not describe restore scope.
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
|
||||
import { DiskList } from '../src/components/Storage/DiskList';
|
||||
import { KubernetesControllersTable } from '../src/features/kubernetes/KubernetesControllersTable';
|
||||
import { ProxmoxCoverageTable } from '../src/features/proxmox/ProxmoxCoverageTable';
|
||||
import { ProxmoxRecoverableTable } from '../src/features/proxmox/ProxmoxRecoverableTable';
|
||||
import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
|
||||
import { ProxmoxNodesTable } from '../src/features/proxmox/ProxmoxNodesTable';
|
||||
import { GuestRow } from '../src/components/Workloads/GuestRow';
|
||||
import { Table, TableBody } from '../src/components/shared/Table';
|
||||
import { ToastContainer } from '../src/components/Toast/Toast';
|
||||
import { UpdateHistorySection } from '../src/components/Settings/UpdateHistorySection';
|
||||
import { buildProxmoxBackupRecoveryModel } from '../src/features/proxmox/proxmoxBackupRecoveryModel';
|
||||
import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
|
||||
import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
|
||||
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
|
||||
import { updateStore } from '../src/stores/updates';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import type { StorageRecord } from '../src/features/storageBackups/models';
|
||||
import '../src/index.css';
|
||||
|
||||
const now = Date.parse('2026-10-03T12:00:00Z');
|
||||
const resource = (id: string, type: Resource['type'], extra: Partial<Resource> = {}): Resource => ({
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
displayName: id,
|
||||
platformId: 'fixture-estate',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: now,
|
||||
...extra,
|
||||
});
|
||||
const pools: StorageRecord[] = [
|
||||
['archive-tank', 'DEGRADED', 'resilver'],
|
||||
['backup-pool', 'ONLINE', 'scrub'],
|
||||
].map(([name, state, scan], index) => ({
|
||||
id: name,
|
||||
name,
|
||||
category: 'pool',
|
||||
health: index ? 'healthy' : 'warning',
|
||||
location: { label: index ? 'backup-nas' : 'archive-nas', scope: 'host' },
|
||||
source: {
|
||||
platform: 'truenas',
|
||||
family: 'onprem',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
capacity: { totalBytes: 2e12, usedBytes: 1.18e12, freeBytes: 0.82e12, usagePercent: 59 },
|
||||
capabilities: ['capacity', 'health'],
|
||||
observedAt: now,
|
||||
protectionLabel: `ZFS pool ${name} ${scan} is running (45.2%)`,
|
||||
protectionSummary: `ZFS pool ${name} ${scan} is running (45.2%)`,
|
||||
rebuildInProgress: true,
|
||||
details: {
|
||||
zfsPool: {
|
||||
state,
|
||||
scan: `${scan} in progress`,
|
||||
scanDetails: {
|
||||
function: scan.toUpperCase(),
|
||||
state: 'SCANNING',
|
||||
percentage: 45.2,
|
||||
},
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const disks = [
|
||||
resource('disk-sda', 'physical_disk', {
|
||||
identity: { hostname: 'archive-nas' },
|
||||
canonicalIdentity: { hostname: 'archive-nas' },
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sda',
|
||||
model: 'Archive HDD',
|
||||
serial: 'FIXTURE-1',
|
||||
diskType: 'hdd',
|
||||
sizeBytes: 2e12,
|
||||
health: 'FAILED',
|
||||
temperature: 42,
|
||||
storageRole: 'data',
|
||||
risk: {
|
||||
level: 'critical',
|
||||
reasons: [
|
||||
{
|
||||
code: 'smart-failed',
|
||||
severity: 'critical',
|
||||
summary: 'SMART failed. The full reason remains readable in the expanded disk header.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
resource('disk-nvme', 'physical_disk', {
|
||||
identity: { hostname: 'backup-nas' },
|
||||
canonicalIdentity: { hostname: 'backup-nas' },
|
||||
physicalDisk: {
|
||||
devPath: '/dev/nvme0n1',
|
||||
model: 'Backup SSD',
|
||||
serial: 'FIXTURE-2',
|
||||
diskType: 'nvme',
|
||||
sizeBytes: 1e12,
|
||||
health: 'PASSED',
|
||||
temperature: 44,
|
||||
wearout: 2,
|
||||
storageRole: 'cache',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const controllers = [
|
||||
resource('nightly-import', 'k8s-job', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'Job',
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
startTime: '2026-10-03T11:00:00Z',
|
||||
completionTime: '2026-10-03T11:05:00Z',
|
||||
desiredReplicas: 1,
|
||||
succeeded: 1,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
},
|
||||
}),
|
||||
resource('billing-rollup', 'k8s-cronjob', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'CronJob',
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
schedule: '*/5 * * * *',
|
||||
lastScheduleTime: '2026-10-03T11:00:00Z',
|
||||
lastSuccessfulTime: '2026-10-03T11:05:00Z',
|
||||
suspend: true,
|
||||
active: 0,
|
||||
},
|
||||
}),
|
||||
resource('node-exporter', 'k8s-daemonset', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'DaemonSet',
|
||||
clusterName: 'prod',
|
||||
namespace: 'observability',
|
||||
desiredNumberScheduled: 3,
|
||||
currentNumberScheduled: 3,
|
||||
numberReady: 2,
|
||||
numberUnavailable: 1,
|
||||
},
|
||||
}),
|
||||
resource('database', 'k8s-statefulset', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'StatefulSet',
|
||||
clusterName: 'prod',
|
||||
namespace: 'apps',
|
||||
desiredReplicas: 1,
|
||||
readyReplicas: 1,
|
||||
serviceName: 'database-headless',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const workload = resource('ct-310', 'system-container', {
|
||||
name: 'artifact-cache-310',
|
||||
displayName: 'artifact-cache-310',
|
||||
status: 'running',
|
||||
proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' },
|
||||
});
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload],
|
||||
pbsBackups: [],
|
||||
tasks: [],
|
||||
nowMs: now,
|
||||
archives: [
|
||||
{
|
||||
id: 'file-310',
|
||||
storage: 'local',
|
||||
node: 'pve-edge',
|
||||
instance: 'homelab',
|
||||
type: 'ct',
|
||||
vmid: 310,
|
||||
time: '2026-09-20T02:00:00Z',
|
||||
ctime: Date.parse('2026-09-20T02:00:00Z') / 1000,
|
||||
size: 1048576,
|
||||
format: 'tar.zst',
|
||||
protected: false,
|
||||
volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst',
|
||||
isPBS: false,
|
||||
verified: false,
|
||||
},
|
||||
],
|
||||
snapshots: [
|
||||
{
|
||||
id: 'snap-310',
|
||||
name: 'fresh-local-snapshot',
|
||||
node: 'pve-edge',
|
||||
instance: 'homelab',
|
||||
type: 'ct',
|
||||
vmid: 310,
|
||||
time: '2026-10-03T11:30:00Z',
|
||||
vmstate: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const pbs = resource('pbs-archive', 'pbs', {
|
||||
platformType: 'proxmox-pbs',
|
||||
cpu: { current: 14 },
|
||||
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
|
||||
pbs: {
|
||||
instance: 'pbs-archive',
|
||||
datastores: [
|
||||
{
|
||||
name: 'archive',
|
||||
total: 2e12,
|
||||
used: 1.18e12,
|
||||
free: 0.82e12,
|
||||
usage: 59,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const node = resource('pve-edge', 'node', {
|
||||
cpu: { current: 12, cores: 4 },
|
||||
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
|
||||
disk: { current: 30, total: 1e12, used: 0.3e12, free: 0.7e12 },
|
||||
proxmox: { node: 'pve-edge', instance: 'homelab', uptime: 86400 },
|
||||
});
|
||||
function Fixture() {
|
||||
const [view, setView] = createSignal('Pools');
|
||||
const [disk, setDisk] = createSignal<string | null>(null);
|
||||
const [pool, setPool] = createSignal<string | null>(null);
|
||||
const [expanded, setExpanded] = createSignal(new Set<string>());
|
||||
const ws = {
|
||||
state: { pmg: [], pbs: [], nodes: [], resources: [], vms: [], containers: [] },
|
||||
activeAlerts: () => ({}),
|
||||
connected: () => true,
|
||||
initialDataReceived: () => true,
|
||||
};
|
||||
return (
|
||||
<WebSocketContext.Provider value={ws as any}>
|
||||
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<ToastContainer />
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1 class="text-lg font-semibold">Operator tables and rollback consent</h1>
|
||||
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
|
||||
{['Pools', 'Disks', 'Controllers', 'Backups', 'Consent'].map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-11 rounded border border-border px-3"
|
||||
onClick={() => setView(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<Show when={view() === 'Pools'}>
|
||||
<section aria-label="Pool table">
|
||||
<StoragePoolsTable
|
||||
groupedRecords={groupStorageRecords(pools, 'none')}
|
||||
groupBy="none"
|
||||
expandedGroups={new Set(['All'])}
|
||||
toggleGroup={() => {}}
|
||||
expandedPoolId={pool()}
|
||||
setExpandedPoolId={setPool}
|
||||
storageGrowthBySeriesId={new Map()}
|
||||
storageGrowthColumnLabel="Growth"
|
||||
physicalDisks={[]}
|
||||
nodeOnlineByLabel={new Map()}
|
||||
highlightedRecordId={null}
|
||||
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
|
||||
isLoading={false}
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Disks'}>
|
||||
<section aria-label="Disk table">
|
||||
<DiskList
|
||||
disks={disks}
|
||||
nodes={[]}
|
||||
selectedNode={null}
|
||||
searchTerm=""
|
||||
selectedDiskId={disk()}
|
||||
onSelectedDiskChange={setDisk}
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Controllers'}>
|
||||
<section aria-label="Controller table">
|
||||
<KubernetesControllersTable
|
||||
resources={controllers}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No controllers"
|
||||
emptyDescription="No controllers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Backups'}>
|
||||
<section aria-label="Backup tables" class="space-y-4">
|
||||
<h2>Coverage</h2>
|
||||
<ProxmoxCoverageTable
|
||||
rows={model.coverageRows}
|
||||
hasAnyRows
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No backups"
|
||||
emptyDescription="No backups"
|
||||
sortKey={() => 'posture'}
|
||||
sortDirection={() => 'asc'}
|
||||
onSort={() => {}}
|
||||
expandedKeys={expanded()}
|
||||
onToggleExpand={(key) =>
|
||||
setExpanded((current) =>
|
||||
current.has(key)
|
||||
? new Set([...current].filter((item) => item !== key))
|
||||
: new Set([...current, key]),
|
||||
)
|
||||
}
|
||||
showPbsColumn
|
||||
showArchiveColumn
|
||||
showSnapshotColumn
|
||||
showTaskColumn={false}
|
||||
/>
|
||||
<h2>By date</h2>
|
||||
<ProxmoxRecoverableTable
|
||||
artifacts={model.recoverableArtifacts}
|
||||
hasAnyArtifacts
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No artifacts"
|
||||
emptyDescription="No artifacts"
|
||||
sortKey={() => 'created'}
|
||||
sortDirection={() => 'desc'}
|
||||
onSort={() => {}}
|
||||
sizeMaxBytes={1048576}
|
||||
/>
|
||||
<h2>Backup servers</h2>
|
||||
<ProxmoxBackupServersTable servers={[pbs]} />
|
||||
<h2>Proxmox nodes</h2>
|
||||
<ProxmoxNodesTable
|
||||
nodes={[node]}
|
||||
guests={[workload]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No nodes"
|
||||
emptyDescription="No nodes"
|
||||
/>
|
||||
<h2>Workload row</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<GuestRow
|
||||
guest={
|
||||
{
|
||||
id: 'ct-310',
|
||||
vmid: 310,
|
||||
name: 'artifact-cache-310',
|
||||
node: 'pve-edge',
|
||||
instance: 'homelab',
|
||||
status: 'running',
|
||||
type: 'lxc',
|
||||
cpu: 0.12,
|
||||
cpus: 2,
|
||||
memory: { usage: 30, total: 1e9, used: 0.3e9, free: 0.7e9 },
|
||||
disk: { usage: 20, total: 1e10, used: 2e9, free: 8e9 },
|
||||
uptime: 86400,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: null,
|
||||
lock: '',
|
||||
lastSeen: new Date(now).toISOString(),
|
||||
} as any
|
||||
}
|
||||
visibleColumnIds={['name', 'cpu', 'memory', 'disk', 'uptime']}
|
||||
workloadTableLayoutMode={innerWidth < 440 ? 'phone' : 'compact'}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Consent'}>
|
||||
<section aria-label="Rollback history">
|
||||
<UpdateHistorySection />
|
||||
</section>
|
||||
</Show>
|
||||
</main>
|
||||
</DarkModeContext.Provider>
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
void updateStore.checkForUpdates();
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/*" component={Fixture} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,497 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
const now = '2026-10-03T12:00:00Z';
|
||||
const base = (id, type, extra = {}) => ({
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
status: 'online',
|
||||
lastSeen: now,
|
||||
sources: ['docker'],
|
||||
...extra,
|
||||
});
|
||||
const observations = (mode) => [
|
||||
base('mail-gateway-eu', 'pmg', {
|
||||
sources: ['pmg'],
|
||||
technology: 'pmg',
|
||||
uptime: 86400,
|
||||
sourceStatus: { pmg: { status: 'online', lastSeen: now } },
|
||||
...(mode === 'missing'
|
||||
? {}
|
||||
: {
|
||||
pmg: {
|
||||
instanceId: 'pmg-eu',
|
||||
hostname: 'mail-gateway-eu',
|
||||
version: '8.1-2',
|
||||
nodeCount: 2,
|
||||
mailCountTotal: 2740,
|
||||
spamIn: 321,
|
||||
virusIn: 14,
|
||||
quarantine: 3,
|
||||
queueTotal: mode === 'fresh' ? 19 : 11,
|
||||
queueDeferred: 5,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
base('nfs-iso', 'storage', {
|
||||
sources: ['proxmox'],
|
||||
parentName: 'cluster',
|
||||
parentId: 'pve-cluster-a',
|
||||
technology: 'nfs',
|
||||
metrics: { disk: { total: 1e12, used: 3e11, percent: 30 } },
|
||||
storage: { type: 'nfs', shared: true, nodes: ['pve1', 'pve2', 'pve3'] },
|
||||
}),
|
||||
base('local-lvm', 'storage', {
|
||||
sources: ['proxmox'],
|
||||
parentName: 'pve2',
|
||||
parentId: 'pve2',
|
||||
metrics: { disk: { total: 1e12, used: 6e11, percent: 60 } },
|
||||
storage: { type: 'lvmthin', shared: false },
|
||||
}),
|
||||
base('edge-web', 'app-container', {
|
||||
status: 'running',
|
||||
technology: 'docker',
|
||||
sourceStatus: { docker: { status: 'online', lastSeen: now } },
|
||||
actionReadiness: [{ name: 'restart', available: false, reason: 'Command agent disconnected' }],
|
||||
metrics: { cpu: { percent: 42 }, memory: { percent: 50, used: 5e8, total: 1e9 } },
|
||||
docker: {
|
||||
agentId: 'agent-edge',
|
||||
hostSourceId: 'agent-edge',
|
||||
containerId: 'native-edge-web',
|
||||
hostname: 'edge-long-hostname',
|
||||
runtime: 'docker',
|
||||
runtimeVersion: '27.5.1',
|
||||
image: 'nginx:latest',
|
||||
containerState: 'running',
|
||||
restartCount: 7,
|
||||
updateStatus: {
|
||||
updateAvailable: true,
|
||||
currentDigest: 'sha256:current',
|
||||
latestDigest: 'sha256:latest',
|
||||
lastChecked: now,
|
||||
},
|
||||
},
|
||||
}),
|
||||
base('edge-cache', 'app-container', {
|
||||
status: 'offline',
|
||||
metrics: { cpu: { percent: 0 }, memory: { percent: 0 } },
|
||||
docker: {
|
||||
agentId: 'agent-edge',
|
||||
hostname: 'edge-long-hostname',
|
||||
runtime: 'podman',
|
||||
runtimeVersion: '5.2.1',
|
||||
image: 'redis:7.4',
|
||||
containerState: 'exited',
|
||||
restartCount: 0,
|
||||
},
|
||||
}),
|
||||
base('nginx:latest', 'docker-image', {
|
||||
docker: {
|
||||
hostname: 'edge-long-hostname',
|
||||
imageId: 'image-nginx',
|
||||
repoTags: ['nginx:latest'],
|
||||
sizeBytes: 2e8,
|
||||
containers: ['edge-web'],
|
||||
containerCount: 1,
|
||||
},
|
||||
}),
|
||||
base('web-service', 'docker-service', {
|
||||
docker: {
|
||||
hostname: 'edge-long-hostname',
|
||||
image: 'nginx:latest',
|
||||
serviceMode: 'replicated',
|
||||
desiredTasks: 4,
|
||||
runningTasks: 2,
|
||||
mode: 'replicated',
|
||||
serviceUpdate: { state: 'rollback_started', message: 'Synthetic rollout observation' },
|
||||
},
|
||||
}),
|
||||
base('web.2', 'docker-task', {
|
||||
docker: {
|
||||
hostname: 'edge-long-hostname',
|
||||
serviceName: 'web-service',
|
||||
nodeName: 'worker-1',
|
||||
currentState: 'running',
|
||||
desiredState: 'running',
|
||||
startedAt: '2026-10-03T11:30:00.123456Z',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/proxmox-docker-rows-final-source';
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
process.chdir(root);
|
||||
const playwright = require('playwright/package.json').version;
|
||||
assert.equal(
|
||||
playwright,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
// Keep the fixture runnable from clean committed source, without requiring
|
||||
// an invocation-specific untracked runtime-paths input.
|
||||
const paths = [
|
||||
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
|
||||
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
|
||||
'frontend-modern/src/features/docker/DockerImagesTable.tsx',
|
||||
'frontend-modern/src/features/docker/DockerServicesTable.tsx',
|
||||
'frontend-modern/src/features/docker/DockerTasksTable.tsx',
|
||||
'frontend-modern/src/features/docker/dockerContainerTableModel.ts',
|
||||
'frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts',
|
||||
'frontend-modern/src/features/storageBackups/storageAdapters.ts',
|
||||
'frontend-modern/src/hooks/useUnifiedResources.ts',
|
||||
];
|
||||
const hashes = Object.fromEntries(
|
||||
paths.map((p) => [
|
||||
p,
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join('/workspace', p)))
|
||||
.digest('hex'),
|
||||
]),
|
||||
);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
server: { host: '127.0.0.1', port: 5272, strictPort: true },
|
||||
});
|
||||
const results = [],
|
||||
screenshots = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width] of [
|
||||
['desktop', chromium, 1440],
|
||||
['phone', webkit, 390],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
isMobile: width === 390,
|
||||
hasTouch: width === 390,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(now));
|
||||
page.setDefaultTimeout(15000);
|
||||
const checks = [],
|
||||
errors = [],
|
||||
offOrigin = [],
|
||||
mutations = [];
|
||||
let mode = 'initial';
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.route('**/*', async (route) => {
|
||||
const request = route.request(),
|
||||
url = new URL(request.url());
|
||||
if (url.origin !== 'http://127.0.0.1:5272') {
|
||||
offOrigin.push(url.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (request.method() !== 'GET') {
|
||||
mutations.push({ path: url.pathname, method: request.method() });
|
||||
return route.fulfill({ status: 409, json: { error: 'No fixture mutation' } });
|
||||
}
|
||||
if (url.pathname === '/api/resources')
|
||||
return route.fulfill({ json: { data: observations(mode) } });
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
blocked_capabilities: [],
|
||||
runtime: { build: 'community' },
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/security/status')
|
||||
return route.fulfill({ json: { hasAuthentication: true, requiresAuth: true } });
|
||||
return route.fulfill({
|
||||
json: {
|
||||
enabled: false,
|
||||
data: [],
|
||||
capabilities: [],
|
||||
facets: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
alerts: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
const check = async (label, run) => {
|
||||
try {
|
||||
await run();
|
||||
checks.push({ label, passed: true });
|
||||
} catch (e) {
|
||||
checks.push({ label, passed: false, error: e.message });
|
||||
}
|
||||
};
|
||||
const select = (view) =>
|
||||
page
|
||||
.getByRole('navigation', { name: 'Fixture views' })
|
||||
.getByRole('button', { name: view, exact: true })
|
||||
.click();
|
||||
const capture = async (state) => {
|
||||
await page.waitForTimeout(180); // ResizeObserver and entrance layout, not freshness evidence.
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
width: innerWidth,
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}));
|
||||
checks.push({
|
||||
label: `No horizontal page overflow: ${state}`,
|
||||
passed: dimensions.scroll <= dimensions.width + 1,
|
||||
dimensions,
|
||||
});
|
||||
const file = `${name}-${state}.png`;
|
||||
await page.screenshot({ path: path.join(output, file), fullPage: true });
|
||||
screenshots.push({ file, state, engine: name, width, dimensions });
|
||||
};
|
||||
await page.goto('http://127.0.0.1:5272/browser-tests/proxmox-docker-rows.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120000,
|
||||
});
|
||||
if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await page.getByText('8 REST-mapped rows', { exact: true }).waitFor();
|
||||
await check(
|
||||
'REST-first PMG counters and facets are present before any websocket hydration',
|
||||
async () => {
|
||||
const table = page.getByRole('region', { name: 'Mail table' });
|
||||
await table.getByText('2,740', { exact: true }).waitFor();
|
||||
await table.getByText('11', { exact: true }).waitFor();
|
||||
const row = await page.evaluate(() =>
|
||||
window.__operatorRows.rows().find((r) => r.type === 'pmg'),
|
||||
);
|
||||
assert.equal(row.technology, 'pmg');
|
||||
assert.equal(row.sourceStatus.pmg.status, 'online');
|
||||
assert.equal(row.pmg.queueTotal, 11);
|
||||
assert.deepEqual(row.pmg, row.platformData.pmg);
|
||||
if (width > 390) await table.getByText('8.1-2', { exact: true }).waitFor();
|
||||
},
|
||||
);
|
||||
await capture('rest-mail');
|
||||
await check(
|
||||
'Canonical store hydration followed by REST refresh keeps the new PMG counters',
|
||||
async () => {
|
||||
await page.evaluate(() => window.__operatorRows.seedWebsocket());
|
||||
mode = 'fresh';
|
||||
await page.evaluate(() => window.__operatorRows.refresh());
|
||||
await page
|
||||
.getByRole('region', { name: 'Mail table' })
|
||||
.getByText('19', { exact: true })
|
||||
.waitFor();
|
||||
},
|
||||
);
|
||||
await capture('refreshed-mail');
|
||||
await check('A removed REST PMG facet does not retain obsolete queue evidence', async () => {
|
||||
mode = 'missing';
|
||||
await page.evaluate(() => window.__operatorRows.refresh());
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('region', { name: 'Mail table' })
|
||||
.getByText('19', { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
const row = await page.evaluate(() =>
|
||||
window.__operatorRows.rows().find((r) => r.type === 'pmg'),
|
||||
);
|
||||
assert.equal(row.pmg, undefined);
|
||||
mode = 'fresh';
|
||||
await page.evaluate(() => window.__operatorRows.refresh());
|
||||
});
|
||||
await select('Storage');
|
||||
await check('Shared storage vocabulary preserves native locations and IDs', async () => {
|
||||
const table = page.getByRole('region', { name: 'Storage table' });
|
||||
await table.getByText('NFS', { exact: true }).waitFor({ state: 'attached' });
|
||||
await table.getByText('LVM-Thin', { exact: true }).waitFor({ state: 'attached' });
|
||||
await table.getByText('Shared · 3 nodes', { exact: true }).waitFor({ state: 'attached' });
|
||||
await table.getByText('pve2', { exact: true }).waitFor({ state: 'attached' });
|
||||
const rows = await page.evaluate(() =>
|
||||
window.__operatorRows.rows().filter((r) => r.type === 'storage'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.id, r.parentId]),
|
||||
[
|
||||
['nfs-iso', 'pve-cluster-a'],
|
||||
['local-lvm', 'pve2'],
|
||||
],
|
||||
);
|
||||
});
|
||||
await capture('storage');
|
||||
await select('Containers');
|
||||
await check(
|
||||
'REST lifecycle refusal remains available; phone row keeps update and moves restarts to detail',
|
||||
async () => {
|
||||
await page
|
||||
.locator('[data-lifecycle-refusal]')
|
||||
.getByText('Command agent disconnected', { exact: true })
|
||||
.waitFor();
|
||||
const table = page.getByRole('region', { name: 'Container table' });
|
||||
const update = table.locator('.docker-container-update-cell button');
|
||||
await update.waitFor();
|
||||
assert.match(await update.innerText(), /Update/);
|
||||
const bounds = await update.evaluate((e) => ({
|
||||
w: e.getBoundingClientRect().width,
|
||||
cell: e.closest('td').getBoundingClientRect().width,
|
||||
scroll: e.scrollWidth,
|
||||
client: e.clientWidth,
|
||||
}));
|
||||
assert.ok(
|
||||
bounds.w <= bounds.cell + 1 && bounds.scroll <= bounds.client + 1,
|
||||
JSON.stringify(bounds),
|
||||
);
|
||||
const headers = await table.locator('th:visible').allTextContents();
|
||||
if (width === 390) {
|
||||
assert.ok(!headers.some((h) => h.includes('Restarts')));
|
||||
assert.ok(headers.some((h) => h.includes('State')));
|
||||
}
|
||||
},
|
||||
);
|
||||
await capture('containers');
|
||||
await check(
|
||||
'Restart count and native identity remain reachable via row disclosure',
|
||||
async () => {
|
||||
const row = page.locator('[data-docker-container-row="edge-web"]');
|
||||
if (width === 390) await row.locator('td').first().tap();
|
||||
else await row.getByRole('button', { name: /details/i }).click();
|
||||
const detail = page.getByTestId('resource-docker-container-section');
|
||||
await detail.getByText('Restarts', { exact: true }).waitFor();
|
||||
await detail.getByText('7', { exact: true }).waitFor();
|
||||
},
|
||||
);
|
||||
await capture('container-detail');
|
||||
await select('Images');
|
||||
await check('Image verdict fits; phone hidden headers and cells are symmetric', async () => {
|
||||
const table = page.getByRole('region', { name: 'Image table' });
|
||||
await table.getByText('nginx:latest', { exact: true }).waitFor();
|
||||
const headers = await table.locator('th:visible').allTextContents();
|
||||
const row = table.locator('[data-docker-image-row]');
|
||||
assert.equal(await row.locator('td:visible').count(), headers.length);
|
||||
if (width === 390) {
|
||||
assert.ok(!headers.some((h) => h.includes('Host') || h.includes('Used by')));
|
||||
assert.ok(headers.some((h) => h.includes('Size')));
|
||||
}
|
||||
});
|
||||
await capture('images');
|
||||
await select('Swarm');
|
||||
await check(
|
||||
'Swarm state and started time use operator wording with raw evidence retained',
|
||||
async () => {
|
||||
const state = page.getByText('Rollback started', { exact: true });
|
||||
await state.waitFor({ state: 'attached' });
|
||||
assert.match(
|
||||
await state.locator('xpath=ancestor-or-self::*[@title][1]').getAttribute('title'),
|
||||
/rollback_started/,
|
||||
);
|
||||
const visibleState = await state.evaluate((element) => {
|
||||
const cell = element.closest('td').getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
return [...range.getClientRects()].every(
|
||||
(line) =>
|
||||
line.left >= cell.left &&
|
||||
line.right <= cell.right &&
|
||||
line.top >= cell.top &&
|
||||
line.bottom <= cell.bottom,
|
||||
);
|
||||
});
|
||||
assert.equal(visibleState, true, 'Every word of the rollout state must fit its cell');
|
||||
if (width === 390) {
|
||||
await page.getByText('Run', { exact: true }).waitFor({ state: 'visible' });
|
||||
}
|
||||
const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]');
|
||||
await started.waitFor({ state: 'attached' });
|
||||
assert.match(await started.innerText(), /ago/);
|
||||
await page
|
||||
.getByRole('region', { name: 'Swarm tasks' })
|
||||
.getByTitle('running', { exact: true })
|
||||
.waitFor();
|
||||
},
|
||||
);
|
||||
await capture('swarm');
|
||||
await check(
|
||||
'Swarm touch or keyboard disclosure exposes raw rollout and placement evidence',
|
||||
async () => {
|
||||
const row = page.locator('[data-docker-service-row="web-service"]');
|
||||
if (width === 390) await row.locator('td').first().tap();
|
||||
else {
|
||||
const toggle = row.getByRole('button', {
|
||||
name: 'Expand details for web-service',
|
||||
exact: true,
|
||||
});
|
||||
await toggle.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
const detail = page.locator('[data-inline-docker-service-detail-for="web-service"]');
|
||||
await detail
|
||||
.getByText('rollback_started | Synthetic rollout observation', { exact: true })
|
||||
.waitFor();
|
||||
await detail.getByText('edge-long-hostname', { exact: true }).waitFor();
|
||||
await detail.getByText('4', { exact: true }).waitFor();
|
||||
await detail.getByText('2', { exact: true }).waitFor();
|
||||
},
|
||||
);
|
||||
await capture('swarm-detail');
|
||||
await select('Disk');
|
||||
await check(
|
||||
'Multi-disk Bars show the measured fullest disk and unchanged equal-weight bars',
|
||||
async () => {
|
||||
const label = page.locator('[data-stacked-disk-max-label]');
|
||||
assert.equal(await label.innerText(), '92%');
|
||||
assert.equal(await label.getAttribute('title'), 'Highest usage: /data 92%');
|
||||
assert.equal(await page.locator('[data-stacked-disk-fill="vertical"]').count(), 2);
|
||||
},
|
||||
);
|
||||
await capture('disks');
|
||||
checks.push({
|
||||
label: 'No page errors, external requests or mutation requests',
|
||||
passed: !errors.length && !offOrigin.length && !mutations.length,
|
||||
errors,
|
||||
offOrigin,
|
||||
mutations,
|
||||
});
|
||||
results.push({ engine: name, version: browser.version(), width, checks });
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
const result = {
|
||||
playwright,
|
||||
verified_at: new Date().toISOString(),
|
||||
paths,
|
||||
hashes,
|
||||
results,
|
||||
screenshots,
|
||||
};
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
|
||||
const failures = results.flatMap((r) =>
|
||||
r.checks.filter((c) => !c.passed).map((c) => ({ engine: r.engine, ...c })),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
checked: results.reduce((n, r) => n + r.checks.length, 0),
|
||||
screenshots: screenshots.length,
|
||||
failures,
|
||||
}),
|
||||
);
|
||||
assert.equal(failures.length, 0, JSON.stringify(failures));
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transport facets and operator rows verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./proxmox-docker-rows.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,182 @@
|
||||
// Synthetic REST observations through the real hook, tables, detail disclosures
|
||||
// and CSS. No real websocket, appliance or installed/released acceptance claim.
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { createStore, unwrap } from 'solid-js/store';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { useUnifiedResources } from '../src/hooks/useUnifiedResources';
|
||||
import { ProxmoxMailGatewayTable } from '../src/features/proxmox/ProxmoxMailGatewayTable';
|
||||
import { DockerContainersTable } from '../src/features/docker/DockerContainersTable';
|
||||
import { DockerImagesTable } from '../src/features/docker/DockerImagesTable';
|
||||
import { DockerServicesTable } from '../src/features/docker/DockerServicesTable';
|
||||
import { DockerTasksTable } from '../src/features/docker/DockerTasksTable';
|
||||
import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar';
|
||||
import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
|
||||
import { Table, TableBody, TableRow, TableCell } from '../src/components/shared/Table';
|
||||
import { getDockerContainerLifecycleDisabledReason } from '../src/features/docker/dockerContainerLifecycleActions';
|
||||
import { buildStorageRecords } from '../src/features/storageBackups/storageAdapters';
|
||||
import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
|
||||
import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
|
||||
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import '../src/index.css';
|
||||
|
||||
const [wsState, setWsState] = createStore({
|
||||
resources: [] as Resource[],
|
||||
lastUpdate: 0,
|
||||
nodes: [],
|
||||
pbs: [],
|
||||
pmg: [],
|
||||
vms: [],
|
||||
containers: [],
|
||||
});
|
||||
const [connected, setConnected] = createSignal(false);
|
||||
const ws = {
|
||||
state: wsState,
|
||||
connected,
|
||||
initialDataReceived: () => true,
|
||||
resourceChange: () => ({ version: wsState.lastUpdate, changedIds: null, changedKeys: null }),
|
||||
activeAlerts: () => ({}),
|
||||
shutdown: () => {},
|
||||
};
|
||||
window.__pulseWsStore = ws as any;
|
||||
|
||||
function Fixture() {
|
||||
const [view, setView] = createSignal('Mail');
|
||||
const [expandedPool, setExpandedPool] = createSignal<string | null>(null);
|
||||
const rows = useUnifiedResources({ query: '', cacheKey: 'operator-rows-browser' });
|
||||
const ofType = (type: string) => rows.resources().filter((row) => row.type === type);
|
||||
const container = () => ofType('app-container')[0];
|
||||
const records = () =>
|
||||
buildStorageRecords({ state: wsState as any, resources: ofType('storage') });
|
||||
const grouped = () => groupStorageRecords(records(), 'none');
|
||||
(window as any).__operatorRows = {
|
||||
rows: () => JSON.parse(JSON.stringify(unwrap(rows.resources()))),
|
||||
refresh: () => rows.refetch(),
|
||||
seedWebsocket: () => {
|
||||
setWsState('resources', JSON.parse(JSON.stringify(unwrap(rows.resources()))));
|
||||
setWsState('lastUpdate', Date.now());
|
||||
setConnected(true);
|
||||
},
|
||||
};
|
||||
const nativeProps = {
|
||||
emptyIcon: <span />,
|
||||
emptyTitle: 'No fixture rows',
|
||||
emptyDescription: 'No fixture rows',
|
||||
showToolbar: false,
|
||||
};
|
||||
return (
|
||||
<WebSocketContext.Provider value={ws as any}>
|
||||
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1 class="text-lg font-semibold">Transport facets and operator rows</h1>
|
||||
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
|
||||
{['Mail', 'Storage', 'Containers', 'Images', 'Swarm', 'Disk'].map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-11 rounded border border-border px-3"
|
||||
onClick={() => setView(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<p data-fixture-loaded>{rows.resources().length} REST-mapped rows</p>
|
||||
<Show when={view() === 'Mail'}>
|
||||
<section aria-label="Mail table">
|
||||
<ProxmoxMailGatewayTable
|
||||
resources={ofType('pmg')}
|
||||
emptyTitle="No mail"
|
||||
emptyDescription="No mail"
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Storage'}>
|
||||
<section aria-label="Storage table">
|
||||
<StoragePoolsTable
|
||||
groupedRecords={grouped()}
|
||||
groupBy="none"
|
||||
expandedGroups={new Set(['All'])}
|
||||
toggleGroup={() => {}}
|
||||
expandedPoolId={expandedPool()}
|
||||
setExpandedPoolId={setExpandedPool}
|
||||
storageGrowthBySeriesId={new Map()}
|
||||
storageGrowthColumnLabel="Growth"
|
||||
physicalDisks={[]}
|
||||
nodeOnlineByLabel={new Map()}
|
||||
highlightedRecordId={null}
|
||||
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
|
||||
isLoading={false}
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Containers'}>
|
||||
<section aria-label="Container table">
|
||||
<p data-lifecycle-refusal>
|
||||
{container() && getDockerContainerLifecycleDisabledReason(container(), 'restart')}
|
||||
</p>
|
||||
<DockerContainersTable resources={ofType('app-container')} {...nativeProps} />
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Images'}>
|
||||
<section aria-label="Image table">
|
||||
<DockerImagesTable resources={ofType('docker-image')} {...nativeProps} />
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Swarm'}>
|
||||
<section aria-label="Swarm services">
|
||||
<DockerServicesTable resources={ofType('docker-service')} {...nativeProps} />
|
||||
</section>
|
||||
<section aria-label="Swarm tasks">
|
||||
<DockerTasksTable resources={ofType('docker-task')} {...nativeProps} />
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Disk'}>
|
||||
<section aria-label="Multi-disk Bars">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>pve-edge</TableCell>
|
||||
<TableCell>
|
||||
<StackedDiskBar
|
||||
mode="vertical-bars"
|
||||
disks={[
|
||||
{
|
||||
device: '/dev/sda',
|
||||
mountpoint: '/',
|
||||
usage: 24,
|
||||
total: 100,
|
||||
used: 24,
|
||||
free: 76,
|
||||
type: 'ext4',
|
||||
},
|
||||
{
|
||||
device: '/dev/sdb',
|
||||
mountpoint: '/data',
|
||||
usage: 92,
|
||||
total: 100,
|
||||
used: 92,
|
||||
free: 8,
|
||||
type: 'zfs',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
</Show>
|
||||
</main>
|
||||
</DarkModeContext.Provider>
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/*" component={Fixture} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import { TagBadges } from '@/components/shared/TagBadges';
|
||||
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
|
||||
import { formatInteger } from './resourceDetailMappers';
|
||||
import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
|
||||
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
|
||||
|
||||
interface ResourceSummaryPresentationProps {
|
||||
@@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
|
||||
}
|
||||
: null,
|
||||
props.content !== 'overview' && docker ? dockerSection(docker) : null,
|
||||
props.content !== 'overview' ? buildKubernetesControllerSection(props.resource) : null,
|
||||
props.content !== 'overview'
|
||||
? {
|
||||
label: 'Identity',
|
||||
|
||||
+203
-1
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
buildKubernetesControllerSection,
|
||||
buildKubernetesDetailSections,
|
||||
buildKubernetesDetailsSummary,
|
||||
hasKubernetesDetailSections,
|
||||
@@ -177,3 +179,203 @@ describe('buildKubernetesDetailsSummary', () => {
|
||||
).toBe('https://prod.k8s.local:6443');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKubernetesControllerSection', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const absolute = (timestamp: string): string =>
|
||||
formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } });
|
||||
|
||||
it('returns nothing for resources that are not workload controllers', () => {
|
||||
expect(
|
||||
buildKubernetesControllerSection(
|
||||
makeResource({ id: 'pod-1', type: 'pod', kubernetes: { namespace: 'apps' } }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(buildKubernetesControllerSection(makeResource({ id: 'vm-1', type: 'vm' }))).toBeNull();
|
||||
expect(
|
||||
buildKubernetesControllerSection(makeResource({ id: 'job-1', type: 'k8s-job' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('leads a Job with its absolute start and completion times, then the counts', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'nightly-import',
|
||||
type: 'k8s-job',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
desiredReplicas: 10,
|
||||
active: 1,
|
||||
succeeded: 8,
|
||||
failed: 2,
|
||||
startTime: '2026-05-24T12:55:00Z',
|
||||
completionTime: '2026-05-24T13:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(section?.label).toBe('Job');
|
||||
expect(section?.testId).toBe('resource-kubernetes-controller-section');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Started', value: `${absolute('2026-05-24T12:55:00Z')} (36m ago)`, wrap: true },
|
||||
{ label: 'Completed', value: `${absolute('2026-05-24T13:00:00Z')} (31m ago)`, wrap: true },
|
||||
{ label: 'Duration', value: '5m' },
|
||||
{ label: 'Target', value: '10 completions' },
|
||||
{ label: 'Active', value: '1' },
|
||||
{ label: 'Succeeded', value: '8' },
|
||||
{ label: 'Failed', value: '2', tone: 'danger' },
|
||||
{ label: 'Namespace', value: 'batch' },
|
||||
{ label: 'Cluster', value: 'prod' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a running Job on its start time without a completion or duration row', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'nightly-import',
|
||||
type: 'k8s-job',
|
||||
kubernetes: {
|
||||
namespace: 'batch',
|
||||
desiredReplicas: 1,
|
||||
active: 1,
|
||||
failed: 0,
|
||||
startTime: '2026-05-24T13:29:30Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(section?.rows.map((row) => row.label)).toEqual([
|
||||
'Started',
|
||||
'Target',
|
||||
'Active',
|
||||
'Failed',
|
||||
'Namespace',
|
||||
]);
|
||||
expect(section?.rows[0]).toEqual({
|
||||
label: 'Started',
|
||||
value: `${absolute('2026-05-24T13:29:30Z')} (1m ago)`,
|
||||
wrap: true,
|
||||
});
|
||||
expect(section?.rows[3]).toEqual({ label: 'Failed', value: '0', tone: 'default' });
|
||||
});
|
||||
|
||||
it('leads a CronJob with its schedule and the last run and last success times', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'billing-rollup',
|
||||
type: 'k8s-cronjob',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
schedule: '0 2 * * *',
|
||||
suspend: true,
|
||||
active: 0,
|
||||
lastScheduleTime: '2026-05-24T02:00:00Z',
|
||||
lastSuccessfulTime: '2026-05-23T02:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(section?.label).toBe('CronJob');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Schedule', value: '0 2 * * *', valueClass: 'font-mono' },
|
||||
{ label: 'Last run', value: `${absolute('2026-05-24T02:00:00Z')} (11h ago)`, wrap: true },
|
||||
{ label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true },
|
||||
{ label: 'Suspended', value: 'Yes', tone: 'warning' },
|
||||
{ label: 'Active', value: '0' },
|
||||
{ label: 'Namespace', value: 'batch' },
|
||||
{ label: 'Cluster', value: 'prod' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the service name and counts for StatefulSets and DaemonSets', () => {
|
||||
const statefulSet = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'checkout-api-stateful',
|
||||
type: 'k8s-statefulset',
|
||||
kubernetes: {
|
||||
namespace: 'apps',
|
||||
desiredReplicas: 3,
|
||||
currentReplicas: 3,
|
||||
readyReplicas: 2,
|
||||
availableReplicas: 2,
|
||||
updatedReplicas: 2,
|
||||
serviceName: 'checkout-headless',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(statefulSet?.label).toBe('StatefulSet');
|
||||
expect(statefulSet?.rows).toEqual([
|
||||
{ label: 'Service', value: 'checkout-headless' },
|
||||
{ label: 'Target', value: '3 pods' },
|
||||
{ label: 'Current', value: '3' },
|
||||
{ label: 'Ready', value: '2' },
|
||||
{ label: 'Available', value: '2' },
|
||||
{ label: 'Updated', value: '2' },
|
||||
{ label: 'Namespace', value: 'apps' },
|
||||
]);
|
||||
|
||||
const daemonSet = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'node-exporter',
|
||||
type: 'k8s-daemonset',
|
||||
kubernetes: {
|
||||
namespace: 'observability',
|
||||
desiredNumberScheduled: 6,
|
||||
currentNumberScheduled: 6,
|
||||
numberReady: 5,
|
||||
numberAvailable: 5,
|
||||
numberUnavailable: 1,
|
||||
numberMisscheduled: 0,
|
||||
updatedReplicas: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(daemonSet?.label).toBe('DaemonSet');
|
||||
expect(daemonSet?.rows).toEqual([
|
||||
{ label: 'Target', value: '6 nodes' },
|
||||
{ label: 'Current', value: '6' },
|
||||
{ label: 'Ready', value: '5' },
|
||||
{ label: 'Available', value: '5' },
|
||||
{ label: 'Updated', value: '5' },
|
||||
{ label: 'Unavailable', value: '1', tone: 'warning' },
|
||||
{ label: 'Misscheduled', value: '0', tone: 'default' },
|
||||
{ label: 'Namespace', value: 'observability' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the ReplicaSet labelling and generation fields the Detail column carries', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'checkout-api-replicaset',
|
||||
type: 'k8s-replicaset',
|
||||
kubernetes: {
|
||||
desiredReplicas: 4,
|
||||
readyReplicas: 3,
|
||||
fullyLabeledReplicas: 4,
|
||||
observedGeneration: 7,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.label).toBe('ReplicaSet');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Target', value: '4 pods' },
|
||||
{ label: 'Ready', value: '3' },
|
||||
{ label: 'Fully labeled', value: '4' },
|
||||
{ label: 'Observed generation', value: '7' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,18 @@ import {
|
||||
compactDetailRows as compactRows,
|
||||
compactDetailSections as compactSections,
|
||||
formatDetailBytesValue,
|
||||
formatDetailCountValue,
|
||||
formatDetailIntegerValue,
|
||||
makeDetailRow as makeRow,
|
||||
type DetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
import {
|
||||
formatPlatformTableDateTimeValue,
|
||||
formatPlatformTableDurationValue,
|
||||
formatPlatformTableRelativeTimeValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
|
||||
export type ResourceDetailDrawerKubernetesSection = DetailSection;
|
||||
|
||||
@@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = (
|
||||
return compactSections(sections);
|
||||
};
|
||||
|
||||
const countRow = (
|
||||
label: string,
|
||||
value: number | undefined,
|
||||
options: { noun?: string; tone?: (count: number) => DetailValueTone } = {},
|
||||
): DetailRow | null => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
const text = options.noun
|
||||
? formatDetailCountValue(value, options.noun)
|
||||
: formatDetailIntegerValue(value);
|
||||
return makeRow(label, text, options.tone ? { tone: options.tone(value) } : {});
|
||||
};
|
||||
|
||||
const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default');
|
||||
const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default');
|
||||
|
||||
// Kubernetes reports controller timestamps as RFC 3339 strings. The table row
|
||||
// only has room for an age, so the expansion carries the absolute time first
|
||||
// and the age after it: "Oct 2, 2026, 09:13 PM (1h ago)". The value wraps
|
||||
// because a phone-width detail cell is too narrow for both on one line.
|
||||
const timestampRow = (label: string, timestamp?: string | null): DetailRow | null => {
|
||||
const raw = asString(timestamp);
|
||||
if (!raw) return null;
|
||||
const absolute = formatPlatformTableDateTimeValue(raw, {
|
||||
emptyText: '',
|
||||
dateTimeFormat: { year: 'numeric' },
|
||||
});
|
||||
if (!absolute) return makeRow(label, raw, { wrap: true });
|
||||
const relative = formatPlatformTableRelativeTimeValue(raw, { emptyText: '' });
|
||||
return makeRow(label, relative ? `${absolute} (${relative})` : absolute, { wrap: true });
|
||||
};
|
||||
|
||||
const elapsedSeconds = (start?: string | null, end?: string | null): number | undefined => {
|
||||
const startedAt = Date.parse(asString(start) ?? '');
|
||||
const endedAt = Date.parse(asString(end) ?? '');
|
||||
if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) {
|
||||
return undefined;
|
||||
}
|
||||
return (endedAt - startedAt) / 1000;
|
||||
};
|
||||
|
||||
type ControllerRows = { label: string; rows: Array<DetailRow | null> };
|
||||
|
||||
// Each kind leads with what its narrow table row drops: the Detail column
|
||||
// (service name, timestamps) below the large layout and Target on a phone.
|
||||
const controllerRows = (resource: Resource): ControllerRows | null => {
|
||||
const k = resource.kubernetes;
|
||||
if (!k) return null;
|
||||
switch (resource.type) {
|
||||
case 'k8s-replicaset':
|
||||
return {
|
||||
label: 'ReplicaSet',
|
||||
rows: [
|
||||
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
|
||||
countRow('Current', k.currentReplicas),
|
||||
countRow('Ready', k.readyReplicas),
|
||||
countRow('Available', k.availableReplicas),
|
||||
countRow('Fully labeled', k.fullyLabeledReplicas),
|
||||
countRow('Observed generation', k.observedGeneration),
|
||||
],
|
||||
};
|
||||
case 'k8s-statefulset':
|
||||
return {
|
||||
label: 'StatefulSet',
|
||||
rows: [
|
||||
makeRow('Service', k.serviceName),
|
||||
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
|
||||
countRow('Current', k.currentReplicas),
|
||||
countRow('Ready', k.readyReplicas),
|
||||
countRow('Available', k.availableReplicas),
|
||||
countRow('Updated', k.updatedReplicas),
|
||||
],
|
||||
};
|
||||
case 'k8s-daemonset':
|
||||
return {
|
||||
label: 'DaemonSet',
|
||||
rows: [
|
||||
countRow('Target', k.desiredNumberScheduled, { noun: 'node' }),
|
||||
countRow('Current', k.currentNumberScheduled),
|
||||
countRow('Ready', k.numberReady),
|
||||
countRow('Available', k.numberAvailable),
|
||||
countRow('Updated', k.updatedReplicas),
|
||||
countRow('Unavailable', k.numberUnavailable, { tone: warnWhenPositive }),
|
||||
countRow('Misscheduled', k.numberMisscheduled, { tone: warnWhenPositive }),
|
||||
],
|
||||
};
|
||||
case 'k8s-job':
|
||||
return {
|
||||
label: 'Job',
|
||||
rows: [
|
||||
timestampRow('Started', k.startTime),
|
||||
timestampRow('Completed', k.completionTime),
|
||||
makeRow(
|
||||
'Duration',
|
||||
formatPlatformTableDurationValue(elapsedSeconds(k.startTime, k.completionTime), {
|
||||
emptyText: '',
|
||||
}),
|
||||
),
|
||||
countRow('Target', k.desiredReplicas, { noun: 'completion' }),
|
||||
countRow('Active', k.active),
|
||||
countRow('Succeeded', k.succeeded),
|
||||
countRow('Failed', k.failed, { tone: dangerWhenPositive }),
|
||||
],
|
||||
};
|
||||
case 'k8s-cronjob':
|
||||
return {
|
||||
label: 'CronJob',
|
||||
rows: [
|
||||
makeRow('Schedule', k.schedule, { valueClass: 'font-mono' }),
|
||||
timestampRow('Last run', k.lastScheduleTime),
|
||||
timestampRow('Last success', k.lastSuccessfulTime),
|
||||
makeRow('Suspended', k.suspend === true ? 'Yes' : null, { tone: 'warning' }),
|
||||
countRow('Active', k.active),
|
||||
],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Workload-controller facts for the always-visible summary of the resource
|
||||
// drawer, the same slot Docker containers use for their timestamps. The
|
||||
// controllers table hides Scope and Detail below its large layout and Target
|
||||
// on a phone, so without this a Job's completion time or a CronJob's last
|
||||
// success is unreachable from a narrow row.
|
||||
export const buildKubernetesControllerSection = (resource: Resource): DetailSection | null => {
|
||||
const controller = controllerRows(resource);
|
||||
if (!controller) return null;
|
||||
const rows = compactRows([
|
||||
...controller.rows,
|
||||
makeRow('Namespace', resource.kubernetes?.namespace),
|
||||
makeRow('Cluster', resource.kubernetes?.clusterName),
|
||||
]);
|
||||
if (rows.length === 0) return null;
|
||||
return {
|
||||
label: controller.label,
|
||||
rows,
|
||||
testId: 'resource-kubernetes-controller-section',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildKubernetesDetailsSummary = (resource: Resource): string | null => {
|
||||
const k = resource.kubernetes;
|
||||
if (!k) return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { updateStore } from '@/stores/updates';
|
||||
import { Button } from '@/components/shared/Button';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { getShippedDocUrl } from '@/utils/docsLinks';
|
||||
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
@@ -45,7 +46,7 @@ const statusPresentation = (status: string): { label: string; className: string
|
||||
}
|
||||
};
|
||||
|
||||
// A rollback restores the backup taken before this update was applied, so it
|
||||
// A rollback uses the backup taken before this update was applied, so it
|
||||
// is only offered where that backup is still retained on disk (the backend
|
||||
// clears backup_path when retention prunes it) and the update actually landed.
|
||||
const canRollBack = (entry: UpdateHistoryEntry): boolean =>
|
||||
@@ -89,7 +90,7 @@ export const UpdateHistorySection: Component = () => {
|
||||
Update History
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Updates applied through Pulse, with rollback to the backup taken before each one.
|
||||
Updates applied through Pulse, with version rollback using a retained backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -187,14 +188,30 @@ export const UpdateHistorySection: Component = () => {
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-3 text-sm text-base-content">
|
||||
<p>
|
||||
This restores the binary, configuration, and data from the backup taken before the
|
||||
update to {formatVersionLabel(entry().version_to)} on{' '}
|
||||
{formatTimestamp(entry().timestamp)}.
|
||||
<p class="text-muted">
|
||||
Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.
|
||||
</p>
|
||||
<p>
|
||||
Settings and alert changes made since that backup will be reverted, and Pulse will
|
||||
restart to complete the rollback.
|
||||
This requests an installation rollback using the backup taken before the update to{' '}
|
||||
{formatVersionLabel(entry().version_to)} on {formatTimestamp(entry().timestamp)}.
|
||||
Pulse will restart if the rollback succeeds.
|
||||
</p>
|
||||
<p>
|
||||
Restore scope is not reported for this backup. A scope-aware server keeps active
|
||||
runtime data in place and restores only the binary and independent installation
|
||||
files. Older recovery can also replace install-local data and configuration,
|
||||
reverting later changes.
|
||||
</p>
|
||||
<p>
|
||||
Do not assume this will undo settings or alerts. Full-state recovery requires
|
||||
Pulse to be stopped and a consistent backup of every active store.{' '}
|
||||
<a
|
||||
class="text-primary underline"
|
||||
href={`${getShippedDocUrl('AUTO_UPDATE.md')}#manual-rollback`}
|
||||
>
|
||||
Read the recovery instructions
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UpdateHistoryEntry } from '@/api/updates';
|
||||
|
||||
const mockListUpdateHistory = vi.fn();
|
||||
const mockStoreRollbackUpdate = vi.fn();
|
||||
let runningVersion: string | undefined = '6.0.5';
|
||||
|
||||
vi.mock('@/api/updates', () => ({
|
||||
UpdatesAPI: {
|
||||
@@ -13,7 +14,7 @@ vi.mock('@/api/updates', () => ({
|
||||
|
||||
vi.mock('@/stores/updates', () => ({
|
||||
updateStore: {
|
||||
versionInfo: () => ({ version: '6.0.5' }),
|
||||
versionInfo: () => (runningVersion ? { version: runningVersion } : null),
|
||||
rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args),
|
||||
},
|
||||
}));
|
||||
@@ -39,6 +40,7 @@ describe('UpdateHistorySection', () => {
|
||||
beforeEach(() => {
|
||||
mockListUpdateHistory.mockReset();
|
||||
mockStoreRollbackUpdate.mockReset();
|
||||
runningVersion = '6.0.5';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -102,6 +104,24 @@ describe('UpdateHistorySection', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
|
||||
expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Running now: Pulse v6.0.5.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Restore scope is not reported for this backup/)).toHaveTextContent(
|
||||
'A scope-aware server keeps active runtime data in place',
|
||||
);
|
||||
expect(screen.getByText(/Older recovery can also/)).toHaveTextContent(
|
||||
'reverting later changes',
|
||||
);
|
||||
expect(screen.getByText(/Do not assume this will undo settings or alerts/)).toHaveTextContent(
|
||||
'Full-state recovery requires Pulse to be stopped',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Read the recovery instructions' })).toHaveAttribute(
|
||||
'href',
|
||||
'/docs/AUTO_UPDATE#manual-rollback',
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/Settings and alert changes made since that backup will be reverted/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
|
||||
|
||||
@@ -116,4 +136,62 @@ describe('UpdateHistorySection', () => {
|
||||
expect(screen.queryByText('Roll back to Pulse v6.0.4?')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes the running version from an older history entry without guessing restore scope', async () => {
|
||||
runningVersion = 'v6.6.0-rc.1';
|
||||
mockListUpdateHistory.mockResolvedValue([{ ...baseEntry, notes: 'restore everything' }]);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
expect(screen.getByText('Running now: Pulse v6.6.0-rc.1.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This requests an installation rollback/)).toHaveTextContent(
|
||||
'before the update to v6.0.5',
|
||||
);
|
||||
expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('restore everything')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(baseEntry.backup_path!)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not infer the running version from the selected update when version information is missing', async () => {
|
||||
runningVersion = undefined;
|
||||
mockListUpdateHistory.mockResolvedValue([baseEntry]);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
expect(screen.getByText('Running now: Pulse unknown.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retains the consent and refreshes history after a rejected rollback', async () => {
|
||||
mockListUpdateHistory.mockResolvedValue([baseEntry]);
|
||||
mockStoreRollbackUpdate.mockResolvedValue(false);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
|
||||
await waitFor(() => expect(mockListUpdateHistory).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('Restore scope is not reported');
|
||||
expect(screen.getByRole('button', { name: 'Roll back to v6.0.4' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('prevents duplicate requests and cancellation while the rollback request is pending', async () => {
|
||||
mockListUpdateHistory.mockResolvedValue([baseEntry]);
|
||||
let finish!: (accepted: boolean) => void;
|
||||
mockStoreRollbackUpdate.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
|
||||
expect(screen.getByRole('button', { name: 'Starting rollback...' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Starting rollback...' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(mockStoreRollbackUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
finish(true);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
resolveHistoryRangeWithinLimit,
|
||||
} from '@/components/Storage/historyRangeAccess';
|
||||
import {
|
||||
DISK_DETAIL_HEADER_STACK_CLASS,
|
||||
DISK_DETAIL_HEALTH_LABEL_CLASS,
|
||||
DISK_DETAIL_HEALTH_ROW_CLASS,
|
||||
DISK_DETAIL_HEALTH_SUMMARY_CLASS,
|
||||
DISK_DETAIL_HISTORY_RANGE_OPTIONS,
|
||||
DISK_DETAIL_LIVE_CHARTS,
|
||||
getDiskAttributeValueTextClass,
|
||||
@@ -55,6 +59,7 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
|
||||
historyResourceId,
|
||||
attributeCards,
|
||||
historyCharts,
|
||||
healthPresentation,
|
||||
metricResourceId,
|
||||
collectionMessages,
|
||||
liveIOAvailable,
|
||||
@@ -91,19 +96,32 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
|
||||
return (
|
||||
<div class={STORAGE_DISK_DETAIL_ROOT_CLASS}>
|
||||
<div class={STORAGE_DISK_DETAIL_HEADER_CLASS}>
|
||||
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
|
||||
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>{diskData().model || 'Unknown Disk'}</span>
|
||||
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
|
||||
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
|
||||
<span
|
||||
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
|
||||
title="Data source"
|
||||
>
|
||||
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
|
||||
</span>
|
||||
<Show when={diskData().serial}>
|
||||
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
|
||||
</Show>
|
||||
<div class={DISK_DETAIL_HEADER_STACK_CLASS}>
|
||||
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
|
||||
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>
|
||||
{diskData().model || 'Unknown Disk'}
|
||||
</span>
|
||||
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
|
||||
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
|
||||
<span
|
||||
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
|
||||
title="Data source"
|
||||
>
|
||||
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
|
||||
</span>
|
||||
<Show when={diskData().serial}>
|
||||
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
|
||||
</Show>
|
||||
</div>
|
||||
{/* The verdict the row shows as a single word, with its reason in full. */}
|
||||
<div class={DISK_DETAIL_HEALTH_ROW_CLASS} data-testid="disk-detail-health">
|
||||
<span class={`${DISK_DETAIL_HEALTH_LABEL_CLASS} ${healthPresentation().tone}`}>
|
||||
{healthPresentation().label}
|
||||
</span>
|
||||
<Show when={healthPresentation().summary}>
|
||||
<span class={DISK_DETAIL_HEALTH_SUMMARY_CLASS}>{healthPresentation().summary}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
PHYSICAL_DISK_HEADER_SIZE_CLASS,
|
||||
PHYSICAL_DISK_HEADER_TEMP_CLASS,
|
||||
PHYSICAL_DISK_HEALTH_LABEL_CLASS,
|
||||
PHYSICAL_DISK_HEALTH_SUMMARY_CLASS,
|
||||
PHYSICAL_DISK_HEALTH_WRAP_CLASS,
|
||||
PHYSICAL_DISK_DEVICE_TEXT_CLASS,
|
||||
PHYSICAL_DISK_LIFE_CLASS,
|
||||
@@ -72,8 +71,10 @@ import {
|
||||
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
||||
PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS,
|
||||
PHYSICAL_DISK_TABLE_ROW_STYLE,
|
||||
getPhysicalDiskColumnWidthPercent,
|
||||
getPhysicalDiskCellPaddingClass,
|
||||
getPhysicalDiskColumnWidthStyle,
|
||||
getPhysicalDiskEmptyStatePresentation,
|
||||
getPhysicalDiskHealthCompactLabel,
|
||||
getPhysicalDiskHealthStatus,
|
||||
getPhysicalDiskHealthSummary,
|
||||
getPhysicalDiskHostLabel,
|
||||
@@ -117,11 +118,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
baseClass: string,
|
||||
columnId: PhysicalDiskTableColumnId,
|
||||
visibleClass: 'table-column' | 'table-cell',
|
||||
) =>
|
||||
`${baseClass} ${isPhysicalDiskColumnVisible(layoutMode(), columnId) ? visibleClass : 'hidden'}`.trim();
|
||||
const columnStyle = (columnId: PhysicalDiskTableColumnId) => ({
|
||||
width: `${getPhysicalDiskColumnWidthPercent(layoutMode(), columnId)}%`,
|
||||
});
|
||||
) => {
|
||||
const visibility = isPhysicalDiskColumnVisible(layoutMode(), columnId)
|
||||
? visibleClass
|
||||
: 'hidden';
|
||||
// Rendered cells and headers shed the shared primitives' desktop padding
|
||||
// on the phone layouts; `<col>` elements carry no padding.
|
||||
const padding =
|
||||
visibleClass === 'table-cell' ? getPhysicalDiskCellPaddingClass(layoutMode()) : '';
|
||||
return `${baseClass} ${padding} ${visibility}`.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
const columnStyle = (columnId: PhysicalDiskTableColumnId) =>
|
||||
getPhysicalDiskColumnWidthStyle(layoutMode(), columnId);
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
const model = useDiskListModel({
|
||||
disks: () => props.disks,
|
||||
@@ -327,6 +335,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
|
||||
const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
|
||||
const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status()));
|
||||
const healthCompactLabel = createMemo(() =>
|
||||
getPhysicalDiskHealthCompactLabel(status().label),
|
||||
);
|
||||
const healthLabelClass = () =>
|
||||
`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`;
|
||||
const isSelected = () => model.selectedDisk()?.id === disk.id;
|
||||
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
|
||||
const isSummaryHighlighted = () =>
|
||||
@@ -446,15 +459,30 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
data-storage-column="health"
|
||||
>
|
||||
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
|
||||
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`}>
|
||||
{status().label}
|
||||
</span>
|
||||
<Show when={healthSummary()}>
|
||||
{/* The row shows the verdict word only; the reason is the
|
||||
cell's title here and is spelled out in the disk drawer.
|
||||
The phone projection of the table container shows the
|
||||
shorter word; the tone stays on each span so the rendered
|
||||
label is the styled element in either projection. */}
|
||||
<Show
|
||||
when={healthCompactLabel() !== status().label}
|
||||
fallback={
|
||||
<span class={healthLabelClass()} title={healthSummary() || undefined}>
|
||||
{status().label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS}
|
||||
title={healthSummary()}
|
||||
class={`${healthLabelClass()} platform-table-label-compact`}
|
||||
title={healthSummary() || undefined}
|
||||
>
|
||||
{healthSummary()}
|
||||
{healthCompactLabel()}
|
||||
</span>
|
||||
<span
|
||||
class={`${healthLabelClass()} platform-table-label-full`}
|
||||
title={healthSummary() || undefined}
|
||||
>
|
||||
{status().label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getStorageBarTooltipTitle,
|
||||
getStorageBarZfsHeadingLabel,
|
||||
} from '@/features/storageBackups/storageBarPresentation';
|
||||
import { PlatformResponsiveTableLabel } from '@/features/platformPage/sharedPlatformPage';
|
||||
import { useTooltip } from '@/hooks/useTooltip';
|
||||
import { ProgressBar } from '@/components/shared/ProgressBar';
|
||||
import { TooltipPortal } from '@/components/shared/TooltipPortal';
|
||||
@@ -42,13 +43,14 @@ interface EnhancedStorageBarProps {
|
||||
export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
|
||||
const tip = useTooltip();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const { usagePercent, barColor, label, tooltipRows, zfsSummary } = useEnhancedStorageBarModel({
|
||||
used: () => props.used,
|
||||
total: () => props.total,
|
||||
free: () => props.free,
|
||||
zfsPool: () => props.zfsPool,
|
||||
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
|
||||
});
|
||||
const { usagePercent, barColor, label, compactLabel, tooltipRows, zfsSummary } =
|
||||
useEnhancedStorageBarModel({
|
||||
used: () => props.used,
|
||||
total: () => props.total,
|
||||
free: () => props.free,
|
||||
zfsPool: () => props.zfsPool,
|
||||
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={STORAGE_BAR_ROOT_CLASS}>
|
||||
@@ -71,7 +73,12 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
|
||||
}
|
||||
label={
|
||||
<span class={STORAGE_BAR_LABEL_WRAP_CLASS}>
|
||||
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>{label()}</span>
|
||||
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>
|
||||
{/* The phone projection of the table container keeps the
|
||||
percentage only; the used/total sublabel returns with the
|
||||
wider container and always lives in the tooltip. */}
|
||||
<PlatformResponsiveTableLabel compact={compactLabel()} full={label()} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getStoragePoolCellPaddingClass,
|
||||
isStoragePoolColumnVisible,
|
||||
type StoragePoolTableColumnId,
|
||||
type StoragePoolTableLayoutMode,
|
||||
@@ -62,7 +63,11 @@ interface StoragePoolRowProps {
|
||||
|
||||
export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
|
||||
const columnClass = (baseClass: string, columnId: StoragePoolTableColumnId) =>
|
||||
`${baseClass} ${isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'}`.trim();
|
||||
`${baseClass} ${getStoragePoolCellPaddingClass(props.layoutMode)} ${
|
||||
isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'
|
||||
}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null));
|
||||
const detailControlsId = createMemo(() =>
|
||||
buildSummaryDisclosureControlsId(props.summarySeriesId),
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/components/shared/Table';
|
||||
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
getStoragePoolColumnWidthPercent,
|
||||
getStoragePoolColumnWidthStyle,
|
||||
getStoragePoolTableColumns,
|
||||
getStoragePoolTableLayoutModeForContainer,
|
||||
getStorageEmptyStateMessage,
|
||||
@@ -106,9 +106,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
|
||||
{(column) => (
|
||||
<col
|
||||
class={columnClass(column.colClassName, column.id, 'table-column')}
|
||||
style={{
|
||||
width: `${getStoragePoolColumnWidthPercent(layoutMode(), column.id)}%`,
|
||||
}}
|
||||
style={getStoragePoolColumnWidthStyle(layoutMode(), column.id)}
|
||||
data-storage-column={column.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { createSignal, type JSX } from 'solid-js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DiskDetail } from '@/components/Storage/DiskDetail';
|
||||
@@ -135,6 +135,33 @@ describe('DiskDetail', () => {
|
||||
expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the health verdict and its reason in the drawer header', () => {
|
||||
const disk = buildDisk();
|
||||
disk.physicalDisk!.health = 'FAILED';
|
||||
|
||||
render(() => <DiskDetail disk={disk} nodes={[]} />);
|
||||
|
||||
const health = screen.getByTestId('disk-detail-health');
|
||||
expect(within(health).getByText('Replace Now')).toHaveClass('text-red-700');
|
||||
expect(within(health).getByText('Disk health has degraded to a critical state.')).toHaveClass(
|
||||
'min-w-0',
|
||||
'max-w-full',
|
||||
'whitespace-normal',
|
||||
'wrap-break-word',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the drawer header to the verdict alone when the disk is healthy', () => {
|
||||
const disk = buildDisk();
|
||||
disk.physicalDisk!.health = 'PASSED';
|
||||
|
||||
render(() => <DiskDetail disk={disk} nodes={[]} />);
|
||||
|
||||
const health = screen.getByTestId('disk-detail-health');
|
||||
expect(within(health).getByText('Healthy')).toBeInTheDocument();
|
||||
expect(within(health).queryByText('No active disk-health issues.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an explicit overview fallback when no detail readings are available', () => {
|
||||
const disk = buildDisk();
|
||||
delete disk.physicalDisk!.smart;
|
||||
|
||||
@@ -134,12 +134,18 @@ describe('DiskList', () => {
|
||||
'archive',
|
||||
'Cache Pool',
|
||||
'Archive Pool',
|
||||
'SMART failed.',
|
||||
'4%',
|
||||
'63°C',
|
||||
]) {
|
||||
expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument();
|
||||
}
|
||||
// The reason is not a second truncating string in the 32px row: it rides
|
||||
// on the verdict as its title and is spelled out in the disk drawer.
|
||||
expect(within(row as HTMLElement).queryByText('SMART failed.')).not.toBeInTheDocument();
|
||||
expect(within(row as HTMLElement).getByText('Replace Now')).toHaveAttribute(
|
||||
'title',
|
||||
'SMART failed.',
|
||||
);
|
||||
expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('96%')).not.toBeInTheDocument();
|
||||
@@ -275,7 +281,13 @@ describe('DiskList', () => {
|
||||
expect(screen.getByText('Parity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tower Array')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs Attention')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pending sectors detected.')).toBeInTheDocument();
|
||||
// The reason stays one hover away on the verdict rather than as a second
|
||||
// truncating string in the row; the drawer spells it out.
|
||||
expect(screen.queryByText('Pending sectors detected.')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Needs Attention')).toHaveAttribute(
|
||||
'title',
|
||||
'Pending sectors detected.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => {
|
||||
|
||||
@@ -1154,10 +1154,11 @@ describe('Storage', () => {
|
||||
|
||||
render(() => <Storage />);
|
||||
|
||||
// zpool's upper-case state is presented titleized, like every other state.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('DEGRADED').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Degraded').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByText('DEGRADED')[0]).toHaveAttribute(
|
||||
expect(screen.getAllByText('Degraded')[0]).toHaveAttribute(
|
||||
'title',
|
||||
'1 read, 2 checksum errors',
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('useEnhancedStorageBarModel', () => {
|
||||
|
||||
expect(result.usagePercent()).toBe(40);
|
||||
expect(result.label()).toBe('40% (400 B/1000 B)');
|
||||
expect(result.compactLabel()).toBe('40%');
|
||||
expect(result.tooltipRows()).toEqual([
|
||||
{ label: 'Used', value: '400 B' },
|
||||
{ label: 'Free', value: '600 B' },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
getDiskDetailAttributeCards,
|
||||
getDiskDetailHealthPresentation,
|
||||
getDiskDetailHistoryCharts,
|
||||
} from '@/features/storageBackups/diskDetailPresentation';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
@@ -29,6 +30,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
||||
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
|
||||
);
|
||||
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
|
||||
const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData()));
|
||||
const metricResourceId = createMemo(() => historyResourceId());
|
||||
const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData()));
|
||||
const liveIOAvailable = createMemo(() => {
|
||||
@@ -43,6 +45,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
||||
historyResourceId,
|
||||
attributeCards,
|
||||
historyCharts,
|
||||
healthPresentation,
|
||||
metricResourceId,
|
||||
collectionMessages,
|
||||
liveIOAvailable,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ZFSPool } from '@/types/api';
|
||||
import { getMetricColorClass } from '@/utils/metricThresholds';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import {
|
||||
getStorageBarCompactLabel,
|
||||
getStorageBarLabel,
|
||||
getStorageBarTooltipRows,
|
||||
getStorageBarUsagePercent,
|
||||
@@ -23,6 +24,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
|
||||
getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()),
|
||||
);
|
||||
const label = createMemo(() => getStorageBarLabel(options.used(), options.total()));
|
||||
const compactLabel = createMemo(() => getStorageBarCompactLabel(options.used(), options.total()));
|
||||
const tooltipRows = createMemo(() =>
|
||||
getStorageBarTooltipRows(options.used(), options.free(), options.total()),
|
||||
);
|
||||
@@ -32,6 +34,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
|
||||
usagePercent,
|
||||
barColor,
|
||||
label,
|
||||
compactLabel,
|
||||
tooltipRows,
|
||||
zfsSummary,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@ export function DiskList(props: DiskListProps) {
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Show when={props.diskStatusReason}>
|
||||
<p class="text-xs text-muted">{diskStatusTooltip()}</p>
|
||||
</Show>
|
||||
<For each={diskPresentation()}>
|
||||
{(disk) => {
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,10 @@ import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/Availa
|
||||
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import { getWorkloadsGuestProtectionPresentation } from '@/utils/workloadGuestPresentation';
|
||||
import {
|
||||
getWorkloadGuestDiskStatusMessage,
|
||||
getWorkloadsGuestProtectionPresentation,
|
||||
} from '@/utils/workloadGuestPresentation';
|
||||
|
||||
import { AvailabilityProbeSuggestionCard } from './AvailabilityProbeSuggestionCard';
|
||||
import { buildWorkloadsDiskPresentation } from './diskListModel';
|
||||
@@ -119,6 +122,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
const diskRows = (props.guest.disks ?? []).map((disk, index) =>
|
||||
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds),
|
||||
);
|
||||
const diskStatusMessage =
|
||||
isGuestDrawerVM(props.guest) && props.guest.diskStatusReason
|
||||
? getWorkloadGuestDiskStatusMessage(props.guest.diskStatusReason)
|
||||
: null;
|
||||
|
||||
return compactDetailSections([
|
||||
discovery
|
||||
@@ -187,7 +194,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
.map((row) => makeDetailRow(row.label, row.value)),
|
||||
),
|
||||
},
|
||||
props.hasFilesystemDetails && diskRows.length > 0
|
||||
(props.hasFilesystemDetails && diskRows.length > 0) || diskStatusMessage
|
||||
? {
|
||||
label: 'Filesystems',
|
||||
rows: compactDetailRows([
|
||||
@@ -212,11 +219,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
},
|
||||
),
|
||||
),
|
||||
makeDetailRow(
|
||||
'Status',
|
||||
isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : null,
|
||||
{ wrap: true },
|
||||
),
|
||||
makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true }),
|
||||
]),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -553,7 +553,13 @@ export function GuestRow(props: GuestRowProps) {
|
||||
|
||||
{/* Disk */}
|
||||
<Show when={isColVisible('disk')}>
|
||||
<td class="px-1.5 sm:px-2 py-0.5 align-middle" data-workload-col="disk">
|
||||
<td
|
||||
class="px-1.5 sm:px-2 py-0.5 align-middle"
|
||||
data-workload-col="disk"
|
||||
title={
|
||||
isVM(props.guest) && props.guest.diskStatusReason ? getDiskStatusTooltip() : undefined
|
||||
}
|
||||
>
|
||||
<Show when={isSparklineMode()}>
|
||||
{renderMetricSparkline(
|
||||
'disk',
|
||||
@@ -585,6 +591,11 @@ export function GuestRow(props: GuestRowProps) {
|
||||
aggregateDisk={props.guest.disk}
|
||||
anomaly={diskAnomaly()}
|
||||
thresholds={diskThresholds()}
|
||||
statusMessage={
|
||||
isVM(props.guest) && props.guest.diskStatusReason
|
||||
? getDiskStatusTooltip()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold */}
|
||||
{/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold.
|
||||
The fullest disk's percentage sits beside them so the cell still reads
|
||||
as a number at a glance; every bar keeps equal weight. */}
|
||||
<div
|
||||
data-stacked-disk-trigger
|
||||
class="h-full w-full"
|
||||
onMouseEnter={state.handleMouseEnter}
|
||||
onMouseLeave={state.handleMouseLeave}
|
||||
>
|
||||
<div class="flex h-full items-end justify-center gap-[3px]">
|
||||
<For each={presentation().verticalBars}>
|
||||
{(bar) => (
|
||||
<div
|
||||
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08]"
|
||||
title={bar.title}
|
||||
>
|
||||
<div class="flex h-full items-center justify-center gap-1.5">
|
||||
<Show when={presentation().verticalBarsLabel}>
|
||||
<span
|
||||
data-stacked-disk-max-label
|
||||
class="shrink-0 text-[10px] font-semibold leading-none tabular-nums text-base-content"
|
||||
title={presentation().verticalBarsLabelTitle}
|
||||
>
|
||||
{presentation().verticalBarsLabel}
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex h-full items-end justify-center gap-[3px]">
|
||||
<For each={presentation().verticalBars}>
|
||||
{(bar) => (
|
||||
<div
|
||||
data-stacked-disk-fill="vertical"
|
||||
class="absolute inset-0 rounded-xs brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
|
||||
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08]"
|
||||
title={bar.title}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
<div
|
||||
data-stacked-disk-fill="vertical"
|
||||
class="absolute inset-0 rounded-xs brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
|
||||
>
|
||||
<rect
|
||||
class="metric-fill-geometry"
|
||||
x="0"
|
||||
y={invertPercent(bar.fillPercent)}
|
||||
width="100"
|
||||
height={clampPercent(bar.fillPercent)}
|
||||
rx="2"
|
||||
fill={bar.color}
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<rect
|
||||
class="metric-fill-geometry"
|
||||
x="0"
|
||||
y={invertPercent(bar.fillPercent)}
|
||||
width="100"
|
||||
height={clampPercent(bar.fillPercent)}
|
||||
rx="2"
|
||||
fill={bar.color}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -242,6 +255,9 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
||||
<div class="font-medium mb-1 text-slate-300 border-b border-border pb-1">
|
||||
{presentation().tooltipTitle}
|
||||
</div>
|
||||
<Show when={props.statusMessage}>
|
||||
<p class="mb-1 text-xs text-muted">{props.statusMessage}</p>
|
||||
</Show>
|
||||
<For each={presentation().tooltipContent}>
|
||||
{(item, idx) => (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Fixed client/builder reason vocabulary, not a diagnosis of a native QGA fault.
|
||||
export const guestDiskDeferrals = [
|
||||
[
|
||||
'vm-locked',
|
||||
'Guest reads paused while Proxmox reports a VM operation lock, such as a backup. Pulse will check again on a later poll.',
|
||||
],
|
||||
[
|
||||
'lock-unverified',
|
||||
'Guest reads deferred because Pulse cannot verify that the VM is unlocked. Pulse will check again on a later poll.',
|
||||
],
|
||||
[
|
||||
'agent-busy',
|
||||
'Guest reads deferred while an earlier guest request is still in progress. Pulse will check again on a later poll.',
|
||||
],
|
||||
[
|
||||
'agent-cooldown',
|
||||
'Guest reads paused after an earlier request did not complete reliably. Pulse will check again after the cooldown.',
|
||||
],
|
||||
[
|
||||
'agent-response-incomplete',
|
||||
'Guest reads paused because the previous response was incomplete. Pulse will check again on a later poll.',
|
||||
],
|
||||
[
|
||||
'agent-capacity',
|
||||
'Guest reads deferred because Pulse has reached its guest-read capacity. Pulse will check again on a later poll.',
|
||||
],
|
||||
['invalid-guest-key', 'Guest reads unavailable because the VM identity is invalid.'],
|
||||
[
|
||||
'agent-timeout',
|
||||
'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.',
|
||||
],
|
||||
] as const;
|
||||
@@ -3,6 +3,8 @@ import { render, screen, cleanup } from '@solidjs/testing-library';
|
||||
import { DiskList } from '../DiskList';
|
||||
import { buildWorkloadsDiskPresentation, getWorkloadsDiskUsagePercent } from '../diskListModel';
|
||||
import type { Disk } from '@/types/api';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
|
||||
|
||||
function makeDisk(overrides: Partial<Disk> = {}): Disk {
|
||||
return {
|
||||
@@ -46,7 +48,10 @@ describe('DiskList', () => {
|
||||
'agent-not-running',
|
||||
'Guest agent not running. Install and start qemu-guest-agent in the VM.',
|
||||
],
|
||||
['agent-timeout', 'Guest agent timeout. Agent may need to be restarted.'],
|
||||
[
|
||||
'agent-timeout',
|
||||
'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.',
|
||||
],
|
||||
[
|
||||
'permission-denied',
|
||||
'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).',
|
||||
@@ -59,6 +64,11 @@ describe('DiskList', () => {
|
||||
],
|
||||
['agent-error', 'Error communicating with guest agent.'],
|
||||
['no-data', 'No disk data available from Proxmox API.'],
|
||||
['vm-stopped', 'Guest filesystem stats unavailable while the VM is stopped.'],
|
||||
[
|
||||
'no-status',
|
||||
'Guest filesystem stats unavailable because Pulse could not read the VM status from Proxmox.',
|
||||
],
|
||||
[
|
||||
'prev-no-filesystems',
|
||||
'Using last known disk stats. No filesystems found. VM may be booting or using a Live ISO.',
|
||||
@@ -78,6 +88,30 @@ describe('DiskList', () => {
|
||||
});
|
||||
|
||||
describe('rendering disks', () => {
|
||||
it.each(guestDiskDeferrals)(
|
||||
'keeps retained values and explains %s until fresh data resumes',
|
||||
(reason, message) => {
|
||||
const [disks, setDisks] = createSignal([makeDisk()]);
|
||||
const [status, setStatus] = createSignal<string | undefined>(`prev-${reason}`);
|
||||
render(() => <DiskList disks={disks()} diskStatusReason={status()} />);
|
||||
expect(screen.getByText(`Using last known disk stats. ${message}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
setDisks([makeDisk({ used: 80530636800, usage: 75 })]);
|
||||
setStatus(undefined);
|
||||
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(guestDiskDeferrals)(
|
||||
'explains %s without claiming previous values when none exist',
|
||||
(reason, message) => {
|
||||
render(() => <DiskList disks={[]} diskStatusReason={reason} />);
|
||||
expect(screen.getByText('-')).toHaveAttribute('title', message);
|
||||
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders a single disk with correct label, usage, and type', () => {
|
||||
const disk = makeDisk({ mountpoint: '/data', type: 'xfs' });
|
||||
render(() => <DiskList disks={[disk]} />);
|
||||
|
||||
+43
-1
@@ -1,7 +1,8 @@
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import type { ComponentProps } from 'solid-js';
|
||||
import { createSignal, type ComponentProps } from 'solid-js';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { GuestDrawerOverview } from '../GuestDrawerOverview';
|
||||
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
|
||||
|
||||
const paths = ['/mnt/Plex/Media/Animation/Films', '/mnt/Plex/Media/Animation/Series'];
|
||||
const props: ComponentProps<typeof GuestDrawerOverview> = {
|
||||
@@ -55,6 +56,47 @@ const props: ComponentProps<typeof GuestDrawerOverview> = {
|
||||
describe('GuestDrawerOverview filesystem labels', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it.each(guestDiskDeferrals)(
|
||||
'explains %s in the existing drawer even before any disk sample',
|
||||
(reason, message) => {
|
||||
render(() => (
|
||||
<GuestDrawerOverview
|
||||
{...props}
|
||||
hasFilesystemDetails={false}
|
||||
guest={{ ...props.guest, type: 'qemu', disks: [], diskStatusReason: reason }}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByText('Filesystems')).toBeInTheDocument();
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('retains disk rows with readable status, then withdraws the notice on fresh resumption', () => {
|
||||
const [guest, setGuest] = createSignal({
|
||||
...props.guest,
|
||||
type: 'qemu' as const,
|
||||
diskStatusReason: 'prev-vm-locked',
|
||||
});
|
||||
render(() => <GuestDrawerOverview {...props} guest={guest()} />);
|
||||
expect(
|
||||
screen.getByText(/^Using last known disk stats\. Guest reads paused/).closest('td'),
|
||||
).toHaveAttribute('colspan', '2');
|
||||
expect(screen.queryByText('prev-vm-locked')).not.toBeInTheDocument();
|
||||
for (const path of paths) expect(screen.getByText(path)).toBeInTheDocument();
|
||||
setGuest({
|
||||
...guest(),
|
||||
diskStatusReason: '',
|
||||
disks: guest().disks!.map((disk) => ({ ...disk, used: (disk.total ?? 0) * 0.75, usage: 75 })),
|
||||
});
|
||||
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Status')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: `Filesystem ${paths[0]} utilization` }),
|
||||
).toHaveAttribute('aria-valuenow', '75');
|
||||
});
|
||||
|
||||
it('shows linked-agent RAID arrays beside the guest storage evidence', () => {
|
||||
render(() => (
|
||||
<GuestDrawerOverview
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, cleanup } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { Memory, Disk } from '@/types/api';
|
||||
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -75,7 +76,9 @@ vi.mock('@/components/Workloads/EnhancedCPUBar', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../StackedDiskBar', () => ({
|
||||
StackedDiskBar: () => <div data-testid="disk-bar" />,
|
||||
StackedDiskBar: (props: { statusMessage?: string }) => (
|
||||
<div data-testid="disk-bar" data-status-message={props.statusMessage} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../StackedMemoryBar', () => ({
|
||||
@@ -668,6 +671,39 @@ describe('GuestRow', () => {
|
||||
});
|
||||
|
||||
describe('lock label', () => {
|
||||
it.each(guestDiskDeferrals)(
|
||||
'annotates retained row and disk tooltip for %s',
|
||||
(reason, message) => {
|
||||
const { container } = renderGuestRow({
|
||||
guest: makeGuest({ diskStatusReason: `prev-${reason}` }),
|
||||
});
|
||||
const expected = `Using last known disk stats. ${message}`;
|
||||
expect(container.querySelector('[data-workload-col="disk"]')).toHaveAttribute(
|
||||
'title',
|
||||
expected,
|
||||
);
|
||||
expect(screen.getByTestId('disk-bar')).toHaveAttribute('data-status-message', expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('clears retained status for a same-VM fresh update in sparkline mode without remounting', () => {
|
||||
const [guest, setGuest] = createSignal(
|
||||
makeGuest({ diskStatusReason: 'prev-agent-cooldown' }),
|
||||
);
|
||||
const { container } = render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<GuestRow guest={guest()} metricDisplayMode="sparklines" />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
const cell = container.querySelector('[data-workload-col="disk"]');
|
||||
expect(cell).toHaveAttribute('title', expect.stringContaining('Using last known'));
|
||||
setGuest(makeGuest({ diskStatusReason: undefined, disk: makeDisk({ usage: 75 }) }));
|
||||
expect(container.querySelector('[data-workload-col="disk"]')).toBe(cell);
|
||||
expect(cell).not.toHaveAttribute('title');
|
||||
});
|
||||
|
||||
it('shows lock label when guest is locked', () => {
|
||||
renderGuestRow({ guest: makeGuest({ lock: 'migrate' }) });
|
||||
expect(screen.getByText(/Lock:.*migrate/)).toBeTruthy();
|
||||
@@ -1308,23 +1344,26 @@ describe('GUEST_COLUMNS', () => {
|
||||
});
|
||||
|
||||
it('derives mobile overrides from the canonical guest column model', () => {
|
||||
// The phone name cell carries the toggle, status dot, and backup badge, so
|
||||
// it takes a wider anchor than the shared 30% and the metric chips share
|
||||
// the rest.
|
||||
expect(getGuestColumnStyle('name', true)).toEqual({
|
||||
width: '30%',
|
||||
'max-width': '30%',
|
||||
width: '38%',
|
||||
'max-width': '38%',
|
||||
});
|
||||
expect(getGuestColumnStyle('cpu', true)).toEqual({
|
||||
width: '11.3235%',
|
||||
'max-width': '11.3235%',
|
||||
width: '10.0294%',
|
||||
'max-width': '10.0294%',
|
||||
});
|
||||
expect(getGuestColumnStyle('availability', true)).toEqual({
|
||||
width: '7.2059%',
|
||||
'max-width': '7.2059%',
|
||||
width: '6.3824%',
|
||||
'max-width': '6.3824%',
|
||||
});
|
||||
expect(getGuestColumnStyle('type', true)).toEqual({
|
||||
width: '9.2647%',
|
||||
'max-width': '9.2647%',
|
||||
width: '8.2059%',
|
||||
'max-width': '8.2059%',
|
||||
});
|
||||
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' });
|
||||
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' });
|
||||
expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' });
|
||||
});
|
||||
|
||||
|
||||
@@ -343,6 +343,17 @@ describe('StackedDiskBar', () => {
|
||||
// ── Tooltip ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tooltip', () => {
|
||||
it('includes caller-owned freshness context beside unchanged disk evidence', async () => {
|
||||
const message = 'Using last known disk stats. Guest reads paused while a backup is running.';
|
||||
const { container } = render(() => (
|
||||
<StackedDiskBar disks={[makeDisk()]} statusMessage={message} />
|
||||
));
|
||||
await fireEvent.mouseEnter(getBarTrigger(container));
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
expect(screen.getByText('/')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Usage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows tooltip on mouse enter with disk details', async () => {
|
||||
const disk = makeDisk({
|
||||
used: 53687091200,
|
||||
@@ -587,4 +598,36 @@ describe('StackedDiskBar', () => {
|
||||
expect(bar?.getAttribute('fill')).toContain('239, 68, 68');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vertical-bars mode', () => {
|
||||
it('labels the micro-bars with the fullest disk so the cell reads as a number', () => {
|
||||
const { container } = render(() => (
|
||||
<StackedDiskBar
|
||||
mode="vertical-bars"
|
||||
disks={[
|
||||
makeDisk({ mountpoint: '/', usage: 24, used: 24, total: 100 }),
|
||||
makeDisk({ mountpoint: '/data', usage: 52, used: 52, total: 100 }),
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
const label = container.querySelector('[data-stacked-disk-max-label]');
|
||||
expect(label).toHaveTextContent('52%');
|
||||
expect(label).toHaveAttribute('title', 'Highest usage: /data 52%');
|
||||
// Every disk keeps its own bar with equal weight; the label is derived.
|
||||
expect(container.querySelectorAll('[data-stacked-disk-fill="vertical"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('omits the label when no disk has measured usage', () => {
|
||||
const { container } = render(() => (
|
||||
<StackedDiskBar
|
||||
mode="vertical-bars"
|
||||
disks={[makeDisk({ mountpoint: '/', usage: -1 })]}
|
||||
aggregateDisk={makeDisk({ usage: 30 })}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+5
-1
@@ -1661,7 +1661,11 @@ describe('Workloads performance contract', () => {
|
||||
);
|
||||
expect(filesystemSectionSource).toContain("layout: 'stacked'");
|
||||
expect(filesystemSectionSource).toContain('wrap: true');
|
||||
expect(guestDrawerOverviewSource.match(/layout: 'stacked'/g)).toHaveLength(1);
|
||||
// Both filesystem paths and their longer freshness explanation need
|
||||
// the card's full width. Do not couple that layout to a global count.
|
||||
expect(guestDrawerOverviewSource).toContain(
|
||||
"makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true })",
|
||||
);
|
||||
|
||||
expect(guestDrawerOverviewSource).toContain('TechnicalDetailsSection');
|
||||
expect(nodeDrawerOverviewSource).toContain('TechnicalDetailsSection');
|
||||
|
||||
+4
@@ -372,6 +372,10 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
title: '/x: 40% (40.0 B/100 B)',
|
||||
});
|
||||
expect(p.verticalBars[1].color).toBe(CRITICAL); // 120% -> critical
|
||||
// The fullest disk's usage labels the cell so a multi-disk host still
|
||||
// reads as a number without a hover.
|
||||
expect(p.verticalBarsLabel).toBe('120%');
|
||||
expect(p.verticalBarsLabelTitle).toBe('Highest usage: /y 120%');
|
||||
expect(p.tooltipContent).toHaveLength(3);
|
||||
expect(p.tooltipContent[2]).toMatchObject({ label: '/z', percent: '—', used: '?' });
|
||||
});
|
||||
|
||||
@@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({
|
||||
maxWidth: width,
|
||||
});
|
||||
|
||||
// The phone name cell carries the expansion toggle, status dot, and backup
|
||||
// badge before the name, so the shared 30% identity anchor left a 390px phone
|
||||
// 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". The
|
||||
// four metric chips beside it need far less than an even split, so the name
|
||||
// takes the same wider phone anchor the Proxmox host table uses.
|
||||
const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
|
||||
|
||||
// Responsive weights are normalized against the currently visible column set.
|
||||
// That keeps each workload view mode full-width without assuming one fixed set.
|
||||
const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
@@ -548,9 +555,11 @@ const getResponsiveColumnOverride = (
|
||||
activeIds,
|
||||
layoutMode === 'narrow'
|
||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'phone' || layoutMode === 'mobile'
|
||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
: layoutMode === 'phone'
|
||||
? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'mobile'
|
||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
);
|
||||
return typeof style.width === 'string' ? percentageColumn(style.width) : undefined;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface StackedDiskBarProps {
|
||||
showDiskCount?: boolean;
|
||||
anomaly?: AnomalyReport | null;
|
||||
thresholds?: MetricDisplayThresholds | null;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
export interface StackedDiskSegment {
|
||||
@@ -87,6 +88,10 @@ export interface StackedDiskBarPresentation {
|
||||
tooltipTitle: string;
|
||||
useStackedSegments: boolean;
|
||||
verticalBars: StackedDiskVerticalBar[];
|
||||
// The fullest disk's usage, shown beside the micro-bars so a multi-disk
|
||||
// host answers "how close is this host to running out?" without a hover.
|
||||
verticalBarsLabel: string;
|
||||
verticalBarsLabelTitle: string;
|
||||
verticalBarsMode: boolean;
|
||||
}
|
||||
|
||||
@@ -359,6 +364,8 @@ export function buildStackedDiskBarPresentation(
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const verticalBarsLabel = verticalBarsMode && maxInfo ? formatPercent(maxInfo.percent) : '';
|
||||
const verticalBarsLabelTitle = verticalBarsMode && maxInfo ? maxLabelFull : '';
|
||||
const tooltipContent = buildTooltipContent(allDisks, {
|
||||
aggregateDisk: props.aggregateDisk,
|
||||
aggregateMode,
|
||||
@@ -402,6 +409,8 @@ export function buildStackedDiskBarPresentation(
|
||||
tooltipTitle: allDisks.length > 1 ? 'Disk Breakdown' : 'Disk Usage',
|
||||
useStackedSegments,
|
||||
verticalBars,
|
||||
verticalBarsLabel,
|
||||
verticalBarsLabelTitle,
|
||||
verticalBarsMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('AlertDeliveryLogCard', () => {
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides held rows during an outage and restores their current state on recovery', () => {
|
||||
it('hides held rows when their own read fails and restores them on recovery', () => {
|
||||
const [unavailable, setUnavailable] = createSignal(false);
|
||||
const [heldEvents, setHeldEvents] = createSignal<AlertEvent[]>([
|
||||
{
|
||||
@@ -290,7 +290,8 @@ describe('AlertDeliveryLogCard', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={{ ...log, entries: [] }}
|
||||
unavailable={unavailable()}
|
||||
unavailable={false}
|
||||
heldEventsUnavailable={unavailable()}
|
||||
refreshing={false}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={[]}
|
||||
@@ -300,7 +301,9 @@ describe('AlertDeliveryLogCard', () => {
|
||||
|
||||
expect(screen.getByText('Deferred')).toBeInTheDocument();
|
||||
setUnavailable(true);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
/could not read held or deferred notifications/,
|
||||
);
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('nas-quiet (usage)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
|
||||
@@ -332,4 +335,106 @@ describe('AlertDeliveryLogCard', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/);
|
||||
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
|
||||
});
|
||||
it('keeps independently readable held evidence when delivery attempts are unavailable', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={log}
|
||||
unavailable={true}
|
||||
refreshing={false}
|
||||
heldEvents={[
|
||||
{
|
||||
id: 7,
|
||||
type: 'notification_deferred',
|
||||
alertId: 'held-known',
|
||||
occurredAt: '2026-10-03T10:00:00Z',
|
||||
reason: 'quiet_hours:performance',
|
||||
},
|
||||
]}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={webhooks}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/);
|
||||
expect(screen.getByText('held-known')).toBeInTheDocument();
|
||||
expect(screen.getByText('Quiet hours')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Delivered')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports missing held evidence without hiding readable attempts', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={log}
|
||||
unavailable={false}
|
||||
refreshing={false}
|
||||
heldEventsUnavailable={true}
|
||||
heldEvents={[
|
||||
{
|
||||
id: 8,
|
||||
type: 'notification_suppressed',
|
||||
alertId: 'stale-held',
|
||||
occurredAt: '2026-10-03T10:00:00Z',
|
||||
},
|
||||
]}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={webhooks}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
/could not read held or deferred notifications/,
|
||||
);
|
||||
expect(screen.getByText('Delivered')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ops Discord')).toBeInTheDocument();
|
||||
expect(screen.queryByText('stale-held')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not claim an empty activity window while held evidence is pending', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={{ ...log, entries: [] }}
|
||||
unavailable={false}
|
||||
refreshing={false}
|
||||
refreshingHeldEvents={true}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={[]}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Loading held and deferred notifications...',
|
||||
);
|
||||
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
|
||||
// A slow held read must not prevent a new delivery-attempt refresh.
|
||||
expect(screen.getByRole('button', { name: 'Refresh delivery status' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('does not claim an empty activity window when held evidence is unavailable', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={{ ...log, entries: [] }}
|
||||
unavailable={false}
|
||||
refreshing={false}
|
||||
heldEventsUnavailable={true}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={[]}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
/could not read held or deferred notifications/,
|
||||
);
|
||||
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the initial attempt read as loading rather than empty', () => {
|
||||
render(() => (
|
||||
<AlertDeliveryLogCard
|
||||
log={null}
|
||||
unavailable={false}
|
||||
refreshing={true}
|
||||
onRefresh={vi.fn()}
|
||||
webhooks={[]}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading delivery attempts...');
|
||||
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getAlertDestinationsDeliveryLogEmpty,
|
||||
getAlertDestinationsDeliveryLogTitle,
|
||||
getAlertDestinationsDeliveryLogUnavailable,
|
||||
getAlertDestinationsDeliveryLogLoading,
|
||||
getAlertDestinationsHeldEventsLoading,
|
||||
getAlertDestinationsHeldEventsUnavailable,
|
||||
getAlertDestinationsDeliveryRefreshLabel,
|
||||
} from '@/utils/alertDestinationsPresentation';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
@@ -28,6 +31,8 @@ interface AlertDeliveryLogCardProps {
|
||||
onRefresh: () => void;
|
||||
webhooks: Webhook[];
|
||||
heldEvents?: AlertEvent[];
|
||||
heldEventsUnavailable?: boolean;
|
||||
refreshingHeldEvents?: boolean;
|
||||
}
|
||||
|
||||
type DeliveryLogRow =
|
||||
@@ -99,10 +104,18 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) {
|
||||
return rest.length > 0 ? `${first} +${rest.length} more` : first;
|
||||
};
|
||||
|
||||
const entries = () => props.log?.entries ?? [];
|
||||
const entries = () => (props.unavailable ? [] : (props.log?.entries ?? []));
|
||||
const completedRetentionDays = () => props.log?.completedRetentionDays ?? 7;
|
||||
const deadLetterRetentionDays = () => props.log?.deadLetterRetentionDays ?? 30;
|
||||
const rows = () => mergeDeliveryLogRows(entries(), props.heldEvents ?? []);
|
||||
const rows = () =>
|
||||
mergeDeliveryLogRows(entries(), props.heldEventsUnavailable ? [] : (props.heldEvents ?? []));
|
||||
const attemptsLoading = () => props.refreshing || (!props.log && !props.unavailable);
|
||||
const confirmedEmpty = () =>
|
||||
props.log !== null &&
|
||||
!props.unavailable &&
|
||||
!attemptsLoading() &&
|
||||
!props.heldEventsUnavailable &&
|
||||
!props.refreshingHeldEvents;
|
||||
|
||||
const absoluteTimestamp = (value: string): string => {
|
||||
const parsed = new Date(value);
|
||||
@@ -149,100 +162,113 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={props.unavailable}>
|
||||
<p class="text-sm text-red-800 dark:text-red-300" role="alert">
|
||||
{getAlertDestinationsDeliveryLogUnavailable()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.heldEventsUnavailable}>
|
||||
<p class="text-sm text-red-800 dark:text-red-300" role="alert">
|
||||
{getAlertDestinationsHeldEventsUnavailable()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={attemptsLoading() && entries().length === 0}>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" role="status">
|
||||
{getAlertDestinationsDeliveryLogLoading()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.refreshingHeldEvents}>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" role="status">
|
||||
{getAlertDestinationsHeldEventsLoading()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show
|
||||
when={!props.unavailable}
|
||||
when={rows().length > 0}
|
||||
fallback={
|
||||
<p class="text-sm text-red-800 dark:text-red-300" role="alert">
|
||||
{getAlertDestinationsDeliveryLogUnavailable()}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={rows().length > 0}
|
||||
fallback={
|
||||
<Show when={confirmedEmpty()}>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{getAlertDestinationsDeliveryLogEmpty()}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ul class="max-h-80 divide-y divide-gray-200 overflow-y-auto dark:divide-gray-700">
|
||||
<For each={rows()}>
|
||||
{(row) =>
|
||||
row.kind === 'attempt' ? (
|
||||
<li class="flex flex-col gap-1 py-2">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span
|
||||
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${outcomeBadgeClasses[row.entry.outcome]}`}
|
||||
>
|
||||
{getAlertDeliveryLogOutcomeLabel(row.entry.outcome)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{destinationLabel(row.entry)}
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400"
|
||||
title={row.entry.alertIds.join(', ')}
|
||||
>
|
||||
{alertSummary(row.entry)}
|
||||
</span>
|
||||
<time
|
||||
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
|
||||
dateTime={row.entry.timestamp}
|
||||
title={formatRelativeTime(row.entry.timestamp)}
|
||||
>
|
||||
{absoluteTimestamp(row.entry.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
!row.entry.success && (row.entry.failureClass || row.entry.errorMessage)
|
||||
}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ul class="max-h-80 divide-y divide-gray-200 overflow-y-auto dark:divide-gray-700">
|
||||
<For each={rows()}>
|
||||
{(row) =>
|
||||
row.kind === 'attempt' ? (
|
||||
<li class="flex flex-col gap-1 py-2">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span
|
||||
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${outcomeBadgeClasses[row.entry.outcome]}`}
|
||||
>
|
||||
<p class="text-xs leading-5 text-red-700 dark:text-red-300">
|
||||
<Show when={row.entry.failureClass}>
|
||||
{(failureClass) => (
|
||||
<span class="font-medium">
|
||||
{getAlertDeliveryLogFailureClassLabel(failureClass())}
|
||||
{row.entry.errorMessage ? ': ' : ''}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{row.entry.errorMessage}
|
||||
</p>
|
||||
</Show>
|
||||
</li>
|
||||
) : (
|
||||
<li class="flex flex-col gap-1 py-2" title={row.event.message}>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span
|
||||
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
row.event.type === 'notification_deferred'
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{heldResourceLabel(row.event)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400">
|
||||
{describeAlertEventReason(row.event.reason)}
|
||||
</span>
|
||||
<time
|
||||
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
|
||||
dateTime={row.event.occurredAt}
|
||||
title={formatRelativeTime(row.event.occurredAt)}
|
||||
>
|
||||
{absoluteTimestamp(row.event.occurredAt)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
{getAlertDeliveryLogOutcomeLabel(row.entry.outcome)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{destinationLabel(row.entry)}
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400"
|
||||
title={row.entry.alertIds.join(', ')}
|
||||
>
|
||||
{alertSummary(row.entry)}
|
||||
</span>
|
||||
<time
|
||||
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
|
||||
dateTime={row.entry.timestamp}
|
||||
title={formatRelativeTime(row.entry.timestamp)}
|
||||
>
|
||||
{absoluteTimestamp(row.entry.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
!row.entry.success && (row.entry.failureClass || row.entry.errorMessage)
|
||||
}
|
||||
>
|
||||
<p class="text-xs leading-5 text-red-700 dark:text-red-300">
|
||||
<Show when={row.entry.failureClass}>
|
||||
{(failureClass) => (
|
||||
<span class="font-medium">
|
||||
{getAlertDeliveryLogFailureClassLabel(failureClass())}
|
||||
{row.entry.errorMessage ? ': ' : ''}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{row.entry.errorMessage}
|
||||
</p>
|
||||
</Show>
|
||||
</li>
|
||||
) : (
|
||||
<li class="flex flex-col gap-1 py-2" title={row.event.message}>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span
|
||||
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
row.event.type === 'notification_deferred'
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{heldResourceLabel(row.event)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400">
|
||||
{describeAlertEventReason(row.event.reason)}
|
||||
</span>
|
||||
<time
|
||||
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
|
||||
dateTime={row.event.occurredAt}
|
||||
title={formatRelativeTime(row.event.occurredAt)}
|
||||
>
|
||||
{absoluteTimestamp(row.event.occurredAt)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
+1
-1
@@ -259,7 +259,7 @@ describe('DestinationsTab recovery while editing SMTP', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Pulse could not read the delivery log, so recent delivery activity cannot be shown.',
|
||||
'Pulse could not read the delivery log. Refresh to check delivery attempts.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('SMTP fixture rejected')).not.toBeInTheDocument();
|
||||
|
||||
@@ -156,6 +156,17 @@ describe('useAlertDestinationsTabState', () => {
|
||||
await Promise.resolve();
|
||||
expect(result.deliveryLog()).toEqual(current);
|
||||
expect(result.deliveryLogUnavailable()).toBe(false);
|
||||
expect(result.heldEventsUnavailable()).toBe(false);
|
||||
expect(result.refreshingHeldEvents()).toBe(false);
|
||||
vi.mocked(AlertsAPI.getEvents).mockRejectedValueOnce(new Error('held events denied'));
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValueOnce(current);
|
||||
await result.loadDeliveryLog();
|
||||
expect(result.heldEvents()).toEqual([]);
|
||||
expect(result.heldEventsUnavailable()).toBe(true);
|
||||
expect(result.refreshingHeldEvents()).toBe(false);
|
||||
expect(result.deliveryLogUnavailable()).toBe(false);
|
||||
expect(result.deliveryLog()).toEqual(current);
|
||||
|
||||
expect(result.refreshingDeliveryLog()).toBe(false);
|
||||
} finally {
|
||||
cleanup();
|
||||
|
||||
@@ -107,6 +107,8 @@ describe('useNotificationDeliveryLog', () => {
|
||||
expect(state.deliveryLog()).toEqual(emptyLog);
|
||||
expect(state.refreshingDeliveryLog()).toBe(false);
|
||||
expect(state.heldEvents()).toEqual([]);
|
||||
expect(state.refreshingHeldEvents()).toBe(true);
|
||||
expect(state.heldEventsUnavailable()).toBe(false);
|
||||
} finally {
|
||||
held.resolve([]);
|
||||
dispose();
|
||||
@@ -132,6 +134,8 @@ describe('useNotificationDeliveryLog', () => {
|
||||
expect(state.heldEvents()).toHaveLength(1);
|
||||
await state.loadDeliveryLog();
|
||||
expect(state.heldEvents()).toEqual([]);
|
||||
expect(state.heldEventsUnavailable()).toBe(true);
|
||||
expect(state.refreshingHeldEvents()).toBe(false);
|
||||
expect(state.deliveryLog()).toEqual(emptyLog);
|
||||
expect(state.deliveryLogUnavailable()).toBe(false);
|
||||
expect(state.refreshingDeliveryLog()).toBe(false);
|
||||
@@ -222,6 +226,8 @@ describe('useNotificationDeliveryLog', () => {
|
||||
else older.reject(new Error('stale held read'));
|
||||
await Promise.resolve();
|
||||
expect(state.heldEvents()).toEqual(current);
|
||||
expect(state.heldEventsUnavailable()).toBe(false);
|
||||
expect(state.refreshingHeldEvents()).toBe(false);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
@@ -264,4 +270,56 @@ describe('useNotificationDeliveryLog', () => {
|
||||
expect(AlertsAPI.getEvents).toHaveBeenCalledTimes(1);
|
||||
}),
|
||||
);
|
||||
it('retains the held-read warning during retry until that read succeeds', () =>
|
||||
createRoot(async (dispose) => {
|
||||
const recovery = deferred<Awaited<ReturnType<typeof AlertsAPI.getEvents>>>();
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog);
|
||||
vi.mocked(AlertsAPI.getEvents)
|
||||
.mockRejectedValueOnce(new Error('permission denied'))
|
||||
.mockReturnValueOnce(recovery.promise);
|
||||
const state = useNotificationDeliveryLog();
|
||||
try {
|
||||
await state.loadDeliveryLog();
|
||||
expect(state.heldEventsUnavailable()).toBe(true);
|
||||
await state.loadDeliveryLog();
|
||||
expect(state.heldEventsUnavailable()).toBe(true);
|
||||
expect(state.refreshingHeldEvents()).toBe(true);
|
||||
expect(state.refreshingDeliveryLog()).toBe(false);
|
||||
recovery.resolve([]);
|
||||
await recovery.promise;
|
||||
expect(state.heldEventsUnavailable()).toBe(false);
|
||||
expect(state.refreshingHeldEvents()).toBe(false);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
}));
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'keeps the newest held read pending after an older %s',
|
||||
(outcome) =>
|
||||
createRoot(async (dispose) => {
|
||||
const older = deferred<Awaited<ReturnType<typeof AlertsAPI.getEvents>>>();
|
||||
const newer = deferred<Awaited<ReturnType<typeof AlertsAPI.getEvents>>>();
|
||||
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog);
|
||||
vi.mocked(AlertsAPI.getEvents)
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
const state = useNotificationDeliveryLog();
|
||||
try {
|
||||
await state.loadDeliveryLog();
|
||||
await state.loadDeliveryLog();
|
||||
if (outcome === 'success') older.resolve([]);
|
||||
else older.reject(new Error('stale denied request'));
|
||||
await Promise.resolve();
|
||||
expect(state.refreshingHeldEvents()).toBe(true);
|
||||
expect(state.heldEventsUnavailable()).toBe(false);
|
||||
newer.resolve([]);
|
||||
await newer.promise;
|
||||
expect(state.refreshingHeldEvents()).toBe(false);
|
||||
expect(state.heldEventsUnavailable()).toBe(false);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,6 +102,8 @@ export function DestinationsTab(props: DestinationsTabProps) {
|
||||
onRefresh={() => void state.loadDeliveryLog()}
|
||||
webhooks={state.webhooks()}
|
||||
heldEvents={state.heldEvents()}
|
||||
heldEventsUnavailable={state.heldEventsUnavailable()}
|
||||
refreshingHeldEvents={state.refreshingHeldEvents()}
|
||||
/>
|
||||
|
||||
<Show when={state.hasLoadError()}>
|
||||
|
||||
@@ -53,6 +53,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
|
||||
deliveryLogUnavailable,
|
||||
refreshingDeliveryLog,
|
||||
heldEvents,
|
||||
heldEventsUnavailable,
|
||||
refreshingHeldEvents,
|
||||
loadDeliveryLog,
|
||||
} = useNotificationDeliveryLog();
|
||||
const webhookState = useAlertWebhookDestinationsState({
|
||||
@@ -173,6 +175,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
|
||||
deliveryLogUnavailable,
|
||||
deliveryNeedsAttention,
|
||||
heldEvents,
|
||||
heldEventsUnavailable,
|
||||
refreshingHeldEvents,
|
||||
dismissTerminalFailures,
|
||||
dismissingTerminalFailures,
|
||||
handleRetry,
|
||||
|
||||
@@ -19,12 +19,15 @@ const DELIVERY_LOG_LIMIT = 200;
|
||||
// Held events are the third half of that story: notifications that were
|
||||
// deliberately not attempted, with the mechanism that held them. They come
|
||||
// from the alert event log and degrade independently — a failed events read
|
||||
// hides the held rows without marking delivery attempts unavailable.
|
||||
// withdraws the held rows and reports that gap without hiding readable attempts.
|
||||
export function useNotificationDeliveryLog() {
|
||||
const [deliveryLog, setDeliveryLog] = createSignal<NotificationDeliveryLog | null>(null);
|
||||
const [deliveryLogUnavailable, setDeliveryLogUnavailable] = createSignal(false);
|
||||
const [refreshingDeliveryLog, setRefreshingDeliveryLog] = createSignal(false);
|
||||
const [heldEvents, setHeldEvents] = createSignal<AlertEvent[]>([]);
|
||||
const [heldEventsUnavailable, setHeldEventsUnavailable] = createSignal(false);
|
||||
// Before the first read, an empty array means unknown, not no held alerts.
|
||||
const [refreshingHeldEvents, setRefreshingHeldEvents] = createSignal(true);
|
||||
|
||||
// Both reads belong to the same refresh, but held events never block the log.
|
||||
let latestRequest = 0;
|
||||
@@ -35,6 +38,7 @@ export function useNotificationDeliveryLog() {
|
||||
const ownsRequest = (request: number) => !disposed && request === latestRequest;
|
||||
|
||||
const loadHeldEvents = async (request: number) => {
|
||||
setRefreshingHeldEvents(true);
|
||||
try {
|
||||
const since = new Date(
|
||||
Date.now() - HELD_EVENT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
@@ -44,11 +48,16 @@ export function useNotificationDeliveryLog() {
|
||||
since,
|
||||
limit: HELD_EVENT_LIMIT,
|
||||
});
|
||||
if (ownsRequest(request)) setHeldEvents(events);
|
||||
if (!ownsRequest(request)) return;
|
||||
setHeldEvents(events);
|
||||
setHeldEventsUnavailable(false);
|
||||
} catch (error) {
|
||||
if (!ownsRequest(request)) return;
|
||||
logger.error('Failed to load held alert notification events', error);
|
||||
setHeldEvents([]);
|
||||
setHeldEventsUnavailable(true);
|
||||
} finally {
|
||||
if (ownsRequest(request)) setRefreshingHeldEvents(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +91,8 @@ export function useNotificationDeliveryLog() {
|
||||
deliveryLogUnavailable,
|
||||
refreshingDeliveryLog,
|
||||
heldEvents,
|
||||
heldEventsUnavailable,
|
||||
refreshingHeldEvents,
|
||||
loadDeliveryLog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export const DockerImagesTable: Component<
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="host"
|
||||
class="platform-table-mobile-w-15 md:w-[18%]"
|
||||
class="platform-table-phone-hidden md:w-[18%]"
|
||||
>
|
||||
Host
|
||||
</PlatformSortableTableHead>
|
||||
@@ -160,7 +160,7 @@ export const DockerImagesTable: Component<
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="usedBy"
|
||||
class="platform-table-mobile-w-15 md:w-[24%]"
|
||||
class="platform-table-phone-hidden md:w-[24%]"
|
||||
>
|
||||
Used by
|
||||
</PlatformSortableTableHead>
|
||||
@@ -168,7 +168,7 @@ export const DockerImagesTable: Component<
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="size"
|
||||
class="platform-table-mobile-w-15 md:w-[12%]"
|
||||
class="platform-table-mobile-w-20 md:w-[12%]"
|
||||
>
|
||||
Size
|
||||
</PlatformSortableTableHead>
|
||||
@@ -176,7 +176,7 @@ export const DockerImagesTable: Component<
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="update"
|
||||
class="platform-table-mobile-w-15 md:w-[16%]"
|
||||
class="platform-table-mobile-w-25 md:w-[16%]"
|
||||
>
|
||||
<PlatformResponsiveTableLabel compact="Update" full="Update check" />
|
||||
</PlatformSortableTableHead>
|
||||
@@ -211,13 +211,15 @@ export const DockerImagesTable: Component<
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* Host and consumers fit the row expansion on a phone; the
|
||||
row keeps the image, its size and the update verdict. */}
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
|
||||
>
|
||||
{dockerHostName(resource)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[20rem] truncate"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PlatformWindowedRows,
|
||||
PLATFORM_HEALTH_FILTER_OPTIONS,
|
||||
PlatformSortableTableHead,
|
||||
PlatformResponsiveTableLabel,
|
||||
PlatformTableNumberValue,
|
||||
PlatformTableToolbar,
|
||||
PlatformTableEmptyState,
|
||||
@@ -74,7 +75,12 @@ const formatServiceUpdate = (
|
||||
return { label: 'Stable', title: 'No active service update reported' };
|
||||
}
|
||||
|
||||
const label = state || 'Updating';
|
||||
// Docker reports the rollout state as a snake_case token
|
||||
// ("rollback_started"); read it as words in the cell and keep the raw
|
||||
// token, message and completion time in the title.
|
||||
const label = state
|
||||
? state.replace(/[_-]+/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
||||
: 'Updating';
|
||||
const title = [state, message, completedAt].filter(Boolean).join(' | ') || label;
|
||||
return { label, title };
|
||||
};
|
||||
@@ -256,7 +262,7 @@ export const DockerServicesTable: Component<{
|
||||
sortKey="running"
|
||||
class="platform-table-mobile-w-10 w-[10%] md:w-[8%]"
|
||||
>
|
||||
Running
|
||||
<PlatformResponsiveTableLabel compact="Run" full="Running" />
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
@@ -354,7 +360,10 @@ export const DockerServicesTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span class="truncate inline-block max-w-40" title={update().title}>
|
||||
<span
|
||||
class="inline-block max-w-full whitespace-normal wrap-break-word leading-3 md:max-w-40 md:truncate md:leading-normal"
|
||||
title={update().title}
|
||||
>
|
||||
{update().label}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createPlatformTableSortState,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableDateTimeSortValue,
|
||||
PlatformTableRelativeTimeValue,
|
||||
PlatformTableShell,
|
||||
type PlatformTableSortValue,
|
||||
withPlatformStatusCounts,
|
||||
@@ -255,11 +256,10 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-narrow-hidden hidden text-base-content md:table-cell`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-48 truncate"
|
||||
title={dockerTextValue(resource.docker?.startedAt)}
|
||||
>
|
||||
{dockerTextValue(resource.docker?.startedAt)}
|
||||
{/* Swarm reports a raw RFC3339 stamp with microseconds; an age
|
||||
reads at a glance and the stamp stays in the title. */}
|
||||
<span title={dockerTextValue(resource.docker?.startedAt)}>
|
||||
<PlatformTableRelativeTimeValue value={resource.docker?.startedAt} />
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1505,6 +1505,8 @@ describe('Docker native tables', () => {
|
||||
));
|
||||
|
||||
expect(screen.getByText('Update')).toBeInTheDocument();
|
||||
expect(screen.getByText('Run')).toHaveClass('platform-table-label-compact');
|
||||
expect(screen.getByText('Running')).toHaveClass('platform-table-label-full');
|
||||
expect(screen.getByText('Stack')).toBeInTheDocument();
|
||||
expect(screen.getByText('shop')).toBeInTheDocument();
|
||||
expect(screen.getByText('checkout-api')).toBeInTheDocument();
|
||||
@@ -1512,7 +1514,15 @@ describe('Docker native tables', () => {
|
||||
expect(screen.getByText('replicated')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('rollback_started')).toBeInTheDocument();
|
||||
// The snake_case rollout token reads as words; the raw token stays in the title.
|
||||
expect(screen.getByText('Rollback started')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rollback started')).toHaveClass(
|
||||
'whitespace-normal',
|
||||
'wrap-break-word',
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'),
|
||||
).toContain('rollback_started');
|
||||
expect(screen.getByText('18080:8080/tcp')).toBeInTheDocument();
|
||||
expect(screen.getByText('manager-1')).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-docker-service-row="service-1"]')).not.toBeNull();
|
||||
@@ -1651,7 +1661,10 @@ describe('Docker native tables', () => {
|
||||
expect(screen.getByText('web.2')).toBeInTheDocument();
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText('running 2 minutes')).toBeInTheDocument();
|
||||
expect(screen.getByText('2026-05-24T13:05:00Z')).toBeInTheDocument();
|
||||
// The raw RFC3339 stamp moves to the title; the cell shows an age.
|
||||
const started = screen.getByTitle('2026-05-24T13:05:00Z');
|
||||
expect(started).toBeInTheDocument();
|
||||
expect(started.textContent).toMatch(/ago$/);
|
||||
});
|
||||
|
||||
it('renders Docker Swarm secret API metadata without secret data', () => {
|
||||
|
||||
@@ -30,7 +30,9 @@ describe('dockerContainerTableModel', () => {
|
||||
const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true);
|
||||
const ids = columns.map((column) => column.id);
|
||||
|
||||
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates']);
|
||||
// Restarts waits for mobile width: a phone row could not fit its header
|
||||
// beside the Update control, and the count stays in the row expansion.
|
||||
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']);
|
||||
expect(getDockerContainerColumnWidthStyle('container', 'phone', ids)).toEqual({
|
||||
width: '30%',
|
||||
});
|
||||
|
||||
@@ -70,7 +70,9 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
|
||||
state: 'narrow',
|
||||
cpu: 'narrow',
|
||||
memory: 'narrow',
|
||||
restarts: 'phone',
|
||||
// A phone row cannot fit the Restarts header beside the Update control;
|
||||
// the count stays in the row expansion there and returns at mobile width.
|
||||
restarts: 'mobile',
|
||||
updates: 'narrow',
|
||||
actions: 'mobile',
|
||||
host: 'tablet',
|
||||
@@ -126,11 +128,10 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
|
||||
},
|
||||
phone: {
|
||||
container: 32,
|
||||
state: 13,
|
||||
cpu: 15,
|
||||
memory: 17,
|
||||
restarts: 10,
|
||||
updates: 13,
|
||||
state: 14,
|
||||
cpu: 14,
|
||||
memory: 14,
|
||||
updates: 16,
|
||||
},
|
||||
mobile: {
|
||||
container: 30,
|
||||
@@ -151,17 +152,20 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
|
||||
updates: 9,
|
||||
actions: 6,
|
||||
},
|
||||
// Compact is what a 1280-1536px laptop window gets. Host, Engine and the
|
||||
// Update control are short values that were clipped ("docker 2…") while the
|
||||
// percentage-only CPU and memory bars had room to spare.
|
||||
compact: {
|
||||
container: 18,
|
||||
host: 10,
|
||||
runtime: 8,
|
||||
image: 18,
|
||||
host: 12,
|
||||
runtime: 9.5,
|
||||
image: 16.5,
|
||||
state: 7,
|
||||
cpu: 11,
|
||||
memory: 12,
|
||||
restarts: 9,
|
||||
cpu: 9.5,
|
||||
memory: 10,
|
||||
restarts: 8.5,
|
||||
ports: 12,
|
||||
updates: 9,
|
||||
updates: 10,
|
||||
actions: 8,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
formatPlatformTableDateTimeValue,
|
||||
formatPlatformTableRelativeTimeValue,
|
||||
formatPlatformTableTextValue,
|
||||
getPlatformResourceCountNoun,
|
||||
getPlatformTableCellClassForKind,
|
||||
PlatformTableShell,
|
||||
type PlatformTableSortValue,
|
||||
@@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string =>
|
||||
const controllerKind = (resource: Resource): string =>
|
||||
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
|
||||
|
||||
const countedTarget = (count: number | undefined, noun: string): string => {
|
||||
const value = count ?? 0;
|
||||
return `${value} ${getPlatformResourceCountNoun(noun, value)}`;
|
||||
};
|
||||
|
||||
const targetValue = (resource: Resource): string => {
|
||||
switch (resource.type) {
|
||||
case 'k8s-replicaset':
|
||||
case 'k8s-statefulset':
|
||||
return `${resource.kubernetes?.desiredReplicas ?? 0} pods`;
|
||||
return countedTarget(resource.kubernetes?.desiredReplicas, 'pods');
|
||||
case 'k8s-daemonset':
|
||||
return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`;
|
||||
return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes');
|
||||
case 'k8s-job':
|
||||
return `${resource.kubernetes?.desiredReplicas ?? 0} completions`;
|
||||
return countedTarget(resource.kubernetes?.desiredReplicas, 'completions');
|
||||
case 'k8s-cronjob':
|
||||
return formatPlatformTableTextValue(resource.kubernetes?.schedule);
|
||||
default:
|
||||
@@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="kind"
|
||||
class="platform-table-mobile-w-15 md:w-[8%]"
|
||||
class="platform-table-mobile-w-25 md:w-[8%]"
|
||||
>
|
||||
Kind
|
||||
</PlatformSortableTableHead>
|
||||
@@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{
|
||||
>
|
||||
Scope
|
||||
</PlatformSortableTableHead>
|
||||
{/* The phone row keeps controller, kind, ready and issues. A
|
||||
15% kind track clipped "DaemonSet" and "StatefulSet" and the
|
||||
target track clipped "1 completions", so target moves to the
|
||||
row expansion, which also carries the schedule and job
|
||||
timestamps the narrower layouts drop. */}
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="target"
|
||||
class="platform-table-mobile-w-20 md:w-[12%]"
|
||||
class="platform-table-phone-hidden md:w-[12%]"
|
||||
>
|
||||
Target
|
||||
</PlatformSortableTableHead>
|
||||
@@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="exceptions"
|
||||
class="platform-table-mobile-w-20 md:w-[11%]"
|
||||
class="platform-table-mobile-w-30 md:w-[11%]"
|
||||
>
|
||||
<PlatformResponsiveTableLabel compact="Issues" full="Exceptions" />
|
||||
</PlatformSortableTableHead>
|
||||
@@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
|
||||
>
|
||||
<span class="inline-block max-w-48 truncate" title={target()}>
|
||||
{target()}
|
||||
|
||||
+125
-1
@@ -1,6 +1,16 @@
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/resources', () => ({
|
||||
ResourceAPI: {
|
||||
getFacetBundle: vi.fn().mockResolvedValue({
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { KubernetesControllersTable } from '../KubernetesControllersTable';
|
||||
@@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => {
|
||||
expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => {
|
||||
render(() => (
|
||||
<KubernetesControllersTable
|
||||
resources={[
|
||||
makeResource({
|
||||
id: 'node-exporter',
|
||||
type: 'k8s-daemonset',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'observability',
|
||||
resourceKind: 'DaemonSet',
|
||||
desiredNumberScheduled: 6,
|
||||
numberReady: 6,
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No controllers"
|
||||
emptyDescription="No controllers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
// "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and
|
||||
// "1 completions" clipped in the target track, so the phone row widens kind
|
||||
// and issues and demotes target to the row expansion.
|
||||
expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25');
|
||||
expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden');
|
||||
expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden');
|
||||
expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30');
|
||||
expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30');
|
||||
expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15');
|
||||
expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden');
|
||||
});
|
||||
|
||||
it('shows the absolute Job and CronJob times in the row expansion', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
render(() => (
|
||||
<KubernetesControllersTable
|
||||
resources={[
|
||||
makeResource({
|
||||
id: 'nightly-import',
|
||||
type: 'k8s-job',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
resourceKind: 'Job',
|
||||
desiredReplicas: 10,
|
||||
active: 1,
|
||||
succeeded: 8,
|
||||
failed: 2,
|
||||
startTime: '2026-05-24T12:55:00Z',
|
||||
completionTime: '2026-05-24T13:00:00Z',
|
||||
},
|
||||
}),
|
||||
makeResource({
|
||||
id: 'billing-rollup',
|
||||
type: 'k8s-cronjob',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
resourceKind: 'CronJob',
|
||||
schedule: '*/5 * * * *',
|
||||
lastScheduleTime: '2026-05-24T13:25:00Z',
|
||||
lastSuccessfulTime: '2026-05-24T12:55:00Z',
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No controllers"
|
||||
emptyDescription="No controllers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
// Tapping the row opens the expansion; the Detail column is hidden below a
|
||||
// large container, so this is where a phone or half-width pane reads the
|
||||
// completion time.
|
||||
fireEvent.click(screen.getByText('nightly-import'));
|
||||
const job = screen.getByTestId('resource-kubernetes-controller-section');
|
||||
expect(within(job).getByText('Started')).toBeInTheDocument();
|
||||
expect(
|
||||
within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(job).getByText('Completed')).toBeInTheDocument();
|
||||
expect(
|
||||
within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(job).getByText('Duration')).toBeInTheDocument();
|
||||
expect(within(job).getByText('5m')).toBeInTheDocument();
|
||||
expect(within(job).getByText('Target')).toBeInTheDocument();
|
||||
expect(within(job).getByText('10 completions')).toBeInTheDocument();
|
||||
expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2');
|
||||
|
||||
fireEvent.click(screen.getByText('billing-rollup'));
|
||||
const cron = screen.getByTestId('resource-kubernetes-controller-section');
|
||||
expect(within(cron).getByText('Schedule')).toBeInTheDocument();
|
||||
expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument();
|
||||
expect(within(cron).getByText('Last run')).toBeInTheDocument();
|
||||
expect(
|
||||
within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(cron).getByText('Last success')).toBeInTheDocument();
|
||||
expect(
|
||||
within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch');
|
||||
});
|
||||
|
||||
it('falls back to start and last-run times when a Job or CronJob has not completed', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
@@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
// A single desired completion reads as "1 completion", matching the
|
||||
// expansion's Target row.
|
||||
expect(screen.getByText('1 completion')).toBeInTheDocument();
|
||||
expect(screen.getByText('Started 1m ago')).toHaveAttribute(
|
||||
'title',
|
||||
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,
|
||||
|
||||
+1
-1
@@ -322,7 +322,7 @@ describe('platform overview layout guardrails', () => {
|
||||
expect(source).toContain('platform-table-mobile-w-30');
|
||||
}
|
||||
expect(dockerImagesTableSource).toMatch(
|
||||
/sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[12%\]"/,
|
||||
/sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/,
|
||||
);
|
||||
expect(dockerServicesTableSource).toMatch(
|
||||
/sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/,
|
||||
|
||||
@@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: {
|
||||
}
|
||||
header={
|
||||
<>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[15%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')}`}>
|
||||
<PlatformResponsiveTableLabel compact="Server" full="Backup server" />
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[10%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
|
||||
<PlatformResponsiveTableLabel compact="State" full="Status" />
|
||||
</TableHead>
|
||||
<Show when={columnVisible('version')}>
|
||||
@@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: {
|
||||
</TableHead>
|
||||
</Show>
|
||||
<Show when={columnVisible('datastore')}>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[13%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
|
||||
<PlatformResponsiveTableLabel compact="Store" full="Datastore" />
|
||||
</TableHead>
|
||||
</Show>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[15%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')}`}>
|
||||
Used
|
||||
</TableHead>
|
||||
<Show when={columnVisible('backups')}>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[8%]`}
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')}`}
|
||||
aria-label="Backups"
|
||||
title="Backups"
|
||||
>
|
||||
@@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: {
|
||||
>
|
||||
{(datastore) => (
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={usageVariant(pct())}
|
||||
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
|
||||
ariaHidden
|
||||
/>
|
||||
<Show when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}>
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={usageVariant(pct())}
|
||||
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
|
||||
ariaHidden
|
||||
/>
|
||||
</Show>
|
||||
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
|
||||
<PlatformTablePercentValue value={pct()} />
|
||||
</span>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
buildProxmoxBackupRecoveryModel,
|
||||
coverageRowMatchesSearch,
|
||||
isBackupArtifact,
|
||||
recoverableArtifactMatchesSearch,
|
||||
type RecoverableArtifact,
|
||||
type WorkloadCoverageRow,
|
||||
@@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{
|
||||
return recoveryModel().coverageRows.flatMap((row) => {
|
||||
const artifacts = row.artifacts.filter(locationMatches);
|
||||
if (artifacts.length === 0) return [];
|
||||
// Same rule as the model: running or failed artifacts stay listed but
|
||||
// never become a "latest" pointer, and snapshots never become the backup.
|
||||
const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed);
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
artifacts,
|
||||
latestRecovery: newestArtifact(artifacts),
|
||||
latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')),
|
||||
latestBackup: newestArtifact(completed.filter(isBackupArtifact)),
|
||||
latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')),
|
||||
latestArchive: newestArtifact(
|
||||
artifacts.filter((artifact) => artifact.sourceKind === 'archive'),
|
||||
completed.filter((artifact) => artifact.sourceKind === 'archive'),
|
||||
),
|
||||
latestSnapshot: newestArtifact(
|
||||
artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||
),
|
||||
pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length,
|
||||
archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length,
|
||||
@@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
case 'workload':
|
||||
return cmpString(a.workload.label, b.workload.label, direction);
|
||||
case 'latest':
|
||||
return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction);
|
||||
return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
|
||||
case 'pbs':
|
||||
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
|
||||
case 'archive':
|
||||
|
||||
@@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing
|
||||
// "None" instead of restating the source ("No PVE backup file"). The long form
|
||||
// truncated at most widths and stays available as the hover title.
|
||||
const COVERAGE_EMPTY_LABEL = 'None';
|
||||
const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file';
|
||||
// Matches the posture engine's own wording for a snapshot-only subject.
|
||||
const COVERAGE_SNAPSHOT_ONLY_TITLE =
|
||||
'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.';
|
||||
|
||||
const coveragePostureVariant = (
|
||||
posture: WorkloadCoverageRow['posture'],
|
||||
@@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: {
|
||||
headClass={getPlatformTableHeadClassForKind('text')}
|
||||
/>
|
||||
<SortableHead
|
||||
label={layoutMode() === 'compact' ? 'Age' : 'Restore'}
|
||||
label={
|
||||
layoutMode() === 'compact'
|
||||
? 'Age'
|
||||
: layoutMode() === 'basic'
|
||||
? 'Backup'
|
||||
: 'Last backup'
|
||||
}
|
||||
sortKey="latest"
|
||||
currentSort={props.sortKey}
|
||||
direction={props.sortDirection}
|
||||
@@ -407,14 +417,26 @@ export function ProxmoxCoverageTable(props: {
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<Show
|
||||
when={row.latestRecovery}
|
||||
when={row.latestBackup}
|
||||
fallback={
|
||||
<span class="text-muted" title="No restore point">
|
||||
<span
|
||||
class="text-muted"
|
||||
title={
|
||||
row.latestSnapshot
|
||||
? COVERAGE_SNAPSHOT_ONLY_TITLE
|
||||
: COVERAGE_NO_BACKUP_TITLE
|
||||
}
|
||||
>
|
||||
{COVERAGE_EMPTY_LABEL}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
||||
{(artifact) => (
|
||||
<ProxmoxBackupAgeText
|
||||
artifact={artifact()}
|
||||
compact={layoutMode() === 'compact'}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<Show when={columnVisible('pbs')}>
|
||||
@@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
||||
{(artifact) => (
|
||||
<ProxmoxBackupAgeText
|
||||
artifact={artifact()}
|
||||
compact={layoutMode() === 'compact'}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
</Show>
|
||||
@@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: {
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[24rem] truncate"
|
||||
title={artifact.detail}
|
||||
title={artifact.detailTitle ?? artifact.detail}
|
||||
>
|
||||
{artifact.detail || '—'}
|
||||
</span>
|
||||
|
||||
@@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: {
|
||||
artifact.workload.name || artifact.workload.label,
|
||||
artifact.workload.typeLabel,
|
||||
artifact.workload.vmid,
|
||||
// The phone projection has no location column, so the row's
|
||||
// hover title carries the repository instead.
|
||||
columnVisible('location') ? undefined : artifact.location,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
@@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: {
|
||||
</TableCell>
|
||||
</Show>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
|
||||
<ProxmoxBackupAgeText artifact={artifact} />
|
||||
<ProxmoxBackupAgeText artifact={artifact} compact={layoutMode() === 'compact'} />
|
||||
</TableCell>
|
||||
<Show when={columnVisible('size')}>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}>
|
||||
@@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: {
|
||||
</TableCell>
|
||||
<Show when={columnVisible('details')}>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
|
||||
<span class="inline-block max-w-[20rem] truncate" title={artifact.detail}>
|
||||
<span
|
||||
class="inline-block max-w-[20rem] truncate"
|
||||
title={artifact.detailTitle ?? artifact.detail}
|
||||
>
|
||||
{artifact.detail || '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
expect(headers).toContain('Target ID');
|
||||
expect(headers).toContain('Node');
|
||||
expect(headers).toContain('Posture▲');
|
||||
expect(headers).toContain('Restore');
|
||||
expect(headers).toContain('Last backup');
|
||||
expect(headers).toContain('PBS snapshot');
|
||||
expect(headers).toContain('Guest snapshot');
|
||||
expect(headers).not.toContain('PVE file');
|
||||
@@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
// cannot truncate to "No PVE backup f…".
|
||||
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
|
||||
for (const title of [
|
||||
'No restore point',
|
||||
'No PBS snapshot or PVE backup file',
|
||||
'No PBS snapshot',
|
||||
'No PVE backup file',
|
||||
'No guest snapshot',
|
||||
|
||||
@@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => {
|
||||
expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([
|
||||
'Workload',
|
||||
'Via',
|
||||
'Loc',
|
||||
'Age▼',
|
||||
'State',
|
||||
]);
|
||||
expect(document.body.textContent).toContain('VM 100');
|
||||
expect(document.body.textContent).toContain('main / pve1');
|
||||
// The phone projection has no location column; the row title carries it.
|
||||
expect(document.body.textContent).not.toContain('main / pve1');
|
||||
expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull();
|
||||
expect(document.body.textContent).not.toContain('5 PBS files');
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () =>
|
||||
expect(model.coverageSummary.attention).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => {
|
||||
it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => {
|
||||
const postures = new Map([
|
||||
['vm-830', posture('vm-830', 'unprotected')],
|
||||
['vm-840', posture('vm-840', 'protected')],
|
||||
|
||||
@@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the archive format as its detail and keeps the volid on hover and in search', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [],
|
||||
archives: [archive({ format: 'tar.zst' })],
|
||||
snapshots: [],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
const [artifact] = model.recoverableArtifacts;
|
||||
expect(artifact.detail).toBe('tar.zst');
|
||||
expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
|
||||
expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the archive file name when the provider reports no format', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [],
|
||||
archives: [archive({ format: '' })],
|
||||
snapshots: [],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
|
||||
});
|
||||
|
||||
it('keeps a terminally incomplete PBS artifact out of latest recovery', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
@@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => {
|
||||
|
||||
const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed'));
|
||||
expect(failed).toMatchObject({ failed: true, running: false, verified: undefined });
|
||||
expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z');
|
||||
expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z');
|
||||
expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a fresh guest snapshot from standing in for the last backup', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })],
|
||||
archives: [],
|
||||
snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
const row = model.coverageRows[0];
|
||||
expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z');
|
||||
expect(row.latestBackup?.sourceKind).toBe('pbs');
|
||||
expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z');
|
||||
expect(
|
||||
getRecoveryAgeBand(
|
||||
row.latestBackup?.createdMs,
|
||||
model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'),
|
||||
),
|
||||
).toBe('aging');
|
||||
});
|
||||
|
||||
it('reports no last backup for a snapshot-only workload', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [],
|
||||
archives: [],
|
||||
snapshots: [snapshot()],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
const row = model.coverageRows[0];
|
||||
expect(row.latestBackup).toBeUndefined();
|
||||
expect(row.latestSnapshot?.sourceKind).toBe('snapshot');
|
||||
expect(row.artifacts).toHaveLength(1);
|
||||
expect(model.coverageSummary.recoverableArtifacts).toBe(1);
|
||||
});
|
||||
|
||||
it('uses canonical workload attention while retaining the failed task as evidence', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
|
||||
+3
-4
@@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => {
|
||||
'status',
|
||||
'datastore',
|
||||
'used',
|
||||
'backups',
|
||||
]);
|
||||
expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' });
|
||||
expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' });
|
||||
expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' });
|
||||
expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' });
|
||||
expect(ids(getBackupServerColumns('basic'))).toEqual([
|
||||
'server',
|
||||
'status',
|
||||
@@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => {
|
||||
expect(ids(getRecoverableColumns('compact'))).toEqual([
|
||||
'workload',
|
||||
'source',
|
||||
'location',
|
||||
'created',
|
||||
'state',
|
||||
]);
|
||||
expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' });
|
||||
expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' });
|
||||
expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' });
|
||||
expect(ids(getRecoverableColumns('basic'))).toEqual([
|
||||
'workload',
|
||||
'source',
|
||||
|
||||
@@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra
|
||||
import {
|
||||
ArtifactSourceBadge,
|
||||
ArtifactStateBadge,
|
||||
ProxmoxBackupAgeText,
|
||||
ProxmoxBackupWorkloadTypeBadge,
|
||||
SortableHead,
|
||||
formatCompactBackupAge,
|
||||
} from '../proxmoxBackupsTableShared';
|
||||
import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel';
|
||||
|
||||
@@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => {
|
||||
const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString();
|
||||
expect(formatCompactBackupAge(createdAt)).toBe('18h');
|
||||
expect(formatCompactBackupAge(new Date().toISOString())).toBe('now');
|
||||
|
||||
const artifact = {
|
||||
id: 'pbs:1',
|
||||
nativeId: '1',
|
||||
sourceKind: 'pbs',
|
||||
sourceLabel: 'PBS',
|
||||
workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' },
|
||||
createdAt,
|
||||
createdMs: Date.parse(createdAt),
|
||||
location: 'main',
|
||||
detail: '',
|
||||
protected: false,
|
||||
} as RecoverableArtifact;
|
||||
render(() => <ProxmoxBackupAgeText artifact={artifact} compact />);
|
||||
const age = screen.getByText('18h');
|
||||
expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt);
|
||||
});
|
||||
|
||||
it('keeps backup age text on the shared relative-time primitive', () => {
|
||||
expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue');
|
||||
expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime(');
|
||||
|
||||
@@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => {
|
||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' });
|
||||
});
|
||||
|
||||
it('shows the full operational metric track at phone width', () => {
|
||||
it('matches the guest table columns and keeps node names whole at phone width', () => {
|
||||
const columns = getProxmoxHostVisibleColumnsForLayout('phone');
|
||||
const ids = columns.map((column) => column.id);
|
||||
|
||||
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']);
|
||||
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']);
|
||||
expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-0');
|
||||
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||
});
|
||||
|
||||
it('prioritizes live utilization columns in the mobile host table', () => {
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface RecoverableArtifact {
|
||||
/** Operator-facing source/repository label used by the backup-location filter. */
|
||||
locationLabel?: string;
|
||||
detail: string;
|
||||
/** Full provider identifier behind a shortened `detail`, shown on hover. */
|
||||
detailTitle?: string;
|
||||
protected: boolean;
|
||||
verified?: boolean;
|
||||
/** A still-running backup is writing this artifact; it is not recoverable yet. */
|
||||
@@ -70,7 +72,13 @@ export interface WorkloadCoverageRow {
|
||||
key: string;
|
||||
workload: WorkloadReference;
|
||||
artifacts: RecoverableArtifact[];
|
||||
latestRecovery?: RecoverableArtifact;
|
||||
/**
|
||||
* Newest completed PBS snapshot or PVE backup file. Guest snapshots never
|
||||
* fill this slot: they live on the guest's own storage and the protection
|
||||
* posture engine does not count them as independent recovery, so a fresh
|
||||
* snapshot must not read as a fresh backup beside a stale posture.
|
||||
*/
|
||||
latestBackup?: RecoverableArtifact;
|
||||
latestPBS?: RecoverableArtifact;
|
||||
latestArchive?: RecoverableArtifact;
|
||||
latestSnapshot?: RecoverableArtifact;
|
||||
@@ -138,12 +146,25 @@ export function getRecoveryAgeBand(
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
// A guest snapshot shares the guest's storage, so it is listed as restore
|
||||
// evidence but never counted as a backup. Mirrors the server posture rule that
|
||||
// "snapshots alone do not prove independent recovery".
|
||||
export function isBackupArtifact(artifact: RecoverableArtifact): boolean {
|
||||
return artifact.sourceKind !== 'snapshot';
|
||||
}
|
||||
|
||||
function parseTimestampMs(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
}
|
||||
|
||||
function archiveFileName(volid: string | undefined): string {
|
||||
const trimmed = volid?.trim() ?? '';
|
||||
if (!trimmed) return '';
|
||||
return trimmed.slice(trimmed.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
function pbsBackupFileDetailLabel(fileCount: number): string {
|
||||
if (fileCount <= 0) return 'PBS files not listed';
|
||||
if (fileCount === 1) return '1 PBS file';
|
||||
@@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
size: archive.size,
|
||||
location: archive.storage || archive.node || '—',
|
||||
...artifactLocationScope('archive', archive.instance, archive.storage || archive.node),
|
||||
detail: archive.volid || archive.format || archiveSource.detailFallbackLabel,
|
||||
// The volid repeats the storage, guest type, VMID, and timestamp the row
|
||||
// already shows in their own columns and clips at every width; the
|
||||
// archive format is the one fact it adds, so that is the cell and the
|
||||
// volid stays on hover and in search.
|
||||
detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel,
|
||||
detailTitle: archive.volid || undefined,
|
||||
protected: archive.protected,
|
||||
verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined,
|
||||
running: archive.inProgress === true,
|
||||
@@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
// the "latest" pointers (which answer "what can I recover to?") skip them.
|
||||
// The artifacts themselves stay listed with an honest state.
|
||||
const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed);
|
||||
row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs);
|
||||
row.latestPBS = newest(
|
||||
completed.filter((artifact) => artifact.sourceKind === 'pbs'),
|
||||
(artifact) => artifact.createdMs,
|
||||
@@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||
(artifact) => artifact.createdMs,
|
||||
);
|
||||
row.latestRecovery = newest(completed, (artifact) => artifact.createdMs);
|
||||
}
|
||||
|
||||
const coverageRows = Array.from(rows.values()).map((row) => {
|
||||
@@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
|
||||
coverageRows.sort((left, right) => {
|
||||
if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank;
|
||||
return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0);
|
||||
return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0);
|
||||
});
|
||||
artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0));
|
||||
|
||||
@@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch(
|
||||
artifact.sourceTitle,
|
||||
artifact.location,
|
||||
artifact.detail,
|
||||
artifact.detailTitle,
|
||||
artifact.verified === true
|
||||
? 'verified'
|
||||
: artifact.verified === false
|
||||
|
||||
@@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record<
|
||||
> = {
|
||||
// Reachability and datastore exhaustion are the two risks that can stop all
|
||||
// future backups. Host telemetry and space-efficiency context return only as
|
||||
// the table gains enough room to keep those headline answers readable.
|
||||
compact: ['server', 'status', 'datastore', 'used', 'backups'],
|
||||
// the table gains enough room to keep those headline answers readable. The
|
||||
// phone projection keeps those two answers whole ("Healthy", "33.6%") and
|
||||
// lets the backup count return with the first wider layout.
|
||||
compact: ['server', 'status', 'datastore', 'used'],
|
||||
basic: ['server', 'status', 'datastore', 'used', 'backups'],
|
||||
operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'],
|
||||
expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'],
|
||||
@@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record<
|
||||
ProxmoxBackupsTableLayoutMode,
|
||||
Partial<Record<BackupServerColumnId, number>>
|
||||
> = {
|
||||
compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 },
|
||||
basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 },
|
||||
compact: { server: 40, status: 23, datastore: 22, used: 15 },
|
||||
// Just above the phone projection the used cell carries its used/total
|
||||
// pair again, the longest value in the row, so it takes the most room.
|
||||
basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 },
|
||||
operational: {
|
||||
server: 20,
|
||||
status: 12,
|
||||
@@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
|
||||
{ id: 'targetId', label: 'Target ID', kind: 'text' },
|
||||
{ id: 'node', label: 'Node', kind: 'text' },
|
||||
{ id: 'posture', label: 'Posture', kind: 'text' },
|
||||
{ id: 'latest', label: 'Restore', kind: 'numeric-value' },
|
||||
{ id: 'latest', label: 'Last backup', kind: 'numeric-value' },
|
||||
{ id: 'pbs', label: 'PBS snapshot', kind: 'text' },
|
||||
{ id: 'archive', label: 'PVE file', kind: 'text' },
|
||||
{ id: 'snapshot', label: 'Guest snapshot', kind: 'text' },
|
||||
@@ -184,7 +188,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
|
||||
|
||||
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
|
||||
// On narrow surfaces answer: which workload, what posture, how recent is the
|
||||
// newest restore point, and did the latest task succeed? Provider-by-provider
|
||||
// newest independent backup, and did the latest task succeed? Provider-by-provider
|
||||
// evidence is progressive detail in the expansion row; target identity folds
|
||||
// beneath the name so the scan stays legible at 320px.
|
||||
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
|
||||
@@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record<
|
||||
ProxmoxBackupsTableLayoutMode,
|
||||
Partial<Record<CoverageColumnId, number>>
|
||||
> = {
|
||||
compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 },
|
||||
// Posture keeps its whole compact word ("Unknown"), the two age cells hold
|
||||
// the suffix-free compact age, and the job dot takes what a "Job" header
|
||||
// needs.
|
||||
compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 },
|
||||
// Above the phone projection the identity columns (workload, node) take the
|
||||
// slack that the short type badge, target id, and age cells cannot use, so
|
||||
// names stay whole instead of truncating beside empty space.
|
||||
basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 },
|
||||
basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 },
|
||||
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
|
||||
expanded: {
|
||||
workload: 19,
|
||||
@@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly Recove
|
||||
// A recovery-point feed starts with identity, source, location, age, and
|
||||
// verification. Size and verbose details progressively return with usable
|
||||
// room; the full values remain available through their title affordance.
|
||||
compact: ['workload', 'source', 'location', 'created', 'state'],
|
||||
// The phone projection keeps the source and state badges and the age whole;
|
||||
// the repository location returns with the first wider layout and stays in
|
||||
// the row's hover title meanwhile.
|
||||
compact: ['workload', 'source', 'created', 'state'],
|
||||
basic: ['workload', 'source', 'location', 'created', 'state'],
|
||||
operational: ['workload', 'type', 'source', 'location', 'created', 'size', 'state'],
|
||||
expanded: ['workload', 'type', 'targetId', 'source', 'location', 'created', 'size', 'state'],
|
||||
@@ -301,7 +311,7 @@ const RECOVERABLE_WEIGHTS: Record<
|
||||
// Reserve enough phone-width space for complete state badges (including
|
||||
// Failed and Running) rather than clipping the recovery answer at the
|
||||
// scroll edge. Identity remains on the canonical 40% anchor.
|
||||
compact: { workload: 40, source: 12, location: 17, created: 15, state: 16 },
|
||||
compact: { workload: 40, source: 16, created: 14, state: 30 },
|
||||
basic: { workload: 28, source: 14, location: 23, created: 18, state: 17 },
|
||||
operational: {
|
||||
workload: 24,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Accessor } from 'solid-js';
|
||||
import { Show, type Accessor } from 'solid-js';
|
||||
import { filterChipStatusDot } from '@/components/shared/FilterBar';
|
||||
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
@@ -6,7 +6,10 @@ import { ProgressBar } from '@/components/shared/ProgressBar';
|
||||
import { TableHead } from '@/components/shared/Table';
|
||||
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
|
||||
import { WorkloadTypeBadge as SharedWorkloadTypeBadge } from '@/components/shared/WorkloadTypeBadge';
|
||||
import { PlatformTableRelativeTimeValue } from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
PlatformTableRelativeTimeValue,
|
||||
formatPlatformTableRelativeTimeValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
|
||||
import {
|
||||
getRecoveryAgeBand,
|
||||
@@ -219,7 +222,15 @@ export function RowMetricBar(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
|
||||
// Phone columns are too narrow for "18h ago", so the compact form drops the
|
||||
// suffix ("18h", "now") and keeps the full age band and timestamp on hover.
|
||||
export function formatCompactBackupAge(createdAt: string): string {
|
||||
const relative = formatPlatformTableRelativeTimeValue(createdAt);
|
||||
if (relative === 'just now') return 'now';
|
||||
return relative.replace(/ ago$/, '');
|
||||
}
|
||||
|
||||
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact; compact?: boolean }) {
|
||||
const band = () => getRecoveryAgeBand(props.artifact.createdMs);
|
||||
const title = () => {
|
||||
const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean);
|
||||
@@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
|
||||
|
||||
return (
|
||||
<span class={`font-semibold tabular-nums ${recoveryAgeClassByBand[band()]}`} title={title()}>
|
||||
<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />
|
||||
<Show
|
||||
when={props.compact}
|
||||
fallback={<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />}
|
||||
>
|
||||
{formatCompactBackupAge(props.artifact.createdAt)}
|
||||
</Show>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
||||
cpu: 'narrow',
|
||||
memory: 'narrow',
|
||||
disk: 'narrow',
|
||||
temp: 'phone',
|
||||
temp: 'mobile',
|
||||
uptime: 'narrow',
|
||||
cluster: 'tablet',
|
||||
vms: 'compact',
|
||||
@@ -46,8 +46,8 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
||||
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
|
||||
// pill) take only what they need.
|
||||
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
||||
node: 18,
|
||||
version: 7,
|
||||
node: 20,
|
||||
version: 6,
|
||||
uptime: 7,
|
||||
cpu: 13,
|
||||
memory: 13,
|
||||
@@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
||||
temp: 6,
|
||||
vms: 5,
|
||||
cts: 5,
|
||||
cluster: 13,
|
||||
cluster: 12,
|
||||
};
|
||||
|
||||
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
@@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
disk: 15,
|
||||
uptime: 15,
|
||||
},
|
||||
// Phones carry the same five columns as the guest table below them. A sixth
|
||||
// (temperature) left the node name with four characters, which made a
|
||||
// cluster of "Analytics A".."F" rows unreadable.
|
||||
phone: {
|
||||
node: 32,
|
||||
cpu: 13,
|
||||
memory: 13,
|
||||
disk: 13,
|
||||
temp: 15,
|
||||
uptime: 14,
|
||||
cpu: 15,
|
||||
memory: 15,
|
||||
disk: 15,
|
||||
uptime: 15,
|
||||
},
|
||||
mobile: {
|
||||
node: 32,
|
||||
@@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = (
|
||||
);
|
||||
};
|
||||
|
||||
// The node cell carries the expansion toggle and status dot before the name,
|
||||
// so it needs more than the shared 30% phone identity anchor to show a
|
||||
// typical node name whole.
|
||||
const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
|
||||
|
||||
export const getProxmoxHostColumnWidthStyle = (
|
||||
columnId: ProxmoxHostTableColumnId,
|
||||
layoutMode: WorkloadTableLayoutMode,
|
||||
@@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = (
|
||||
visibleColumnIds,
|
||||
layoutMode === 'narrow'
|
||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'phone' || layoutMode === 'mobile'
|
||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
: layoutMode === 'phone'
|
||||
? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'mobile'
|
||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DISK_DETAIL_LIVE_CHARTS,
|
||||
getDiskAttributeValueTextClass,
|
||||
getDiskDetailAttributeCards,
|
||||
getDiskDetailHealthPresentation,
|
||||
getDiskDetailHistoryCharts,
|
||||
getDiskDetailHistoryFallbackMessage,
|
||||
getDiskDetailLiveBadgeLabel,
|
||||
@@ -34,6 +35,41 @@ describe('diskDetailPresentation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the row verdict and its reason into the drawer header', () => {
|
||||
const disk = {
|
||||
node: 'tower',
|
||||
instance: 'cluster-main',
|
||||
devPath: '/dev/sda',
|
||||
model: 'Archive HDD',
|
||||
serial: 'SERIAL-1',
|
||||
wwn: '',
|
||||
size: 1,
|
||||
health: 'PASSED',
|
||||
wearout: -1,
|
||||
type: 'hdd',
|
||||
temperature: 42,
|
||||
rpm: 7200,
|
||||
used: '',
|
||||
riskReasons: [] as string[],
|
||||
};
|
||||
expect(getDiskDetailHealthPresentation(disk)).toEqual({
|
||||
label: 'Healthy',
|
||||
summary: '',
|
||||
tone: 'text-emerald-700 dark:text-emerald-300',
|
||||
});
|
||||
expect(
|
||||
getDiskDetailHealthPresentation({
|
||||
...disk,
|
||||
health: 'FAILED',
|
||||
riskReasons: ['SMART failed.'],
|
||||
}),
|
||||
).toEqual({
|
||||
label: 'Replace Now',
|
||||
summary: 'SMART failed.',
|
||||
tone: 'text-red-700 dark:text-red-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds canonical SATA and NVMe attribute cards', () => {
|
||||
expect(
|
||||
getDiskDetailAttributeCards({
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
PHYSICAL_DISK_TABLE_CLASS,
|
||||
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
||||
getPhysicalDiskEmptyStatePresentation,
|
||||
getPhysicalDiskColumnWidthPercent,
|
||||
getPhysicalDiskCellPaddingClass,
|
||||
getPhysicalDiskColumnWidthStyle,
|
||||
getPhysicalDiskCollectionMessages,
|
||||
getPhysicalDiskFieldStatusMessage,
|
||||
getPhysicalDiskHealthCompactLabel,
|
||||
getPhysicalDiskHealthStatus,
|
||||
getPhysicalDiskHealthSummary,
|
||||
getPhysicalDiskHostLabel,
|
||||
@@ -158,14 +160,55 @@ describe('diskPresentation', () => {
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'disk')).toBe(33);
|
||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'size')).toBe(14);
|
||||
expect(getPhysicalDiskColumnWidthPercent('operational', 'life')).toBe(9);
|
||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'device')).toBe(0);
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'disk')).toEqual({ width: '34%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'temp')).toEqual({ width: '12%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'size')).toEqual({ width: '15%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('operational', 'life')).toEqual({ width: '9%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'device')).toEqual({ width: '0%' });
|
||||
expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false);
|
||||
expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true);
|
||||
expect(getPhysicalDiskColumnWidthPercent('narrow', 'disk')).toBe(40);
|
||||
expect(getPhysicalDiskColumnWidthPercent('narrow', 'size')).toBe(15);
|
||||
expect(getPhysicalDiskColumnWidthStyle('narrow', 'disk')).toEqual({ width: '41%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('narrow', 'temp')).toEqual({ width: '12%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('narrow', 'size')).toEqual({ width: '16%' });
|
||||
});
|
||||
|
||||
it('keeps the desktop disk column widths on the canonical weighted helper', () => {
|
||||
const columns = [
|
||||
'disk',
|
||||
'device',
|
||||
'host',
|
||||
'role',
|
||||
'parent',
|
||||
'health',
|
||||
'life',
|
||||
'temp',
|
||||
'size',
|
||||
] as const;
|
||||
expect(columns.map((column) => getPhysicalDiskColumnWidthStyle('full', column).width)).toEqual([
|
||||
'19%',
|
||||
'9%',
|
||||
'10%',
|
||||
'8%',
|
||||
'12%',
|
||||
'17%',
|
||||
'6%',
|
||||
'7%',
|
||||
'12%',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sheds the shared cell padding only on the phone disk layouts', () => {
|
||||
expect(getPhysicalDiskCellPaddingClass('narrow')).toBe('px-1!');
|
||||
expect(getPhysicalDiskCellPaddingClass('compact')).toBe('px-1!');
|
||||
expect(getPhysicalDiskCellPaddingClass('basic')).toBe('');
|
||||
expect(getPhysicalDiskCellPaddingClass('full')).toBe('');
|
||||
});
|
||||
|
||||
it('shortens only the health words that cannot fit a phone health column', () => {
|
||||
expect(getPhysicalDiskHealthCompactLabel('Needs Attention')).toBe('Attention');
|
||||
expect(getPhysicalDiskHealthCompactLabel('Replace Now')).toBe('Replace');
|
||||
expect(getPhysicalDiskHealthCompactLabel('Healthy')).toBe('Healthy');
|
||||
expect(getPhysicalDiskHealthCompactLabel('Unknown')).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('detects SMART warnings from counters', () => {
|
||||
|
||||
+8
@@ -62,6 +62,14 @@ describe('resourceStoragePresentation', () => {
|
||||
),
|
||||
).toBe('Datastore');
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage');
|
||||
// Proxmox plugin names are acronyms or compound words; title-casing them
|
||||
// produced "Lvmthin" for every default local-lvm storage and "Nfs".
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'lvmthin')).toBe('LVM-Thin');
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'lvm')).toBe('LVM');
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'nfs')).toBe('NFS');
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'cifs')).toBe('SMB/CIFS');
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'iscsi')).toBe('iSCSI');
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'btrfs')).toBe('BTRFS');
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe(
|
||||
'Rebuild Target',
|
||||
);
|
||||
|
||||
+2
-2
@@ -219,12 +219,12 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
||||
});
|
||||
|
||||
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
|
||||
it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { zfsPool: { state: 'FAULTED', devices: [] } },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => {
|
||||
|
||||
+2
-2
@@ -239,7 +239,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
||||
});
|
||||
|
||||
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
|
||||
it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
@@ -249,7 +249,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('titleizes the derived status when arrayState and pool are both absent', () => {
|
||||
|
||||
@@ -101,6 +101,47 @@ describe('storage row presentation', () => {
|
||||
expect(getCompactStoragePoolProtectionTitle(record)).not.toContain('without parity');
|
||||
});
|
||||
|
||||
it('shows a zfs rebuild as its activity word and keeps the sentence as the title', () => {
|
||||
const resilvering = {
|
||||
...baseRecord(),
|
||||
rebuildInProgress: true,
|
||||
protectionLabel: 'ZFS pool tank is resilvering',
|
||||
details: {
|
||||
zfsPool: { state: 'DEGRADED', scan: 'resilver in progress', devices: [] },
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolProtectionLabel(resilvering)).toBe('Resilvering');
|
||||
expect(getCompactStoragePoolProtectionTitle(resilvering)).toBe('ZFS pool tank is resilvering');
|
||||
|
||||
const scrubbing = {
|
||||
...baseRecord(),
|
||||
rebuildInProgress: true,
|
||||
protectionLabel: 'ZFS pool tank scrub is running (45.2%)',
|
||||
protectionSummary: 'ZFS pool tank scrub is running (45.2%)',
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'ONLINE',
|
||||
scan: '',
|
||||
scanDetails: { function: 'SCRUB', state: 'SCANNING', percentage: 45.2 },
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolProtectionLabel(scrubbing)).toBe('Scrubbing 45%');
|
||||
expect(getCompactStoragePoolProtectionTitle(scrubbing)).toBe(
|
||||
'ZFS pool tank scrub is running (45.2%)',
|
||||
);
|
||||
|
||||
// A rebuild without zfs scan data keeps the label its platform supplied.
|
||||
const parity = {
|
||||
...baseRecord(),
|
||||
rebuildInProgress: true,
|
||||
protectionLabel: 'Parity sync 12%',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolProtectionLabel(parity)).toBe('Parity sync 12%');
|
||||
expect(getCompactStoragePoolProtectionTitle(parity)).toBe('Parity sync 12%');
|
||||
});
|
||||
|
||||
it('derives zfs issue fallback from pool state and errors', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
|
||||
@@ -467,6 +467,37 @@ describe('storageAdapters', () => {
|
||||
expect(records[0].hostLabel).toBe('pve1');
|
||||
});
|
||||
|
||||
it('labels a poller-merged shared storage as shared with its node count', () => {
|
||||
const records = buildStorageRecords({
|
||||
state: baseState(),
|
||||
resources: [
|
||||
makeResourceStorage({
|
||||
id: 'cluster-a-cluster-nfs-iso',
|
||||
name: 'nfs-iso',
|
||||
parentName: 'cluster',
|
||||
platformData: { type: 'nfs', node: 'cluster', instance: 'cluster-a', shared: true },
|
||||
storage: {
|
||||
type: 'nfs',
|
||||
shared: true,
|
||||
nodes: ['pve1', 'pve2', 'pve3'],
|
||||
} as Resource['storage'],
|
||||
}),
|
||||
makeResourceStorage({
|
||||
id: 'pve2-local-lvm',
|
||||
name: 'local-lvm',
|
||||
parentName: 'pve2',
|
||||
platformData: { type: 'lvmthin', node: 'pve2', instance: 'cluster-a', shared: false },
|
||||
storage: { type: 'lvmthin', shared: false } as Resource['storage'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// The poller folds a shared storage into one entry on the synthetic
|
||||
// "cluster" node; a node-local storage keeps its node name.
|
||||
expect(records.map((record) => record.hostLabel)).toEqual(['Shared · 3 nodes', 'pve2']);
|
||||
expect(records.map((record) => record.topologyLabel)).toEqual(['NFS', 'LVM-Thin']);
|
||||
});
|
||||
|
||||
it('keeps dependency impact separate from healthy storage primary issues', () => {
|
||||
const records = buildStorageRecords({
|
||||
state: baseState(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
STORAGE_BAR_ZFS_SECTION_CLASS,
|
||||
STORAGE_BAR_ZFS_STATE_LABEL_CLASS,
|
||||
STORAGE_BAR_ZFS_STATE_ROW_CLASS,
|
||||
getStorageBarCompactLabel,
|
||||
getStorageBarLabel,
|
||||
getStorageBarTooltipRowClass,
|
||||
getStorageBarTooltipRows,
|
||||
@@ -39,6 +40,7 @@ describe('storageBarPresentation', () => {
|
||||
expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400');
|
||||
expect(getStorageBarUsagePercent(40, 100)).toBe(40);
|
||||
expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)');
|
||||
expect(getStorageBarCompactLabel(40, 100)).toBe('40%');
|
||||
expect(getStorageBarTooltipTitle()).toBe('Storage Details');
|
||||
expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 ');
|
||||
expect(getStorageBarTooltipRowClass(true)).toBe(
|
||||
|
||||
+32
-9
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStoragePoolColumnWidthPercent,
|
||||
getStoragePoolCellPaddingClass,
|
||||
getStoragePoolColumnWidthStyle,
|
||||
getStoragePoolTableColumns,
|
||||
getStoragePoolTableLayoutModeForContainer,
|
||||
getStorageTableHeading,
|
||||
@@ -46,22 +47,44 @@ describe('storagePagePresentation', () => {
|
||||
expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(559)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(560)).toBe('operational');
|
||||
// The compact/operational boundary is the shared phone container query
|
||||
// (34rem), so the usage bar label and the column set change together.
|
||||
expect(getStoragePoolTableLayoutModeForContainer(543)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(544)).toBe('operational');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(1_039)).toBe('operational');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full');
|
||||
expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true);
|
||||
expect(isStoragePoolColumnVisible('operational', 'growth')).toBe(false);
|
||||
expect(getStoragePoolColumnWidthPercent('operational', 'usage')).toBe(21);
|
||||
expect(getStoragePoolColumnWidthStyle('operational', 'usage')).toEqual({ width: '25%' });
|
||||
expect(getStoragePoolColumnWidthStyle('operational', 'name')).toEqual({ width: '27%' });
|
||||
expect(isStoragePoolColumnVisible('compact', 'host')).toBe(true);
|
||||
expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false);
|
||||
expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false);
|
||||
expect(getStoragePoolColumnWidthPercent('compact', 'name')).toBe(35);
|
||||
expect(getStoragePoolColumnWidthPercent('compact', 'usage')).toBe(25);
|
||||
expect(getStoragePoolColumnWidthPercent('compact', 'growth')).toBe(0);
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'name')).toEqual({ width: '37%' });
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'host')).toEqual({ width: '31.5%' });
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'usage')).toEqual({ width: '13%' });
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'growth')).toEqual({ width: '0%' });
|
||||
expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false);
|
||||
expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false);
|
||||
expect(getStoragePoolColumnWidthPercent('narrow', 'name')).toBe(40);
|
||||
expect(getStoragePoolColumnWidthPercent('narrow', 'usage')).toBe(20);
|
||||
expect(getStoragePoolColumnWidthStyle('narrow', 'name')).toEqual({ width: '35%' });
|
||||
expect(getStoragePoolColumnWidthStyle('narrow', 'usage')).toEqual({ width: '13%' });
|
||||
});
|
||||
|
||||
it('sheds the desktop cell gutter only on the phone pool layouts', () => {
|
||||
expect(getStoragePoolCellPaddingClass('narrow')).toBe('px-1!');
|
||||
expect(getStoragePoolCellPaddingClass('compact')).toBe('px-1!');
|
||||
expect(getStoragePoolCellPaddingClass('operational')).toBe('');
|
||||
expect(getStoragePoolCellPaddingClass('full')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the desktop pool column widths on the canonical weighted helper', () => {
|
||||
const fullWidths = ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'].map(
|
||||
(column) =>
|
||||
getStoragePoolColumnWidthStyle(
|
||||
'full',
|
||||
column as 'name' | 'state' | 'type' | 'host' | 'protection' | 'usage' | 'growth',
|
||||
).width,
|
||||
);
|
||||
expect(fullWidths).toEqual(['20%', '14%', '10%', '12%', '13%', '20%', '11%']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
getPhysicalDiskHealthStatus,
|
||||
getPhysicalDiskHealthSummary,
|
||||
type PhysicalDiskPresentationData,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { formatPowerOnHours } from '@/utils/format';
|
||||
import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
@@ -78,6 +82,32 @@ export const getDiskDetailLiveBadgeLabel = (): string => 'Real-time';
|
||||
export const getDiskDetailHistoryFallbackMessage = (): string =>
|
||||
'Historical disk charts are unavailable until Pulse can resolve a stable identity for this disk.';
|
||||
|
||||
export type DiskDetailHealthPresentation = {
|
||||
label: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
};
|
||||
|
||||
// The drawer header carries the disk's verdict and its reason. The table row
|
||||
// shows only the verdict word, so this is where the reason is read in full.
|
||||
export const DISK_DETAIL_HEADER_STACK_CLASS = 'min-w-0 space-y-1';
|
||||
export const DISK_DETAIL_HEALTH_ROW_CLASS =
|
||||
'flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[11px]';
|
||||
export const DISK_DETAIL_HEALTH_LABEL_CLASS = 'font-semibold';
|
||||
export const DISK_DETAIL_HEALTH_SUMMARY_CLASS =
|
||||
'min-w-0 max-w-full whitespace-normal wrap-break-word text-muted';
|
||||
|
||||
export function getDiskDetailHealthPresentation(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
): DiskDetailHealthPresentation {
|
||||
const status = getPhysicalDiskHealthStatus(disk);
|
||||
return {
|
||||
label: status.label,
|
||||
summary: getPhysicalDiskHealthSummary(status),
|
||||
tone: status.tone,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDiskDetailAttributeCards(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
diskTempThresholds?: MetricDisplayThresholds | null,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import type {
|
||||
PhysicalDiskCollectionStatus,
|
||||
PhysicalDiskFieldStatus,
|
||||
Resource,
|
||||
} from '@/types/resource';
|
||||
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
getSourcePlatformLabel,
|
||||
getSourcePlatformPresentation,
|
||||
@@ -141,12 +143,16 @@ const PHYSICAL_DISK_VISIBLE_COLUMNS: Record<
|
||||
full: ['disk', 'device', 'host', 'role', 'parent', 'health', 'life', 'temp', 'size'],
|
||||
};
|
||||
|
||||
const PHYSICAL_DISK_COLUMN_WIDTHS: Record<
|
||||
// Relative weights per layout, resolved through the canonical weighted-width
|
||||
// helper. The phone layouts are sized from the measured values they show:
|
||||
// a temperature ("100°C"), a capacity ("5.46 TB"), a compact health word and
|
||||
// a short node name must each fit whole, and the disk model takes the rest.
|
||||
const PHYSICAL_DISK_COLUMN_WEIGHTS: Record<
|
||||
PhysicalDiskTableLayoutMode,
|
||||
Partial<Record<PhysicalDiskTableColumnId, number>>
|
||||
> = {
|
||||
narrow: { disk: 40, host: 15, health: 20, temp: 10, size: 15 },
|
||||
compact: { disk: 33, host: 15, health: 18, life: 10, temp: 10, size: 14 },
|
||||
narrow: { disk: 41, host: 12, health: 19, temp: 12, size: 16 },
|
||||
compact: { disk: 34, host: 11, health: 17, life: 11, temp: 12, size: 15 },
|
||||
basic: { disk: 33, host: 17, health: 22, temp: 12, size: 16 },
|
||||
operational: { disk: 25, host: 12, parent: 15, health: 17, life: 9, temp: 9, size: 13 },
|
||||
expanded: { disk: 22, host: 10, role: 9, parent: 14, health: 15, life: 8, temp: 8, size: 14 },
|
||||
@@ -168,10 +174,31 @@ export const isPhysicalDiskColumnVisible = (
|
||||
columnId: PhysicalDiskTableColumnId,
|
||||
): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId);
|
||||
|
||||
export const getPhysicalDiskColumnWidthPercent = (
|
||||
export const getPhysicalDiskColumnWidthStyle = (
|
||||
layout: PhysicalDiskTableLayoutMode,
|
||||
columnId: PhysicalDiskTableColumnId,
|
||||
): number => PHYSICAL_DISK_COLUMN_WIDTHS[layout][columnId] ?? 0;
|
||||
): JSX.CSSProperties =>
|
||||
getPlatformTableWeightedColumnWidthStyle(
|
||||
columnId,
|
||||
PHYSICAL_DISK_COLUMN_WEIGHTS[layout],
|
||||
PHYSICAL_DISK_VISIBLE_COLUMNS[layout],
|
||||
);
|
||||
|
||||
// The shared table cell primitives pad every cell for desktop density. On the
|
||||
// phone layouts that padding alone is a fifth of the row, so rendered cells
|
||||
// and headers shed it the same way the Proxmox replication table does.
|
||||
export const PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS = 'px-1!';
|
||||
|
||||
export const getPhysicalDiskCellPaddingClass = (layout: PhysicalDiskTableLayoutMode): string =>
|
||||
layout === 'narrow' || layout === 'compact' ? PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS : '';
|
||||
|
||||
// Health words that do not fit a phone-width health column fall back to a
|
||||
// shorter form; the full label stays in the wider projections and the detail.
|
||||
export const getPhysicalDiskHealthCompactLabel = (label: string): string => {
|
||||
if (label === 'Needs Attention') return 'Attention';
|
||||
if (label === 'Replace Now') return 'Replace';
|
||||
return label;
|
||||
};
|
||||
|
||||
export const PHYSICAL_DISK_COL_DISK_CLASS = '';
|
||||
export const PHYSICAL_DISK_COL_DEVICE_CLASS = '';
|
||||
@@ -221,8 +248,10 @@ export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted';
|
||||
export const PHYSICAL_DISK_LIFE_CLASS = 'text-[11px] font-medium';
|
||||
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
|
||||
'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
|
||||
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'min-w-0 truncate text-[11px] font-semibold';
|
||||
export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted';
|
||||
// The health word is the row's verdict and always renders whole. Its reason
|
||||
// lives in the cell title and in the disk drawer, not in a truncating sibling
|
||||
// that would compete with it for the track.
|
||||
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold';
|
||||
export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium';
|
||||
export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content';
|
||||
|
||||
|
||||
@@ -244,6 +244,30 @@ export const getResourceStorageTopologyLabel = (
|
||||
case 'rbd':
|
||||
case 'cephfs':
|
||||
return 'Cluster Storage';
|
||||
// Proxmox storage plugin names are acronyms or compound words that
|
||||
// title-casing mangles ("Lvmthin" for the default local-lvm, "Nfs"); use
|
||||
// the names the Proxmox UI shows for them.
|
||||
case 'nfs':
|
||||
return 'NFS';
|
||||
case 'cifs':
|
||||
case 'smb':
|
||||
return 'SMB/CIFS';
|
||||
case 'lvm':
|
||||
return 'LVM';
|
||||
case 'lvmthin':
|
||||
case 'lvm-thin':
|
||||
return 'LVM-Thin';
|
||||
case 'iscsi':
|
||||
case 'iscsidirect':
|
||||
return 'iSCSI';
|
||||
case 'zfs':
|
||||
return 'ZFS';
|
||||
case 'btrfs':
|
||||
return 'BTRFS';
|
||||
case 'glusterfs':
|
||||
return 'GlusterFS';
|
||||
case 'esxi':
|
||||
return 'ESXi';
|
||||
default:
|
||||
return titleize(storageType) || titleize(resource.type) || 'Storage';
|
||||
}
|
||||
|
||||
@@ -66,9 +66,32 @@ export function getStoragePoolStateTextClass(record: StorageRecord): string {
|
||||
return 'text-base-content';
|
||||
}
|
||||
|
||||
// The rebuild summary the backend promotes into the protection label is a
|
||||
// sentence ("ZFS pool tank is resilvering (45.2%)"). The protection column is
|
||||
// a badge, so a ZFS scan reads as the activity word, derived from the pool's
|
||||
// own scan state (the same source the backend keys its risk reason on); the
|
||||
// sentence stays available as the cell title. Non-ZFS rebuilds keep the label
|
||||
// their platform supplies.
|
||||
export function getStoragePoolRebuildLabel(record: StorageRecord): string {
|
||||
const pool = getStorageRecordZfsPool(record);
|
||||
if (!pool) return '';
|
||||
const scan = `${pool.scanDetails?.function || ''} ${pool.scan || ''}`.toLowerCase();
|
||||
const percentage = pool.scanDetails?.percentage;
|
||||
const progress =
|
||||
typeof percentage === 'number' && Number.isFinite(percentage) && percentage > 0
|
||||
? ` ${Math.round(percentage)}%`
|
||||
: '';
|
||||
if (scan.includes('resilver')) return `Resilvering${progress}`;
|
||||
if (scan.includes('scrub')) return `Scrubbing${progress}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string {
|
||||
const label = (record.protectionLabel || '').trim();
|
||||
if (record.rebuildInProgress || record.protectionReduced) {
|
||||
if (record.rebuildInProgress) {
|
||||
return getStoragePoolRebuildLabel(record) || label || '—';
|
||||
}
|
||||
if (record.protectionReduced) {
|
||||
return label || '—';
|
||||
}
|
||||
if (label && label.toLowerCase() !== 'healthy') {
|
||||
@@ -87,7 +110,9 @@ export function getStoragePoolStateLabel(record: StorageRecord): string {
|
||||
}
|
||||
const pool = getStorageRecordZfsPool(record);
|
||||
if (pool?.state) {
|
||||
return pool.state === 'ONLINE' ? 'Online' : pool.state;
|
||||
// zpool reports upper-case states; present them like every other state
|
||||
// path so a DEGRADED pool reads the same as a degraded array.
|
||||
return titleize(pool.state);
|
||||
}
|
||||
const status = getStorageRecordStatus(record);
|
||||
return status ? titleize(status) : '—';
|
||||
@@ -122,7 +147,9 @@ export function getCompactStoragePoolProtectionTitle(record: StorageRecord): str
|
||||
return issueSummary;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
// When the badge shows a derived word, the platform's full label is the title.
|
||||
const fullLabel = (record.protectionLabel || '').trim();
|
||||
return fullLabel && fullLabel.toLowerCase() !== label.toLowerCase() ? fullLabel : label;
|
||||
}
|
||||
|
||||
export function getCompactStoragePoolImpactLabel(record: StorageRecord): string {
|
||||
|
||||
@@ -129,7 +129,16 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag
|
||||
const usedBytes = asNumberOrNull(resource.disk?.used);
|
||||
const freeBytes = asNumberOrNull(resource.disk?.free);
|
||||
const canonicalPlatform = getCanonicalStoragePlatformKey(resource, resource.storage?.platform);
|
||||
const hostLabel = locationLabel;
|
||||
// The poller folds a shared storage into one entry whose node is the
|
||||
// synthetic "cluster" marker (the mock uses "shared"); show that as a
|
||||
// shared storage with its reporting node count rather than a node name.
|
||||
const sharedLocationMarkers = new Set(['cluster', 'shared']);
|
||||
const hostLabel =
|
||||
shared && sharedLocationMarkers.has(locationLabel.trim().toLowerCase())
|
||||
? storageNodes.length > 1
|
||||
? `Shared · ${storageNodes.length} nodes`
|
||||
: 'Shared'
|
||||
: locationLabel;
|
||||
const platformLabel = getResourceStoragePlatformLabel(canonicalPlatform);
|
||||
const issueLabel = getResourceStorageIssueLabel(resource);
|
||||
const issueSummary = getResourceStorageIssueSummary(resource);
|
||||
|
||||
@@ -38,8 +38,13 @@ export const getStorageBarUsagePercent = (used: number, total: number): number =
|
||||
return (used / total) * 100;
|
||||
};
|
||||
|
||||
// Phone-width tables only have room for the percentage inside the bar; the
|
||||
// used/total sublabel stays in the tooltip and in the wider projections.
|
||||
export const getStorageBarCompactLabel = (used: number, total: number): string =>
|
||||
formatPercent(getStorageBarUsagePercent(used, total));
|
||||
|
||||
export const getStorageBarLabel = (used: number, total: number): string =>
|
||||
`${formatPercent(getStorageBarUsagePercent(used, total))} (${formatBytes(used)}/${formatBytes(total)})`;
|
||||
`${getStorageBarCompactLabel(used, total)} (${formatBytes(used)}/${formatBytes(total)})`;
|
||||
|
||||
export const getStorageBarTooltipTitle = (): string => 'Storage Details';
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import type { StorageSortKey } from './storageModelCore';
|
||||
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
|
||||
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
|
||||
|
||||
export type StorageViewOption = {
|
||||
value: 'pools' | 'disks';
|
||||
@@ -21,11 +23,14 @@ export type StoragePoolTableColumn = {
|
||||
|
||||
export type StoragePoolTableLayoutMode = 'narrow' | 'compact' | 'operational' | 'full';
|
||||
|
||||
// The compact/operational boundary matches the shared phone projection of the
|
||||
// table container (`@container (max-width: 33.999rem)` = 544px), so the
|
||||
// columns and the in-cell labels always change projection together.
|
||||
export const getStoragePoolTableLayoutModeForContainer = (
|
||||
containerWidth: number,
|
||||
): StoragePoolTableLayoutMode => {
|
||||
if (containerWidth >= 1_040) return 'full';
|
||||
if (containerWidth >= 560) return 'operational';
|
||||
if (containerWidth >= 544) return 'operational';
|
||||
if (containerWidth > 0 && containerWidth < 360) return 'narrow';
|
||||
return 'compact';
|
||||
};
|
||||
@@ -43,13 +48,20 @@ const STORAGE_POOL_VISIBLE_COLUMNS: Record<
|
||||
full: ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'],
|
||||
};
|
||||
|
||||
const STORAGE_POOL_COLUMN_WIDTHS: Record<
|
||||
// Relative weights per layout, resolved through the canonical weighted-width
|
||||
// helper. On phones the usage bar carries only its percentage, so most of the
|
||||
// row goes to the two identity strings (pool name and host); a state word such
|
||||
// as "Degraded" must still fit whole. The shared 30% identity anchor is not
|
||||
// used here because a four-column row cannot keep the host readable with it.
|
||||
const STORAGE_POOL_COLUMN_WEIGHTS: Record<
|
||||
StoragePoolTableLayoutMode,
|
||||
Partial<Record<StoragePoolTableColumnId, number>>
|
||||
> = {
|
||||
narrow: { name: 40, state: 20, host: 20, usage: 20 },
|
||||
compact: { name: 35, state: 20, host: 20, usage: 25 },
|
||||
operational: { name: 29, state: 20, host: 15, protection: 15, usage: 21 },
|
||||
narrow: { name: 35, state: 21, host: 31, usage: 13 },
|
||||
compact: { name: 37, state: 18.5, host: 31.5, usage: 13 },
|
||||
// The full "59% (1.18 TB/2.00 TB)" bar label returns at 544px, so the usage
|
||||
// track must hold ~120px of text from the first operational width.
|
||||
operational: { name: 27, state: 18, host: 15, protection: 15, usage: 25 },
|
||||
full: { name: 20, state: 14, type: 10, host: 12, protection: 13, usage: 20, growth: 11 },
|
||||
};
|
||||
|
||||
@@ -58,10 +70,24 @@ export const isStoragePoolColumnVisible = (
|
||||
columnId: StoragePoolTableColumnId,
|
||||
): boolean => STORAGE_POOL_VISIBLE_COLUMNS[layout].includes(columnId);
|
||||
|
||||
export const getStoragePoolColumnWidthPercent = (
|
||||
// On phones the pool row sheds the desktop cell gutter the same way its
|
||||
// headers (via the shared container query) and the physical-disk rows do:
|
||||
// four cells at 12px each were 48px of a 361px row, and the measured values
|
||||
// fit the tracks with only a pixel or two to spare.
|
||||
export const STORAGE_POOL_PHONE_CELL_PADDING_CLASS = 'px-1!';
|
||||
|
||||
export const getStoragePoolCellPaddingClass = (layout: StoragePoolTableLayoutMode): string =>
|
||||
layout === 'narrow' || layout === 'compact' ? STORAGE_POOL_PHONE_CELL_PADDING_CLASS : '';
|
||||
|
||||
export const getStoragePoolColumnWidthStyle = (
|
||||
layout: StoragePoolTableLayoutMode,
|
||||
columnId: StoragePoolTableColumnId,
|
||||
): number => STORAGE_POOL_COLUMN_WIDTHS[layout][columnId] ?? 0;
|
||||
): JSX.CSSProperties =>
|
||||
getPlatformTableWeightedColumnWidthStyle(
|
||||
columnId,
|
||||
STORAGE_POOL_COLUMN_WEIGHTS[layout],
|
||||
STORAGE_POOL_VISIBLE_COLUMNS[layout],
|
||||
);
|
||||
|
||||
const STORAGE_POOL_TABLE_HEADER_CLASS =
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] lg:text-xs uppercase tracking-wider';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { batch, createRoot, createSignal } from 'solid-js';
|
||||
import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildProxmoxPageModel } from '@/features/proxmox/proxmoxPageModel';
|
||||
import { getDockerContainerLifecycleDisabledReason } from '@/features/docker/dockerContainerLifecycleActions';
|
||||
import type { State } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import useUnifiedResourcesSource from '../useUnifiedResources.ts?raw';
|
||||
@@ -1626,6 +1627,173 @@ describe('useUnifiedResources', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps PMG metadata on the canonical resource for REST-first paints', async () => {
|
||||
// The Mail Gateway table reads `resource.pmg`. The mapper used to keep it
|
||||
// only under platformData, so a REST-sourced row rendered every PMG column
|
||||
// as a dash until a websocket row happened to replace it.
|
||||
apiFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: 'pmg-1',
|
||||
type: 'pmg',
|
||||
name: 'mail-gateway-eu',
|
||||
status: 'online',
|
||||
lastSeen: '2026-02-06T12:00:00Z',
|
||||
sources: ['pmg'],
|
||||
uptime: 1_555_222,
|
||||
technology: 'pmg',
|
||||
sourceStatus: { pmg: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
|
||||
actionReadiness: [{ name: 'restart', available: true }],
|
||||
pmg: {
|
||||
instanceId: 'pmg-eu',
|
||||
hostname: 'pmg.example.lan',
|
||||
version: '8.1-2',
|
||||
nodeCount: 1,
|
||||
mailCountTotal: 2_740,
|
||||
spamIn: 321,
|
||||
virusIn: 14,
|
||||
queueTotal: 11,
|
||||
queueDeferred: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useUnifiedResources({ query: 'type=pmg&source=pmg', cacheKey: 'pmg-first-paint' });
|
||||
});
|
||||
|
||||
await result!.refetch();
|
||||
const row = result!.resources().find((resource) => resource.id === 'pmg-1');
|
||||
expect(row?.pmg).toEqual(
|
||||
expect.objectContaining({ version: '8.1-2', spamIn: 321, queueDeferred: 5 }),
|
||||
);
|
||||
expect(row?.platformData?.pmg).toEqual(row?.pmg);
|
||||
// The per-source status, action readiness, and technology facets ride the
|
||||
// same race: Docker lifecycle actions and storage freshness read them at
|
||||
// the top level, where only websocket rows used to carry them.
|
||||
expect(row?.sourceStatus?.pmg?.status).toBe('online');
|
||||
expect(row?.actionReadiness?.[0]?.name).toBe('restart');
|
||||
expect(row?.technology).toBe('pmg');
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'stale inventory',
|
||||
sourceStatus: { docker: { status: 'stale', lastSeen: '2026-02-06T12:00:00Z' } },
|
||||
actionReadiness: [],
|
||||
expected: 'Docker inventory is stale. Refresh inventory before running lifecycle actions.',
|
||||
},
|
||||
{
|
||||
label: 'failed inventory',
|
||||
sourceStatus: {
|
||||
docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z', error: 'Collector refused' },
|
||||
},
|
||||
actionReadiness: [],
|
||||
expected: 'Docker inventory is not healthy: Collector refused',
|
||||
},
|
||||
{
|
||||
label: 'server action refusal',
|
||||
sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: false, reason: 'Command agent disconnected' },
|
||||
],
|
||||
expected: 'Command agent disconnected',
|
||||
},
|
||||
{
|
||||
label: 'no invented action grant',
|
||||
sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
|
||||
actionReadiness: [],
|
||||
expected:
|
||||
'Pulse does not currently advertise a fresh restart command capability for this container.',
|
||||
},
|
||||
])('preserves $label through REST mapping into lifecycle controls', async (testCase) => {
|
||||
apiFetchMock.mockResolvedValue(
|
||||
resourceResponse([
|
||||
{
|
||||
id: 'docker-container-1',
|
||||
type: 'app-container',
|
||||
name: 'edge-web',
|
||||
status: 'running',
|
||||
lastSeen: '2026-02-06T12:00:00Z',
|
||||
sources: ['docker'],
|
||||
technology: 'docker',
|
||||
docker: { runtime: 'docker', agentId: 'agent-edge', containerState: 'running' },
|
||||
sourceStatus: testCase.sourceStatus,
|
||||
actionReadiness: testCase.actionReadiness,
|
||||
},
|
||||
]),
|
||||
);
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useUnifiedResources({ query: 'type=app-container', cacheKey: testCase.label });
|
||||
});
|
||||
try {
|
||||
await result!.refetch();
|
||||
const row = result!.resources()[0];
|
||||
expect(row.sourceStatus).toEqual(testCase.sourceStatus);
|
||||
expect(row.actionReadiness).toEqual(testCase.actionReadiness);
|
||||
expect(row.technology).toBe('docker');
|
||||
expect(getDockerContainerLifecycleDisabledReason(row, 'restart')).toBe(testCase.expected);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces REST facets without retaining a removed PMG counter or action refusal', async () => {
|
||||
const base = {
|
||||
id: 'pmg-1',
|
||||
type: 'pmg',
|
||||
name: 'mail-gateway',
|
||||
status: 'online',
|
||||
sources: ['pmg'],
|
||||
lastSeen: '2026-02-06T12:00:00Z',
|
||||
};
|
||||
apiFetchMock.mockResolvedValue(
|
||||
resourceResponse([
|
||||
{
|
||||
...base,
|
||||
technology: 'pmg',
|
||||
pmg: { version: '8.1-2', queueTotal: 11 },
|
||||
sourceStatus: { pmg: { status: 'stale', lastSeen: base.lastSeen } },
|
||||
actionReadiness: [{ name: 'restart', available: false, reason: 'Old refusal' }],
|
||||
},
|
||||
]),
|
||||
);
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useUnifiedResources({ query: 'type=pmg', cacheKey: 'removed-rest-facets' });
|
||||
});
|
||||
try {
|
||||
await result!.refetch();
|
||||
expect(result!.resources()[0].pmg?.queueTotal).toBe(11);
|
||||
apiFetchMock.mockResolvedValue(
|
||||
resourceResponse([{ ...base, actionReadiness: [], sourceStatus: {} }]),
|
||||
);
|
||||
await result!.refetch();
|
||||
const row = result!.resources()[0];
|
||||
expect(row.pmg).toBeUndefined();
|
||||
expect(row.platformData?.pmg).toBeUndefined();
|
||||
expect(row.actionReadiness).toEqual([]);
|
||||
expect(row.sourceStatus).toEqual({});
|
||||
expect(row.technology).toBeUndefined();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to canonical resource.uptime when no platform-specific field is set (vSphere)', async () => {
|
||||
// vSphere ESXi hosts and VMs populate only the canonical
|
||||
// Resource.Uptime field — there's no vmware-specific UptimeSeconds
|
||||
|
||||
@@ -18,8 +18,10 @@ import type {
|
||||
ResourceDockerMeta,
|
||||
ResourceHealth,
|
||||
ResourceMetricsTarget,
|
||||
ResourceActionReadiness,
|
||||
ResourceAvailabilityMeta,
|
||||
ResourcePBSMeta,
|
||||
ResourcePMGMeta,
|
||||
ResourcePolicyPostureSummary,
|
||||
ResourcePhysicalDiskMeta,
|
||||
ResourcePoolHealth,
|
||||
@@ -210,6 +212,8 @@ type APIResource = {
|
||||
sources?: string[];
|
||||
platformScopes?: string[];
|
||||
sourceStatus?: Record<string, { status: string; lastSeen: string; error?: string }>;
|
||||
actionReadiness?: ResourceActionReadiness[];
|
||||
technology?: string;
|
||||
health?: ResourceHealth;
|
||||
identity?: {
|
||||
machineId?: string;
|
||||
@@ -868,6 +872,17 @@ const toResource = (v2: APIResource): Resource => {
|
||||
truenas: v2.truenas as ResourceTrueNASMeta | undefined,
|
||||
vmware: v2.vmware as ResourceVMwareMeta | undefined,
|
||||
pbs: v2.pbs as ResourcePBSMeta | undefined,
|
||||
// Mail Gateway rows read `resource.pmg` directly. Leaving it only under
|
||||
// platformData meant a REST-first paint rendered every PMG column as a
|
||||
// dash until a websocket row happened to replace it, and never did when
|
||||
// the REST snapshot landed last.
|
||||
pmg: v2.pmg as ResourcePMGMeta | undefined,
|
||||
// Same race for the per-source status, action readiness, and technology
|
||||
// facets: Docker lifecycle actions and storage freshness read them from
|
||||
// the top level, and the websocket rows carry them there.
|
||||
sourceStatus: v2.sourceStatus,
|
||||
actionReadiness: v2.actionReadiness,
|
||||
technology: v2.technology,
|
||||
availability: v2.availability as ResourceAvailabilityMeta | undefined,
|
||||
availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined,
|
||||
physicalDisk: v2.physicalDisk,
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
getAlertDestinationsDeliveryLogEmpty,
|
||||
getAlertDestinationsDeliveryLogTitle,
|
||||
getAlertDestinationsDeliveryLogUnavailable,
|
||||
getAlertDestinationsDeliveryLogLoading,
|
||||
getAlertDestinationsHeldEventsLoading,
|
||||
getAlertDestinationsHeldEventsUnavailable,
|
||||
getAlertDestinationsTestPausedWarning,
|
||||
getAlertDestinationsAppriseTargetsHelp,
|
||||
getAlertDestinationsAppriseTestLabel,
|
||||
@@ -267,6 +270,28 @@ describe('alert destinations delivery log copy', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes pending and unavailable activity sources without implying delivery failure', () => {
|
||||
expect(getAlertDestinationsDeliveryLogLoading()).toBe('Loading delivery attempts...');
|
||||
expect(getAlertDestinationsHeldEventsLoading()).toBe(
|
||||
'Loading held and deferred notifications...',
|
||||
);
|
||||
expect(getAlertDestinationsHeldEventsUnavailable()).toBe(
|
||||
'Pulse could not read held or deferred notifications. Refresh to try again.',
|
||||
);
|
||||
expect(getAlertDestinationsDeliveryLogUnavailable()).toBe(
|
||||
'Pulse could not read the delivery log. Refresh to check delivery attempts.',
|
||||
);
|
||||
for (const message of [
|
||||
getAlertDestinationsDeliveryLogLoading(),
|
||||
getAlertDestinationsHeldEventsLoading(),
|
||||
getAlertDestinationsHeldEventsUnavailable(),
|
||||
getAlertDestinationsDeliveryLogUnavailable(),
|
||||
]) {
|
||||
expect(message).not.toContain('No alert deliveries');
|
||||
expect(message).not.toContain('not delivered');
|
||||
}
|
||||
});
|
||||
|
||||
it('warns that a passing test does not mean live alerts flow while delivery is paused', () => {
|
||||
const warning = getAlertDestinationsTestPausedWarning();
|
||||
expect(warning).toContain('Test sent');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
|
||||
import {
|
||||
getWorkloadsGuestBackupStatusPresentation,
|
||||
getWorkloadsGuestBackupTooltip,
|
||||
@@ -8,6 +9,18 @@ import {
|
||||
} from '@/utils/workloadGuestPresentation';
|
||||
|
||||
describe('workloadGuestPresentation', () => {
|
||||
it.each(guestDiskDeferrals)(
|
||||
'explains %s with and without retained disk evidence',
|
||||
(reason, message) => {
|
||||
expect(getWorkloadGuestDiskStatusMessage(reason)).toBe(message);
|
||||
expect(getWorkloadGuestDiskStatusMessage(`prev-${reason}`)).toBe(
|
||||
`Using last known disk stats. ${message}`,
|
||||
);
|
||||
expect(message).not.toContain('may not be installed');
|
||||
expect(message).not.toContain('may need to be restarted');
|
||||
},
|
||||
);
|
||||
|
||||
it('returns canonical guest backup status presentation', () => {
|
||||
expect(getWorkloadsGuestBackupStatusPresentation('fresh')).toEqual({
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user