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/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/docker-update-guidance.cjs b/frontend-modern/browser-tests/docker-update-guidance.cjs
new file mode 100644
index 0000000000..bfddf3c4ae
--- /dev/null
+++ b/frontend-modern/browser-tests/docker-update-guidance.cjs
@@ -0,0 +1,131 @@
+// 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');
+ // 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',
+ '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/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/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/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/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx
index 2622930599..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';
@@ -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';
@@ -49,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,
@@ -241,7 +243,8 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
- onClick={props.onClick}
+ on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined}
+ {...rowActionProps}
{...interactiveRowHandlers}
>
{/* Name - always visible */}
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();
}
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');
+ });
+});
diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py
index f68fbb09d2..1bb96bed8f 100644
--- a/scripts/release_control/release_promotion_policy_test.py
+++ b/scripts/release_control/release_promotion_policy_test.py
@@ -2643,8 +2643,9 @@ 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; retain runtime
- # settings but require an existing capability without rewriting billing.
+ # 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"',
@@ -2652,11 +2653,15 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
'"PULSE_MOCK_" + key.upper()', 'self.profile_admission()',
'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["capabilities"] = ["demo_fixtures"]', transaction)
self.assertNotIn('atomic_json(billing_file, billing)', 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)