From d856689f5b3b200c942db4151a0b657cfdacf1c4 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:31:00 +0100
Subject: [PATCH 1/5] Restore first-tap guest details on touch browsers
Reuse the shared non-activating native row marker without moving actions out of delegated event ordering. Preserve nested controls, keyboard disclosure, row identity and conditional action removal. Cover guest/full-drawer touch input and safe backup guidance with exact browser proof and source regressions.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 14 +
.../subsystems/performance-and-scalability.md | 9 +
.../browser-tests/guest-row-touch.cjs | 299 ++++++++++++++++++
.../browser-tests/guest-row-touch.html | 12 +
.../browser-tests/guest-row-touch.tsx | 123 +++++++
.../7139f2120612aedc.json | 45 +++
.../src/components/Workloads/GuestRow.tsx | 2 +
.../Workloads/__tests__/GuestRow.test.tsx | 65 ++++
.../src/components/shared/Table.tsx | 3 +-
.../shared/__tests__/PulseDataGrid.test.tsx | 13 +-
10 files changed, 581 insertions(+), 4 deletions(-)
create mode 100644 frontend-modern/browser-tests/guest-row-touch.cjs
create mode 100644 frontend-modern/browser-tests/guest-row-touch.html
create mode 100644 frontend-modern/browser-tests/guest-row-touch.tsx
create mode 100644 frontend-modern/browser-verification/7139f2120612aedc.json
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 0bf42b1a70..7760e00f05 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -623,6 +623,20 @@ scrolling. Explicit caller-owned native listeners remain caller-owned.
isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance.
The production PBS table/drawer browser fixture exercises first-tap disclosure
and History in WebKit phone emulation, not installed collection or devices.
+The custom Workloads `GuestRow` shell reuses `Table`'s exported
+`nativeRowClickTarget` marker, conditional on its own action, without inheriting
+shared row styling or changing alert/highlight treatment. Its name and row body
+must open the full guest drawer on the first touch even when the compact-layout
+disclosure button is visually hidden. Keep the action document-delegated;
+nested disclosure SVGs, external links and keyboard controls must still perform
+only their own action. Removing the action removes the native marker, and
+re-enabling it must work without replacing the row. Pointer-up and touch-end
+alone never perform the action. `GuestRow.test.tsx` covers native binding,
+non-activation, dynamic removal and child ordering;
+`browser-tests/guest-row-touch.cjs` covers trusted row/name taps, the production
+full drawer's backup/read guidance, nested controls and shared-row compatibility
+on Chromium desktop/phone and WebKit phone VM/CT fixtures. This is emulated
+input/presentation proof, not physical-device, native thaw or released acceptance.
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
is visually removed on phone layouts where the complete row is the touch target;
provider tables must not add a second mobile chevron. When row activation
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 1aa8041768..db65ab3598 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -198,6 +198,15 @@ poller, per-agent goroutine, inventory scan, or action-session lookup to normal
request routing; local assets are streamed once and published fallbacks retain
the existing bounded HTTP client and artifact-size checks.
+The custom Workloads guest row shares `Table`'s singleton non-activating native
+click marker for WebKit touch eligibility. Bind it only while a row action
+exists, without changing row identity, alert/highlight classes, metric derivation
+or delegated child-control ordering. The marker must not perform actions, read
+telemetry, start History work, add polling/timers or activate on pointer-up or
+touch-end. `GuestRow.test.tsx` verifies its conditional lifecycle and
+non-activation; the production row/full-drawer touch fixture verifies first
+activation and unchanged identity independently of native collector acceptance.
+
## Canonical Files
1. `pkg/metrics/store.go`
diff --git a/frontend-modern/browser-tests/guest-row-touch.cjs b/frontend-modern/browser-tests/guest-row-touch.cjs
new file mode 100644
index 0000000000..b75faf4c31
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-row-touch.cjs
@@ -0,0 +1,299 @@
+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-row-touch';
+const origin = 'http://127.0.0.1:5313';
+const runtime = [
+ 'frontend-modern/src/components/Workloads/GuestRow.tsx',
+ 'frontend-modern/src/components/shared/Table.tsx',
+];
+const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const result = {
+ playwright: require('playwright/package.json').version,
+ runtime_hashes: Object.fromEntries(runtime.map((file) => [file, sha256('/workspace/' + file)])),
+ results: [],
+ screenshots: [],
+ result: 'incomplete',
+ };
+ assert.equal(
+ result.playwright,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ 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: output + '/cache',
+ server: { host: '127.0.0.1', port: 5313, strictPort: true },
+ });
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, dark, kinds] of [
+ ['webkit-phone', webkit, 390, true, ['qemu', 'lxc']],
+ ['chromium-desktop', chromium, 1365, false, ['qemu', 'lxc']],
+ ['chromium-phone', chromium, 390, false, ['qemu']],
+ ['webkit-narrow', webkit, 360, false, ['lxc']],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const context = await browser.newContext({
+ viewport: { width, height: width <= 768 ? 844 : 900 },
+ isMobile: width <= 768,
+ hasTouch: width <= 768,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ for (const kind of kinds) {
+ const page = await context.newPage();
+ page.setDefaultTimeout(15000);
+ page.setDefaultNavigationTimeout(60000);
+ const record = {
+ name,
+ kind,
+ browser: browser.version(),
+ viewport: { width, height: width <= 768 ? 844 : 900 },
+ dark,
+ errors: [],
+ requests: [],
+ events: [],
+ checks: [],
+ };
+ result.results.push(record);
+ page.on('pageerror', (error) => record.errors.push(error.message));
+ await page.route('**/*', (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ record.requests.push({
+ method: request.method(),
+ origin: url.origin,
+ path: url.pathname,
+ });
+ if (url.origin !== origin) return route.abort();
+ if (url.pathname.startsWith('/api/'))
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ success: true,
+ data: [],
+ alerts: [],
+ anomalies: [],
+ config: null,
+ }),
+ });
+ return route.continue();
+ });
+ await page.addInitScript((isDark) => {
+ window.__inputEvents = [];
+ for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'click'])
+ document.addEventListener(
+ type,
+ (event) =>
+ window.__inputEvents.push({
+ type,
+ tag: event.target.tagName,
+ trusted: event.isTrusted,
+ pointerType: event.pointerType,
+ }),
+ true,
+ );
+ document.addEventListener('DOMContentLoaded', () => {
+ if (isDark) document.documentElement.classList.add('dark');
+ });
+ }, dark);
+ await page.goto(`${origin}/browser-tests/guest-row-touch.html?kind=${kind}`);
+ await page.waitForFunction(() => window.__guestRowTouch);
+ assert.equal(await page.evaluate(() => innerWidth), width);
+ const row = page.locator('[data-guest-id]');
+ const nameTarget = row.getByText('backup-guest', { exact: true });
+ const drawer = page.getByRole('region', { name: 'Guest details' });
+ const disclosure = row.locator('[data-row-action]');
+ await page.evaluate(() => {
+ window.__originalGuestRow = document.querySelector('[data-guest-id]');
+ });
+ const state = () =>
+ page.evaluate(() => ({
+ count: window.__guestRowTouch.actionCount(),
+ expanded: window.__guestRowTouch.expanded(),
+ closeCount: window.__guestRowTouch.closeCount(),
+ disclosureExpanded: document
+ .querySelector('[data-row-action]')
+ ?.getAttribute('aria-expanded'),
+ sameRow: document.querySelector('[data-guest-id]') === window.__originalGuestRow,
+ }));
+ const activate = async (target) => {
+ if (width <= 768) await target.tap();
+ else await target.click();
+ await page.waitForTimeout(650);
+ };
+ const expectState = async (count, expanded) => {
+ const value = await state();
+ record.state = value;
+ record.events = await page.evaluate(() => window.__inputEvents);
+ assert.equal(value.count, count, `${name}/${kind}: single row activation`);
+ assert.equal(value.expanded, expanded);
+ assert.equal(value.disclosureExpanded, String(expanded));
+ assert.equal(value.sameRow, true);
+ };
+ try {
+ await activate(width === 360 ? row : nameTarget);
+ await expectState(1, true);
+ assert(record.events.some((event) => event.type === 'click' && event.trusted));
+ if (width <= 768) {
+ assert(record.events.some((event) => event.type === 'touchend' && event.trusted));
+ assert.equal(await row.getAttribute('data-history-lens-active'), null);
+ }
+ await drawer.getByText('No completed backup found', { exact: true }).waitFor();
+ await drawer.getByText('Running · not completed yet', { exact: true }).waitFor();
+ if (kind === 'qemu')
+ await drawer.getByText(/Using last known disk stats\. Guest reads paused/).waitFor();
+ assert.equal(
+ await disclosure.getAttribute('aria-controls'),
+ await drawer.getAttribute('id'),
+ );
+ assert.equal(await row.getAttribute('tabindex'), null);
+ assert.equal(await row.getAttribute('aria-expanded'), null);
+ assert.equal(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
+ true,
+ );
+ record.checks.push(
+ `first trusted ${width === 360 ? 'row-body' : 'name'} activation opens production full drawer with safe backup/read context`,
+ );
+ const screenshot = `${output}/${name}-${kind}-open.png`;
+ await page.screenshot({ path: screenshot, fullPage: true });
+ result.screenshots.push({
+ path: path.relative('/workspace', screenshot),
+ sha256: sha256(screenshot),
+ });
+ await activate(width === 360 ? row : nameTarget);
+ await expectState(2, false);
+ await activate(row);
+ await expectState(3, true);
+ await activate(page.getByRole('button', { name: 'Collapse backup-guest details' }));
+ await expectState(3, false);
+ assert.equal((await state()).closeCount, 1);
+ record.checks.push(
+ 'repeated name and row-centre activation toggle once; nested full-drawer collapse stays independent',
+ );
+
+ await disclosure.focus();
+ await page.keyboard.press('Enter');
+ await expectState(4, true);
+ await page.keyboard.press('Space');
+ await expectState(5, false);
+ await disclosure.locator('svg').click();
+ await expectState(6, true);
+ // Keyboard focus makes the compact disclosure button visible and
+ // can consume the name's space; the row body stays the touch target.
+ await activate(row);
+ await expectState(7, false);
+ record.checks.push(
+ 'native disclosure keyboard Enter/Space and nested SVG click do not double-activate',
+ );
+
+ if (width >= 1024) {
+ // The adjacent link is intentionally hidden in condensed phone
+ // identity. Exercise its real wide-layout action, preventing only
+ // external navigation, not production propagation handling.
+ const link = row.getByRole('link', { name: 'Open web interface for backup-guest' });
+ await link.evaluate((anchor) =>
+ anchor.addEventListener('click', (event) => event.preventDefault()),
+ );
+ await activate(link.locator('svg'));
+ await expectState(7, false);
+ record.checks.push(
+ 'real external-link SVG retains its separate action; navigation prevented in fixture only',
+ );
+ }
+
+ await page.evaluate(() => window.__guestRowTouch.enabled(false));
+ assert.equal(await disclosure.count(), 0);
+ await activate(row);
+ assert.equal((await state()).count, 7);
+ assert.equal((await state()).expanded, false);
+ await page.evaluate(() => window.__guestRowTouch.enabled(true));
+ await activate(row);
+ await expectState(8, true);
+ record.checks.push(
+ 'disabling/re-enabling the row action preserves node identity and first-tap activation',
+ );
+ assert.deepEqual(record.errors, []);
+ assert(
+ record.requests.every(
+ (request) => request.origin === origin && request.method === 'GET',
+ ),
+ );
+ record.result = 'passed';
+ } catch (error) {
+ record.result = 'failed';
+ record.error = error.message;
+ const screenshot = `${output}/${name}-${kind}-failed.png`;
+ await page.screenshot({ path: screenshot, fullPage: true });
+ result.screenshots.push({
+ path: path.relative('/workspace', screenshot),
+ sha256: sha256(screenshot),
+ });
+ throw error;
+ } finally {
+ await page.close();
+ }
+ }
+ // Exporting the compatibility marker must preserve the original shared
+ // TableRow's own single-action and embedded-control ordering.
+ const page = await context.newPage();
+ await page.goto(`${origin}/browser-tests/table-row-touch.html`);
+ const sharedState = () => page.getByRole('status', { name: 'Action state' }).textContent();
+ const shared = page.locator('[data-testid="control-row"]');
+ if (width <= 768) await shared.getByText('Shared resource name', { exact: true }).tap();
+ else await shared.getByText('Shared resource name', { exact: true }).click();
+ const sharedValue = JSON.parse(await sharedState());
+ assert.equal(sharedValue.rowActions, 1);
+ await shared.getByRole('button', { name: 'Child action' }).click();
+ const childValue = JSON.parse(await sharedState());
+ assert.equal(childValue.rowActions, 1);
+ assert.equal(childValue.childActions, 1);
+ result.results.push({
+ name,
+ control: 'shared-TableRow',
+ single: sharedValue,
+ child: childValue,
+ result: 'passed',
+ });
+ await page.close();
+ await context.close();
+ await browser.close();
+ browser = null;
+ }
+ result.result = 'passed';
+ result.verified_at = new Date().toISOString();
+ console.log(
+ 'PASS: trusted guest row/disclosure activation, full drawer safety context and child isolation',
+ );
+ } catch (error) {
+ result.result = 'failed';
+ result.error = error.message;
+ throw error;
+ } finally {
+ fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error.stack);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/guest-row-touch.html b/frontend-modern/browser-tests/guest-row-touch.html
new file mode 100644
index 0000000000..c3c2c52ed3
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-row-touch.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Guest row touch verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/guest-row-touch.tsx b/frontend-modern/browser-tests/guest-row-touch.tsx
new file mode 100644
index 0000000000..539499c43e
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-row-touch.tsx
@@ -0,0 +1,123 @@
+// Production guest row and full drawer with synthetic observations.
+// No collector, backup command, QGA/thaw, installed device 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 { buildSummaryDisclosureControlsId } from '../src/components/shared/summaryInteractionA11y';
+import type { WorkloadTableLayoutMode } from '../src/components/Workloads/guestRowModel';
+import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
+import { DarkModeContext } from '../src/contexts/appRuntime';
+import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
+import type { WorkloadGuest } from '../src/types/workloads';
+import '../src/index.css';
+
+syncAIRuntimeSettings({ discovery_enabled: false });
+
+function Fixture() {
+ const [observation, setObservation] = createSignal({
+ lastBackup: 0,
+ running: true,
+ reason: 'prev-vm-locked',
+ });
+ const [closeCount, setCloseCount] = createSignal(0);
+ const [actionCount, setActionCount] = createSignal(0);
+ const [expanded, setExpanded] = createSignal(false);
+ const [enabled, setEnabled] = createSignal(true);
+ const [layout, setLayout] = createSignal(
+ window.innerWidth <= 768 ? 'phone' : 'wide',
+ );
+ const toggle = () => {
+ setActionCount((count) => count + 1);
+ setExpanded((value) => !value);
+ };
+ const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu';
+ const guest = (): WorkloadGuest => ({
+ id: 'fixture-pve:pve-a:101',
+ vmid: 101,
+ name: 'backup-guest',
+ node: 'pve-a',
+ instance: 'fixture-pve',
+ status: 'running',
+ type,
+ 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: 5 * 1024 ** 3, usage: 50 },
+ disks: [
+ { mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
+ ],
+ diskStatusReason: observation().reason,
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ lastBackup: observation().lastBackup,
+ backupInProgress: observation().running,
+ tags: [],
+ lock: observation().running ? 'backup' : '',
+ lastSeen: '2026-10-03T12:00:00Z',
+ });
+ (window as any).__guestRowTouch = {
+ update: setObservation,
+ closeCount,
+ actionCount,
+ expanded,
+ enabled: setEnabled,
+ layout: setLayout,
+ identity: () => guest().id,
+ };
+ return (
+
+ Guest row touch verification
+ Synthetic observations; no backup command is sent.
+
+
+
+ {
+ setCloseCount((count) => count + 1);
+ setExpanded(false);
+ }}
+ />
+
+
+
+ );
+}
+
+render(
+ () => (
+
+ (
+ document.documentElement.classList.contains('dark')}
+ >
+
+
+ )}
+ />
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification/7139f2120612aedc.json b/frontend-modern/browser-verification/7139f2120612aedc.json
new file mode 100644
index 0000000000..604657fe02
--- /dev/null
+++ b/frontend-modern/browser-verification/7139f2120612aedc.json
@@ -0,0 +1,45 @@
+{
+ "version": 1,
+ "base_sha": "888d918ba1a0a4bf448542630bc0f47af72e4572",
+ "verified_at": "2026-10-03T23:27:41.013Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestRow.tsx",
+ "frontend-modern/src/components/shared/Table.tsx"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/GuestRow.tsx": "1096ad3669baea8bec5c6a80b016b64f2c428d2e5a0b7c85746ff6bf21e103fa",
+ "frontend-modern/src/components/shared/Table.tsx": "014d9c4382d4de03a547f171e410fb9207686bf00dd76695b50c98249d6eacbd"
+ },
+ "routes": [
+ "/browser-tests/guest-row-touch.html?kind=qemu",
+ "/browser-tests/guest-row-touch.html?kind=lxc",
+ "/browser-tests/table-row-touch.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 360,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.",
+ "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.",
+ "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.",
+ "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately."
+ ],
+ "interactions": [
+ "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.",
+ "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.",
+ "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.",
+ "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors."
+ ]
+}
diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx
index 2622930599..8254fed215 100644
--- a/frontend-modern/src/components/Workloads/GuestRow.tsx
+++ b/frontend-modern/src/components/Workloads/GuestRow.tsx
@@ -27,6 +27,7 @@ import {
createSummaryInteractiveRowPreviewHandlers,
} from '@/components/shared/summaryInteractionA11y';
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
+import { nativeRowClickTarget } from '@/components/shared/Table';
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink';
@@ -241,6 +242,7 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
+ on:click={props.onClick ? nativeRowClickTarget : undefined}
onClick={props.onClick}
{...interactiveRowHandlers}
>
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
index 42b1032ce3..16dd981390 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
@@ -212,6 +212,7 @@ afterEach(() => {
cleanup();
vi.useRealTimers();
vi.clearAllMocks();
+ vi.restoreAllMocks();
isMobileMock.mockReturnValue(false);
});
@@ -835,6 +836,70 @@ describe('GuestRow', () => {
expect(onClick).toHaveBeenCalledOnce();
});
+ it('marks only actionable guest rows as native touch click targets', () => {
+ const addListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
+ const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener');
+ const onClick = vi.fn();
+ const [enabled, setEnabled] = createSignal(false);
+ const { container } = render(() => (
+
+ ));
+ const row = container.querySelector('tr')!;
+ const nativeClickCalls = () => addListener.mock.calls.filter(([type]) => type === 'click');
+
+ expect(nativeClickCalls()).toHaveLength(0);
+ setEnabled(true);
+ expect(nativeClickCalls()).toHaveLength(1);
+ expect(row).not.toHaveAttribute('tabindex');
+ expect(row).not.toHaveAttribute('role', 'button');
+ expect(row).not.toHaveAttribute('aria-expanded');
+
+ // The native compatibility target must never perform the action itself.
+ const nativeClick = nativeClickCalls()[0][1] as EventListener;
+ nativeClick.call(row, new MouseEvent('click'));
+ fireEvent.touchEnd(row);
+ fireEvent.pointerUp(row, { pointerType: 'touch' });
+ expect(onClick).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText('test-vm'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ setEnabled(false);
+ expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
+ fireEvent.click(row);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ setEnabled(true);
+ fireEvent.click(screen.getByText('test-vm'));
+ expect(onClick).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps child disclosure and external-link actions ahead of guest row activation', () => {
+ const onClick = vi.fn();
+ const { container } = renderGuestRow({
+ guest: makeGuest(),
+ customUrl: 'https://guest.example.invalid/',
+ onClick,
+ });
+ fireEvent.click(screen.getByRole('link', { name: 'Open web interface for test-vm' }));
+ expect(onClick).not.toHaveBeenCalled();
+ const button = screen.getByRole('button', { name: 'Expand test-vm' });
+ fireEvent.click(button.querySelector('svg')!);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ fireEvent.keyDown(button, { key: 'Enter' });
+ expect(onClick).toHaveBeenCalledTimes(2);
+ fireEvent.keyDown(button, { key: ' ', code: 'Space' });
+ expect(onClick).toHaveBeenCalledTimes(3);
+ fireEvent.keyDown(container.querySelector('tr')!, { key: 'Enter' });
+ expect(onClick).toHaveBeenCalledTimes(3);
+ });
+
it('calls onHoverChange with canonical guestId on fine-pointer preview', () => {
const onHoverChange = vi.fn();
const { container } = renderGuestRow({
diff --git a/frontend-modern/src/components/shared/Table.tsx b/frontend-modern/src/components/shared/Table.tsx
index a39c4ebabc..7b87a7d588 100644
--- a/frontend-modern/src/components/shared/Table.tsx
+++ b/frontend-modern/src/components/shared/Table.tsx
@@ -66,7 +66,8 @@ export type TableRowProps = JSX.HTMLAttributes;
// WebKit touch needs a native click target on otherwise-static table rows.
// Keep actions document-delegated: nested controls must run first and retain
// their existing stopPropagation behaviour instead of opening the row too.
-const nativeRowClickTarget = () => undefined;
+// Custom row shells reuse this marker without inheriting TableRow's styling.
+export const nativeRowClickTarget = () => undefined;
export function TableRow(props: TableRowProps) {
const [local, rest] = splitProps(props, ['class', 'children']);
diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx
index b42076dff2..b3da84eeb9 100644
--- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx
@@ -5,7 +5,7 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
-import { TableCell } from '@/components/shared/Table';
+import { TableCell, nativeRowClickTarget } from '@/components/shared/Table';
import tableSource from '@/components/shared/Table.tsx?raw';
type TestRow = {
@@ -105,16 +105,23 @@ describe('PulseDataGrid', () => {
it('inherits the native touch click target from the shared TableRow', () => {
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
+ const onRowClick = vi.fn();
try {
render(() => (
data={[{ id: '1', name: 'Touch tower' }]}
columns={[{ key: 'name', label: 'Name' }]}
keyExtractor={(row) => row.id}
- onRowClick={vi.fn()}
+ onRowClick={onRowClick}
/>
));
- expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
+ expect(nativeListener.mock.calls.find(([type]) => type === 'click')?.[1]).toBe(
+ nativeRowClickTarget,
+ );
+ nativeRowClickTarget();
+ expect(onRowClick).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText('Touch tower'));
+ expect(onRowClick).toHaveBeenCalledTimes(1);
} finally {
nativeListener.mockRestore();
}
From 826eaf2a1275b59dfc22f372958665223a0ebbe8 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:47:28 +0100
Subject: [PATCH 2/5] Forward native and delegated guest row handlers through
typed props
Match the existing shared TableRow splitProps pattern so the JSX lint check recognises distinct native eligibility and delegated action handlers. Preserve the same callback ordering, conditional removal, identity and keyboard controls; repeat the production guest/full-drawer browser pass for the final binding.
Change-source: pulse-maintainer
Contract-Neutral: Typed handler forwarding preserves the already-contracted touch eligibility, actions, child isolation, accessibility, row identity and state; no contract delta.
---
.../9596a94267f10b6d.json | 44 +++++++++++++++++++
.../src/components/Workloads/GuestRow.tsx | 7 +--
2 files changed, 48 insertions(+), 3 deletions(-)
create mode 100644 frontend-modern/browser-verification/9596a94267f10b6d.json
diff --git a/frontend-modern/browser-verification/9596a94267f10b6d.json b/frontend-modern/browser-verification/9596a94267f10b6d.json
new file mode 100644
index 0000000000..b18aa6a289
--- /dev/null
+++ b/frontend-modern/browser-verification/9596a94267f10b6d.json
@@ -0,0 +1,44 @@
+{
+ "version": 1,
+ "base_sha": "d856689f5b3b200c942db4151a0b657cfdacf1c4",
+ "verified_at": "2026-10-03T23:44:57.288Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestRow.tsx"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/GuestRow.tsx": "e531b8c4584a8a8f4c76a3205b472ae6cc41c15780cf70a0205b06f7bdf553ed"
+ },
+ "routes": [
+ "/browser-tests/guest-row-touch.html?kind=qemu",
+ "/browser-tests/guest-row-touch.html?kind=lxc",
+ "/browser-tests/table-row-touch.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 360,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.",
+ "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.",
+ "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.",
+ "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately.",
+ "Final GuestRow uses typed splitProps forwarding, matching the shared TableRow native/delegated pattern after the initial JSX lint failure. No lint rule was disabled; all browser interaction checks rerun on these exact final bytes."
+ ],
+ "interactions": [
+ "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.",
+ "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.",
+ "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.",
+ "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors."
+ ]
+}
diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx
index 8254fed215..784665c66b 100644
--- a/frontend-modern/src/components/Workloads/GuestRow.tsx
+++ b/frontend-modern/src/components/Workloads/GuestRow.tsx
@@ -1,4 +1,4 @@
-import { createMemo, createSignal, Show } from 'solid-js';
+import { createMemo, createSignal, Show, splitProps } from 'solid-js';
import BoxIcon from 'lucide-solid/icons/box';
import type { VM } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
@@ -50,6 +50,7 @@ export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel';
import { getGuestColumnStyle } from './guestRowModel';
export function GuestRow(props: GuestRowProps) {
+ const [rowActionProps] = splitProps(props, ['onClick']);
const {
agentVersion,
appContainerRuntimeBadge,
@@ -242,8 +243,8 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
- on:click={props.onClick ? nativeRowClickTarget : undefined}
- onClick={props.onClick}
+ on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined}
+ {...rowActionProps}
{...interactiveRowHandlers}
>
{/* Name - always visible */}
From c211bfe5229b2823975a708a6fa5c11435396b3d Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:50:18 +0100
Subject: [PATCH 3/5] Make monitored-container update help truthful about
recovery
Replace a claimed 15-minute backup and guaranteed rollback with the implemented five-minute cleanup, shared-data and best-effort limits. Put independent backups and deployment-manager recovery before the action, preserve current-state checks, and distinguish image-update suppression from command authority.
Contract-Neutral: Documentation and validation only; no container runtime, command authority or API changes.
Change-source: pulse-maintainer
---
docs/DOCKER.md | 77 +++++++++--
.../browser-tests/docker-update-guidance.cjs | 125 ++++++++++++++++++
frontend-modern/public/docs/DOCKER.md | 77 +++++++++--
.../docs/__tests__/dockerUpdateGuide.test.ts | 93 +++++++++++++
4 files changed, 344 insertions(+), 28 deletions(-)
create mode 100644 frontend-modern/browser-tests/docker-update-guidance.cjs
create mode 100644 frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index 1398425542..ac46ce3f9e 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -77,7 +77,7 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
| `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* |
| `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* |
| `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
-| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
+| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disable container image-update actions; does not disable start/stop/restart | `false` |
| `PULSE_METRICS_DB_PATH` | Optional path for only `metrics.db`, useful with tmpfs | `/data/metrics.db` |
| `PULSE_METRICS_ROLLUP_INTERVAL` | Metrics aggregation cadence; minimum 5 minutes | `15m` |
@@ -169,25 +169,48 @@ an image change alone does not undo data migrations.
## 🔄 Docker / Podman Updates
-Pulse can detect and apply updates to your Docker / Podman containers directly from the UI.
+These actions update **monitored containers**, not the Pulse server. Pulse can
+detect image updates without command execution; applying an update requires a
+separately enabled command channel and administrator review. Leave command
+execution disabled when you only need monitoring.
+
+### Before updating a workload
+
+- Plan for downtime: a running container is stopped and replaced, not updated
+ in place. Check the application's upgrade and data-migration requirements.
+- Take an independent, consistent backup of its application data, including
+ volumes and bind mounts, using the application's backup procedure. The
+ renamed old container is **not a backup of mounted data**: the replacement
+ uses those same mounts and can change their contents.
+- Keep the previous image identity and your deployment definition privately.
+ For Compose, Yacht or another deployment manager, prefer that manager's
+ update procedure so its saved definition records the intended image. Pulse's
+ recreation does not edit your Compose file or manager's desired state.
+- Do not use an update as a diagnostic test or start another container against
+ the same writable data to check a failure.
### How It Works
1. **Update Detection**: Pulse compares the local image digest with the latest digest from the container registry
2. **Visual Indicator**: Containers with available updates show a blue upward arrow icon
-3. **Reviewed Update**: Click the update button, approve the reviewed action, and Pulse handles the rest
+3. **Reviewed Update**: Review the target and approve the action, then verify the application yourself
### Updating a Container
-1. Navigate to the **Workloads** page (or filter by Docker sources on **Infrastructure**)
+1. Open **Docker → Overview** (`/docker/overview`) and find the host and container
2. Look for containers with a blue update arrow (⬆️)
3. Click the update button and approve the action in the review dialog (admin approval required)
-4. Pulse will:
+4. Pulse attempts to:
- Pull the latest image
- - Stop the current container
- - Create a backup (renamed with `_pulse_backup_` suffix)
- - Start a new container with the same configuration
- - Clean up the backup after 15 minutes (if the update succeeds)
+ - Stop the current container if it was running
+ - Rename the original container with the `_pulse_backup_` suffix
+ - Recreate the container from its inspected configuration, reusing its data mounts
+ - Start it only if the original was running, then perform a short runtime check
+ - Schedule removal of the renamed old container after a successful update
+
+Afterwards, check the actual image, container state, application readiness and
+data through your normal administration tools. A successful action is not an
+end-to-end application or data-integrity test.
### Batch Updates
@@ -195,9 +218,29 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd
### Safety Features
-- **Automatic Backup**: The old container is renamed, not deleted, until the update succeeds
-- **Rollback on Failure**: If the new container fails to start, the old one is restored
-- **Configuration Preserved**: Networks, volumes, ports, environment variables are all preserved
+- **Temporary old container**: the `_pulse_backup_` name retains the old
+ container, not a separate copy of its volumes or bind mounts. Current agents
+ schedule removal **five minutes after a successful update**; periodic cleanup
+ also removes old backup containers. Do not rely on a 15-minute recovery window
+ or on a remaining container as a durable backup.
+- **Best-effort rollback**: when recreation, network attachment or the early
+ runtime check fails, Pulse attempts to restore the original name and running
+ state. Removal, rename or restart can fail too. A failed banner does not prove
+ that the original container is running or that no change occurred.
+- **Limited verification**: the running replacement is inspected after a short
+ wait and rejected if stopped or explicitly unhealthy. A missing healthcheck
+ or a `starting` health status is not proof that the application is ready.
+- **Inspected configuration**: Pulse reuses container settings and mounts; it
+ does not reconcile your deployment manager or roll back application data
+ migrations. Verify networks, ports, mounts and the application after the change.
+
+If the result is failed or uncertain, **check the current state before retrying**.
+Retain the action error and time, old/new container and image identities, and
+any rollback error. Inspect these locally; do not post full `docker inspect`
+output, environments or registry credentials. Do not delete the old container
+or volumes to clear a warning. Recover through the application's and deployment
+manager's procedures using a verified matching data backup where required;
+returning to the old image alone does not undo a data migration.
### Requirements
@@ -231,9 +274,9 @@ Pulse provides granular control over update features via environment variables o
| Variable | Description |
|----------|-------------|
-| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hides update buttons from the UI while still detecting updates. Use this for "read-only" monitoring. |
+| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disables image-update actions while still detecting updates. Start/stop/restart have separate controls. |
-**Example - Read-Only Mode** (detect updates but prevent actions):
+**Example - Disable image updates** (continue detecting updates):
```yaml
services:
pulse:
@@ -242,6 +285,12 @@ services:
- PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true
```
+This setting is not a monitoring-only security boundary: it does not disable
+container lifecycle actions or revoke agent command permissions. For
+monitoring-only installs, leave command execution disabled on the agent and do
+not grant command execution permission to monitoring tokens. See
+[container lifecycle requirements](#️-container-lifecycle-actions).
+
To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**.
You can also toggle "Hide Docker Update Buttons" from the UI in **Settings → System → General** under **Docker / Podman updates**.
diff --git a/frontend-modern/browser-tests/docker-update-guidance.cjs b/frontend-modern/browser-tests/docker-update-guidance.cjs
new file mode 100644
index 0000000000..7bc69a5388
--- /dev/null
+++ b/frontend-modern/browser-tests/docker-update-guidance.cjs
@@ -0,0 +1,125 @@
+// Production Docs viewer and shipped Docker guide; no container action is run.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules', 'docker-update-guidance-browser');
+ fs.mkdirSync(artifacts, { recursive: true });
+ 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(root, 'node_modules', '.vite-docker-update-guidance'),
+ server: { host: '127.0.0.1', port: 5250, strictPort: true },
+ optimizeDeps: { noDiscovery: true, include: [] },
+ });
+ let browser;
+ const results = [];
+ try {
+ await server.listen();
+ for (const [engine, width, dark] of [
+ ['chromium', 1280, false],
+ ['webkit', 390, true],
+ ]) {
+ browser = await { chromium, webkit }[engine].launch(
+ engine === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width, height: 1000 },
+ ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
+ });
+ const errors = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text());
+ });
+ await page.goto('http://127.0.0.1:5250/browser-tests/docs-fragment-navigation.html', {
+ waitUntil: 'domcontentloaded',
+ timeout: 60000,
+ });
+ await page.evaluate(() => {
+ history.pushState({}, '', '/docs/DOCKER#before-updating-a-workload');
+ dispatchEvent(new PopStateEvent('popstate'));
+ });
+ if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const waitForHeading = async (id) => {
+ await page.waitForFunction((target) => {
+ const heading = document.getElementById(target);
+ if (!heading) return false;
+ const rect = heading.getBoundingClientRect();
+ return document.activeElement === heading && rect.top >= 0 && rect.top < innerHeight;
+ }, id);
+ assert.ok(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
+ 'page must not overflow the viewport',
+ );
+ };
+ await waitForHeading('before-updating-a-workload');
+ const article = await page.locator('article').innerText();
+ for (const text of [
+ 'independent, consistent backup',
+ 'not a backup of mounted data',
+ 'five minutes after a successful update',
+ 'Removal, rename or restart can fail too',
+ 'check the current state before retrying',
+ 'not a monitoring-only security boundary',
+ ])
+ assert.ok(article.replace(/\s+/g, ' ').includes(text), text);
+ await page.screenshot({ path: path.join(artifacts, `${engine}-precautions.png`) });
+ await page.evaluate(() => {
+ history.pushState({}, '', '/docs/DOCKER#safety-features');
+ dispatchEvent(new PopStateEvent('popstate'));
+ });
+ await waitForHeading('safety-features');
+ await page.screenshot({ path: path.join(artifacts, `${engine}-limits.png`) });
+ const link = page.getByRole('link', {
+ name: 'container lifecycle requirements',
+ exact: true,
+ });
+ const target = decodeURIComponent((await link.getAttribute('href')).split('#')[1]);
+ await link.focus();
+ await page.keyboard.press('Enter');
+ await waitForHeading(target);
+ assert.equal(
+ await page
+ .getByRole('heading', { name: '▶️ Container Lifecycle Actions', exact: true })
+ .textContent(),
+ '▶️ Container Lifecycle Actions',
+ );
+ assert.deepEqual(errors, []);
+ results.push({
+ engine,
+ version: browser.version(),
+ width,
+ dark,
+ precautionsAndLimitsRendered: true,
+ keyboardRequirementsLink: true,
+ headingsFocusedAndVisible: true,
+ noHorizontalOverflow: true,
+ errors,
+ });
+ await browser.close();
+ browser = undefined;
+ }
+ const result = {
+ result: 'passed',
+ playwright: require('playwright/package.json').version,
+ scope: 'shipped help rendering and keyboard navigation, not native updates or rollback',
+ results,
+ };
+ fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
+ console.log(JSON.stringify(result));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md
index 1398425542..ac46ce3f9e 100644
--- a/frontend-modern/public/docs/DOCKER.md
+++ b/frontend-modern/public/docs/DOCKER.md
@@ -77,7 +77,7 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
| `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* |
| `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* |
| `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
-| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
+| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disable container image-update actions; does not disable start/stop/restart | `false` |
| `PULSE_METRICS_DB_PATH` | Optional path for only `metrics.db`, useful with tmpfs | `/data/metrics.db` |
| `PULSE_METRICS_ROLLUP_INTERVAL` | Metrics aggregation cadence; minimum 5 minutes | `15m` |
@@ -169,25 +169,48 @@ an image change alone does not undo data migrations.
## 🔄 Docker / Podman Updates
-Pulse can detect and apply updates to your Docker / Podman containers directly from the UI.
+These actions update **monitored containers**, not the Pulse server. Pulse can
+detect image updates without command execution; applying an update requires a
+separately enabled command channel and administrator review. Leave command
+execution disabled when you only need monitoring.
+
+### Before updating a workload
+
+- Plan for downtime: a running container is stopped and replaced, not updated
+ in place. Check the application's upgrade and data-migration requirements.
+- Take an independent, consistent backup of its application data, including
+ volumes and bind mounts, using the application's backup procedure. The
+ renamed old container is **not a backup of mounted data**: the replacement
+ uses those same mounts and can change their contents.
+- Keep the previous image identity and your deployment definition privately.
+ For Compose, Yacht or another deployment manager, prefer that manager's
+ update procedure so its saved definition records the intended image. Pulse's
+ recreation does not edit your Compose file or manager's desired state.
+- Do not use an update as a diagnostic test or start another container against
+ the same writable data to check a failure.
### How It Works
1. **Update Detection**: Pulse compares the local image digest with the latest digest from the container registry
2. **Visual Indicator**: Containers with available updates show a blue upward arrow icon
-3. **Reviewed Update**: Click the update button, approve the reviewed action, and Pulse handles the rest
+3. **Reviewed Update**: Review the target and approve the action, then verify the application yourself
### Updating a Container
-1. Navigate to the **Workloads** page (or filter by Docker sources on **Infrastructure**)
+1. Open **Docker → Overview** (`/docker/overview`) and find the host and container
2. Look for containers with a blue update arrow (⬆️)
3. Click the update button and approve the action in the review dialog (admin approval required)
-4. Pulse will:
+4. Pulse attempts to:
- Pull the latest image
- - Stop the current container
- - Create a backup (renamed with `_pulse_backup_` suffix)
- - Start a new container with the same configuration
- - Clean up the backup after 15 minutes (if the update succeeds)
+ - Stop the current container if it was running
+ - Rename the original container with the `_pulse_backup_` suffix
+ - Recreate the container from its inspected configuration, reusing its data mounts
+ - Start it only if the original was running, then perform a short runtime check
+ - Schedule removal of the renamed old container after a successful update
+
+Afterwards, check the actual image, container state, application readiness and
+data through your normal administration tools. A successful action is not an
+end-to-end application or data-integrity test.
### Batch Updates
@@ -195,9 +218,29 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd
### Safety Features
-- **Automatic Backup**: The old container is renamed, not deleted, until the update succeeds
-- **Rollback on Failure**: If the new container fails to start, the old one is restored
-- **Configuration Preserved**: Networks, volumes, ports, environment variables are all preserved
+- **Temporary old container**: the `_pulse_backup_` name retains the old
+ container, not a separate copy of its volumes or bind mounts. Current agents
+ schedule removal **five minutes after a successful update**; periodic cleanup
+ also removes old backup containers. Do not rely on a 15-minute recovery window
+ or on a remaining container as a durable backup.
+- **Best-effort rollback**: when recreation, network attachment or the early
+ runtime check fails, Pulse attempts to restore the original name and running
+ state. Removal, rename or restart can fail too. A failed banner does not prove
+ that the original container is running or that no change occurred.
+- **Limited verification**: the running replacement is inspected after a short
+ wait and rejected if stopped or explicitly unhealthy. A missing healthcheck
+ or a `starting` health status is not proof that the application is ready.
+- **Inspected configuration**: Pulse reuses container settings and mounts; it
+ does not reconcile your deployment manager or roll back application data
+ migrations. Verify networks, ports, mounts and the application after the change.
+
+If the result is failed or uncertain, **check the current state before retrying**.
+Retain the action error and time, old/new container and image identities, and
+any rollback error. Inspect these locally; do not post full `docker inspect`
+output, environments or registry credentials. Do not delete the old container
+or volumes to clear a warning. Recover through the application's and deployment
+manager's procedures using a verified matching data backup where required;
+returning to the old image alone does not undo a data migration.
### Requirements
@@ -231,9 +274,9 @@ Pulse provides granular control over update features via environment variables o
| Variable | Description |
|----------|-------------|
-| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hides update buttons from the UI while still detecting updates. Use this for "read-only" monitoring. |
+| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disables image-update actions while still detecting updates. Start/stop/restart have separate controls. |
-**Example - Read-Only Mode** (detect updates but prevent actions):
+**Example - Disable image updates** (continue detecting updates):
```yaml
services:
pulse:
@@ -242,6 +285,12 @@ services:
- PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true
```
+This setting is not a monitoring-only security boundary: it does not disable
+container lifecycle actions or revoke agent command permissions. For
+monitoring-only installs, leave command execution disabled on the agent and do
+not grant command execution permission to monitoring tokens. See
+[container lifecycle requirements](#️-container-lifecycle-actions).
+
To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**.
You can also toggle "Hide Docker Update Buttons" from the UI in **Settings → System → General** under **Docker / Podman updates**.
diff --git a/frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts b/frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts
new file mode 100644
index 0000000000..2ff6bcecfb
--- /dev/null
+++ b/frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts
@@ -0,0 +1,93 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { renderDocMarkdown } from '../docMarkdown';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
+const guide = readFileSync(path.join(repoRoot, 'docs/DOCKER.md'), 'utf8');
+
+function renderGuide(): HTMLElement {
+ const article = document.createElement('article');
+ article.innerHTML = renderDocMarkdown(guide, 'DOCKER');
+ return article;
+}
+
+function section(article: HTMLElement, id: string): string {
+ const heading = article.querySelector(`#${id}`);
+ expect(heading, `missing guide section ${id}`).not.toBeNull();
+ const parts: string[] = [];
+ for (let element = heading?.nextElementSibling; element; element = element.nextElementSibling) {
+ if (/^H[1-3]$/.test(element.tagName)) break;
+ parts.push(element.textContent ?? '');
+ }
+ return parts.join(' ').replace(/\s+/g, ' ');
+}
+
+describe('shipped monitored-container update guide', () => {
+ it('places independent data backup and deployment-manager limits before the action', () => {
+ const article = renderGuide();
+ const precautions = section(article, 'before-updating-a-workload');
+ expect(precautions).toContain('Plan for downtime');
+ expect(precautions).toContain('independent, consistent backup');
+ expect(precautions).toContain('not a backup of mounted data');
+ expect(precautions).toContain('same mounts and can change their contents');
+ expect(precautions).toContain("does not edit your Compose file or manager's desired state");
+ expect(precautions).toContain('Do not use an update as a diagnostic test');
+ const precautionHeading = article.querySelector('#before-updating-a-workload')!;
+ const actionHeading = article.querySelector('#updating-a-container')!;
+ expect(precautionHeading.compareDocumentPosition(actionHeading)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(section(article, 'updating-a-container')).toContain('/docker/overview');
+ expect(section(article, 'updating-a-container')).not.toContain('Workloads page');
+ });
+
+ it('does not promise durable backup, complete rollback or application readiness', () => {
+ const limits = section(renderGuide(), 'safety-features');
+ expect(limits).toContain('not a separate copy of its volumes or bind mounts');
+ expect(limits).toContain('Removal, rename or restart can fail too');
+ expect(limits).toContain('A failed banner does not prove');
+ expect(limits).toContain('starting health status is not proof');
+ expect(limits).toContain('check the current state before retrying');
+ expect(limits).toContain('Do not delete the old container or volumes');
+ expect(limits).toContain('old image alone does not undo a data migration');
+ expect(limits).toContain('do not post full docker inspect output');
+ expect(limits).not.toContain('the old one is restored');
+ expect(limits).not.toContain('are all preserved');
+ });
+
+ it('matches the implemented early-removal window, not the orphan cleanup interval', () => {
+ // Static source binding, not a claim that a real update or rollback ran.
+ const update = readFileSync(
+ path.join(repoRoot, 'internal/dockeragent/container_update.go'),
+ 'utf8',
+ );
+ const cleanup = readFileSync(path.join(repoRoot, 'internal/dockeragent/cleanup.go'), 'utf8');
+ expect(update).toMatch(/waitForAsyncDelay\(5 \* time.Minute\)/);
+ expect(cleanup).toMatch(/time.Since\(backupTime\) > 15\*time.Minute/);
+ const limits = section(renderGuide(), 'safety-features');
+ expect(limits).toContain('five minutes after a successful update');
+ expect(limits).toContain('Do not rely on a 15-minute recovery window');
+ expect(limits).not.toContain('Clean up the backup after 15 minutes');
+ });
+
+ it('keeps image-update suppression separate from command authority and links the real requirements', () => {
+ const article = renderGuide();
+ const disabled = section(article, 'disabling-update-features');
+ expect(disabled).toContain('not a monitoring-only security boundary');
+ expect(disabled).toContain('does not disable container lifecycle actions');
+ expect(disabled).toContain('leave command execution disabled on the agent');
+ expect(disabled).toContain('do not grant command execution permission to monitoring tokens');
+ expect(disabled).not.toContain('Read-Only Mode');
+ const link = Array.from(article.querySelectorAll('a')).find(
+ (anchor) => anchor.textContent === 'container lifecycle requirements',
+ );
+ expect(link).toBeDefined();
+ const targetId = decodeURIComponent(link!.hash.slice(1));
+ const target = Array.from(article.querySelectorAll('h2')).find(
+ (heading) => heading.id === targetId,
+ );
+ expect(target?.textContent).toBe('▶️ Container Lifecycle Actions');
+ });
+});
From c5bd6f1d99eecd49b5907c3091197dcbf55a6511 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:56:16 +0100
Subject: [PATCH 4/5] Align promotion controls with billing-safe demo profiles
PR2429 governance run 37161775883 still requires the removed demo entitlement rewrite and integrity clearing. Require the existing read-only capability gate instead, and reject billing writes or integrity removal. Keep all runtime profile, protected publication and exact-RC controls.
Contract-Neutral: Test-only alignment with the reviewed billing-preserving demo transaction; no runtime, API, deployment authority or release policy delta.
Change-source: pulse-maintainer
---
.../release_promotion_policy_test.py | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py
index 351a017957..343391ca2a 100644
--- a/scripts/release_control/release_promotion_policy_test.py
+++ b/scripts/release_control/release_promotion_policy_test.py
@@ -2643,15 +2643,23 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("MOCK_SEED_DURATION=2h", demo_profile)
self.assertIn("MOCK_SAMPLE_INTERVAL=5m", demo_profile)
self.assertIn("MOCK_UPDATE_INTERVAL=15s", demo_profile)
- # Profile application moved into the owned transaction; keep its
- # runtime settings and entitlement, not the superseded shell snippets.
+ # The owned transaction adjusts only the demo runtime profile. It
+ # requires an existing entitlement; it must not grant one by rewriting
+ # signed billing state or clearing its integrity check.
transaction = read(".github/scripts/demo-runtime-transaction.py")
for required in ('"DEMO_MODE": "true"', '"PULSE_MOCK_MODE": "true"',
'"PULSE_MOCK_SEED_METRICS_STORE": "false"',
'"seed_duration": "trends_seed_duration"', '"sample_interval": "trends_sample_interval"',
- '"PULSE_MOCK_" + key.upper()', '["demo_fixtures"]', 'billing.pop("integrity", None)',
+ '"PULSE_MOCK_" + key.upper()',
+ 'if "demo_fixtures" not in billing["capabilities"]:',
+ 'raise Failure("billing-demo-capability-required")',
+ 'os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK',
+ 'self.host.profile_admission()',
'self.host.profile(self.request, not healthy_baseline)', 'self.host.start()'):
self.assertIn(required, transaction)
+ self.assertNotIn('billing.pop("integrity", None)', transaction)
+ self.assertNotIn('billing_file.write_text(', transaction)
+ self.assertNotIn('billing_file.write_bytes(', transaction)
self.assertIn("MOCK_NODES: ${{ needs.resolve.outputs.mock_nodes }}", demo)
self.assertIn("MOCK_UPDATE_INTERVAL: ${{ needs.resolve.outputs.mock_update_interval }}", demo)
self.assertIn("/api/license/runtime-capabilities", demo)
From 1b97f2d94b3f4600ac0be45b31e16f4283c2c6f9 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:56:41 +0100
Subject: [PATCH 5/5] Capture Docker safety help after theme transitions settle
Wait for the Docs fixture theme transition before retaining phone screenshots. This changes the visual witness only, not rendering, container actions or the guide.
Contract-Neutral: Browser validation only; no runtime or public contract change.
Change-source: pulse-maintainer
---
frontend-modern/browser-tests/docker-update-guidance.cjs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/frontend-modern/browser-tests/docker-update-guidance.cjs b/frontend-modern/browser-tests/docker-update-guidance.cjs
index 7bc69a5388..bfddf3c4ae 100644
--- a/frontend-modern/browser-tests/docker-update-guidance.cjs
+++ b/frontend-modern/browser-tests/docker-update-guidance.cjs
@@ -61,6 +61,12 @@ const { chromium, webkit } = require('playwright');
);
};
await waitForHeading('before-updating-a-workload');
+ // Theme colours transition after the root class changes. Capture settled
+ // content, not an intermediate grey background on the phone fixture.
+ await page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
+ );
+ await page.waitForFunction(() => document.getAnimations().length === 0);
const article = await page.locator('article').innerText();
for (const text of [
'independent, consistent backup',