mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
+63
-14
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user