Open guest details on the first tap and clarify container-update recovery (#2453)

## Outcome and why

- **Guest details open on the first phone tap.** Give VM and container
rows the same native click eligibility as shared table rows while
preserving delegated actions, keyboard disclosure and nested-link
isolation. The observed touch-browser failure hid existing backup and
disk safety details.
- **Container-update help states the real recovery limits.** Put
independent application-data backups before updates. Explain shared
mounts, five-minute old-container removal, best-effort rollback and
limited readiness checks; distinguish image-update suppression from
command authority. The preceding guide promised recovery guarantees the
agent does not provide.
- **Promotion controls preserve billing state.** Strengthen the existing
read-only entitlement and integrity assertions after an obsolete
assertion blocked the preceding reviewed batch. No production billing or
release authority changes.

Related to #1891. Its installed failed-update and durable recovery
outcomes remain unfinished. Related to #2439. Opening safety details is
not native guest thaw or a demonstrated fix for that report. No issue is
intentionally closed.

## Reviewed range and reconciliation

Complete range:
`dd00f9fccd6a7e66eb8841d230cb82d0d3b46cf1..5311a7c6febc5dfd7fcd81b10ca188d925e34527`.

Includes:
- Delivery candidate `c5bd6f1d99eecd49b5907c3091197dcbf55a6511`,
integrated at `a131e4be7dd83c323af9d3613f1b4b5e95624bbd` with the union
of overlapping test assertions.
- Container-help candidate
`c211bfe5229b2823975a708a6fa5c11435396b3d..1b97f2d94b3f4600ac0be45b31e16f4283c2c6f9`,
integrated at `4c3d6f10c4a979d1ed02d82229eeeb2f78acdbed`.
- Guest-row repairs `d856689f5b3b200c942db4151a0b657cfdacf1c4` and
`826eaf2a1275b59dfc22f372958665223a0ebbe8`, qualified together at
`116211d7f2585481b79f28c7c441aa6efbb879a1` and integrated at the final
head.
- Additive upstream composition
`6de9771e76340d83d53730ebb2acbe78ae253487` retains the current published
main and every reviewed identity. No accepted history was rewritten;
dependency manifests are unchanged.

## Validation and limits

Retained owning proof `source-vrim01em` passes 137 GuestRow tests, full
types, focused lint/format, copy and row accessibility audits,
production frontend/embed build and budgets. Original own-parent browser
receipts cover the exact unchanged final row bytes: six
guest/full-drawer cases plus four shared-row controls, desktop Chromium
and phone WebKit, first/repeated taps, keyboard disclosure and
propagation isolation. This is emulated browser evidence, not a physical
phone or native backup operation.

Retained help proof `source-34vo9thj` passes 59 rendered-doc controls,
types, focused lint/format/copy, production build/budgets and mirrors.
Its inspected desktop Chromium and phone WebKit witness covers help
rendering, keyboard navigation and overflow, not an actual container
update or rollback. Retained Delivery proof `source-o60c8bal` passes 60
promotion controls, 58 connected demo-transaction controls and two
install contracts. Earlier setup failures and corrected intermediate
lint/theme observations remain adverse history, not passes.

Final-range provenance, whitespace, canonical completion, exact guide
mirror and cumulative two-file browser coverage pass. The final-source
promotion suite passes all 60 controls. An additional combined frontend
check could not start within the isolated executor capacity window
(`proof-worker-busy`, client exit 2); it is not counted as a pass. The
checked row source/fixtures and guide/witness are byte-identical to
their respective owning proofs, the two changes are disjoint, and
manifests and shared renderer code are unchanged. Those sufficient
build/browser checks are reused; exact-head CI must still complete.

Independent review and required exact-head CI still govern landing. This
proposes source improvements only; containing release, installed
recovery, native thaw and customer acceptance remain separate outcomes.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-04 01:48:44 +00:00
committed by GitHub
16 changed files with 985 additions and 36 deletions
+63 -14
View File
@@ -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**.
@@ -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
@@ -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`
@@ -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;
});
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Guest row touch verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-row-touch.tsx"></script>
</body>
</html>
@@ -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<WorkloadTableLayoutMode>(
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 (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest row touch verification</h1>
<p class="text-xs text-muted">Synthetic observations; no backup command is sent.</p>
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
onClick={enabled() ? toggle : undefined}
isExpanded={expanded()}
workloadTableLayoutMode={layout()}
customUrl="https://guest.example.invalid/"
/>
</tbody>
</table>
<Show when={expanded()}>
<section
id={buildSummaryDisclosureControlsId(guest().id)}
aria-label="Guest details"
class="mx-auto max-w-4xl"
>
<GuestDrawer
guest={guest()}
onClose={() => {
setCloseCount((count) => count + 1);
setExpanded(false);
}}
/>
</section>
</Show>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -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."
]
}
@@ -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."
]
}
+63 -14
View File
@@ -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**.
@@ -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 */}
@@ -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(() => (
<table>
<tbody>
<GuestRow
guest={makeGuest()}
workloadTableLayoutMode="phone"
onClick={enabled() ? onClick : undefined}
/>
</tbody>
</table>
));
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({
@@ -66,7 +66,8 @@ export type TableRowProps = JSX.HTMLAttributes<HTMLTableRowElement>;
// 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']);
@@ -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(() => (
<PulseDataGrid<TestRow>
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();
}
@@ -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');
});
});
@@ -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)