From 5cd4b4439042ed9be54773d2bc6dd37c20d9e96c Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 10:29:28 +0100
Subject: [PATCH] fix(diagnostics): redact current exports and explain safe
collection
Redact actual token usage, snapshot and nested guest/disk identities without losing measurements or real diagnostic joins. Distinguish private downloads from files that need manual review, explain live-check risks before either run action, and keep the existing shipped help in sync.
Change-source: pulse-maintainer
---
docs/TROUBLESHOOTING.md | 31 +-
.../subsystems/frontend-primitives.md | 30 ++
.../browser-tests/diagnostics-safety.cjs | 423 ++++++++++++++++++
.../browser-tests/diagnostics-safety.html | 12 +
.../browser-tests/diagnostics-safety.tsx | 29 ++
.../b1ce78ea7a48b4a8.json | 60 +++
.../public/docs/TROUBLESHOOTING.md | 31 +-
.../components/Settings/DiagnosticsPanel.tsx | 28 +-
.../Settings/DiagnosticsResultsPanel.tsx | 2 +
.../DiagnosticsResultsPanel.test.tsx | 24 +-
.../diagnosticsExportPrivacy.test.ts | 257 +++++++++++
.../diagnosticsModel.branchcov2.test.ts | 14 +-
.../__tests__/settingsArchitecture.test.ts | 19 +
.../useDiagnosticsPanelState.test.ts | 8 +-
.../components/Settings/diagnosticsModel.ts | 130 +++++-
.../Settings/useDiagnosticsPanelState.ts | 7 +-
.../__tests__/diagnosticsPresentation.test.ts | 16 +-
.../src/utils/diagnosticsPresentation.ts | 15 +-
18 files changed, 1097 insertions(+), 39 deletions(-)
create mode 100644 frontend-modern/browser-tests/diagnostics-safety.cjs
create mode 100644 frontend-modern/browser-tests/diagnostics-safety.html
create mode 100644 frontend-modern/browser-tests/diagnostics-safety.tsx
create mode 100644 frontend-modern/browser-verification/b1ce78ea7a48b4a8.json
create mode 100644 frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index b5e498ebb3..547b336a02 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -520,6 +520,32 @@ not a guide to the current menu.
## 🛠️ Advanced Diagnostics
+### Collect diagnostics safely
+
+**Run Diagnostics** in **Settings → Diagnostics** is not just a passive export.
+It can make live Proxmox/PBS API and guest-agent requests; results may also come
+from a short-lived cache. Do not run it during a backup, freeze/thaw or an
+unresponsive-host incident merely to obtain a report. Keep the original errors
+and existing observations instead. A successful one-off check does not prove
+that normal monitoring has recovered or that a guest has thawed.
+
+After a result is displayed, the download buttons reuse that result without
+running the checks again. They save a local JSON file, **not an upload**:
+
+- **Full (private)** retains identifying diagnostic details. Keep it private;
+ do not attach it to a public issue or discussion.
+- **GitHub (review first)** replaces selected infrastructure and token
+ identifiers, private filesystem paths and raw disk-response fields. Counts,
+ measurements, collection times and diagnostic states remain useful for
+ triage. It is not a guarantee that every free-text error or future field is
+ free of private information. Open the file locally and review it before sharing.
+
+Remove credentials, cookies, secret URLs, private host/network or personal
+information, including details echoed in errors or notes. Do not paste a
+**Copy as cURL** command, full network export, configuration or data directory.
+Share only evidence relevant to the symptom; a screenshot or exact redacted
+error may be enough. See [Getting Help](#-getting-help).
+
### Inspect Notification Logs
Prefer **Recent delivery activity** in **Alerts → Notifications**. If a local log
@@ -684,8 +710,9 @@ If you're still stuck:
running image tag or digest. If installation never started Pulse, give the
attempted release and public installer/helper source, or say "unknown".
3. **Choose relevant, safe evidence**: if Pulse is running and collection is
- safe, use **Settings → Diagnostics → Export for GitHub (sanitized)** for
- connection or data failures. For a visual problem, a screenshot or the exact
+ safe, use **Settings → Diagnostics → GitHub (review first)** for
+ connection or data failures, following [safe diagnostics collection](#collect-diagnostics-safely).
+ For a visual problem, a screenshot or the exact
error may be enough. If logs are needed, inspect a bounded local excerpt
(`journalctl -u pulse -n 100 --no-pager` or `docker logs --tail 100 pulse`),
not a full configuration or data-directory upload.
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 8f582d0b22..914efd6f49 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -20,6 +20,36 @@
## Purpose
+### Diagnostics are live checks; downloads require a privacy review
+
+The existing Settings Diagnostics panel explains before either run action that
+checks can make live API and guest-agent requests, must not run during backup,
+freeze/thaw or an unresponsive-host incident, and cannot prove ongoing collection
+has recovered. Both run controls reference that visible explanation. Export
+controls distinguish **Full (private)** from **GitHub (review first)** and reference
+the visible sharing warning. They download the displayed result locally without
+another diagnostics request or upload, and remain disabled during a refresh.
+The existing Troubleshooting viewer links to matching shipped guidance.
+
+Sanitised exports redact the server's actual token usage IDs and agent names,
+memory snapshot identities and breakdown instances, nested guest IDs/names,
+mount paths and physical disk paths. Per-download aliases preserve actual token,
+agent and snapshot joins across differently ordered arrays. Counts, memory
+measurements, failure states and known fixed API failure categories remain useful;
+unrecognised upstream response bodies are redacted. Nested IPv4 prose receives
+the same IP redaction as top-level errors. The full/displayed result is unchanged.
+This is not an anonymity guarantee: arbitrary prose, future fields, credentials,
+secret URLs and other private information still require manual review.
+
+Verification: `diagnosticsExportPrivacy.test.ts` exercises current server field
+names, reordered identity joins, nested privacy gaps, unchanged measurements and
+unchanged full data. Diagnostics presentation, hook, results and Settings tests
+cover the connected actions and warnings; shipped docs remain byte-synchronised.
+`browser-tests/diagnostics-safety.cjs` exercises production components, the real
+API reader and local downloads with synthetic responses on Chromium and touch
+WebKit, including loading/failure, keyboard, narrow layouts and the Docs link.
+These proofs do not run native guest checks or establish release availability.
+
### Backup age requires a usable completed timestamp
Existing workload indicators, age badges and drawer protection context distinguish
diff --git a/frontend-modern/browser-tests/diagnostics-safety.cjs b/frontend-modern/browser-tests/diagnostics-safety.cjs
new file mode 100644
index 0000000000..f85ba89ab8
--- /dev/null
+++ b/frontend-modern/browser-tests/diagnostics-safety.cjs
@@ -0,0 +1,423 @@
+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/diagnostics-safety-proof';
+const origin = 'http://127.0.0.1:5293';
+const runtime = [
+ 'frontend-modern/src/components/Settings/diagnosticsModel.ts',
+ 'frontend-modern/src/components/Settings/DiagnosticsPanel.tsx',
+ 'frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx',
+ 'frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts',
+ 'frontend-modern/src/utils/diagnosticsPresentation.ts',
+];
+// Only synthetic server-shaped fields, including identities omitted by the old
+// export redactor. No credential or actual report is read by this fixture.
+const payload = {
+ version: '6.4.5',
+ runtime: 'go',
+ uptime: 300,
+ nodes: [],
+ pbs: [],
+ errors: [],
+ system: {
+ os: 'linux',
+ arch: 'amd64',
+ goVersion: 'go1.26.7',
+ numCPU: 4,
+ numGoroutine: 30,
+ memoryMB: 128,
+ },
+ apiTokens: {
+ enabled: true,
+ tokenCount: 2,
+ recommendTokenSetup: false,
+ tokens: [
+ {
+ id: 'synthetic-private-token-a',
+ name: 'synthetic-private-purpose-a',
+ hint: 'synthetic-private-hint',
+ },
+ {
+ id: 'synthetic-private-token-b',
+ name: 'synthetic-private-purpose-b',
+ hint: 'synthetic-private-hint',
+ },
+ ],
+ usage: [
+ { tokenId: 'synthetic-private-token-b', agentCount: 1, agents: ['synthetic-private-agent'] },
+ { tokenId: 'synthetic-private-token-a', agentCount: 1, agents: ['synthetic-private-agent'] },
+ ],
+ },
+ nodeSnapshots: [
+ {
+ instance: 'synthetic-private-instance',
+ node: 'synthetic-private-node',
+ memorySource: 'agent',
+ memory: { used: 256 },
+ raw: { total: 512 },
+ },
+ ],
+ guestSnapshots: [
+ {
+ instance: 'synthetic-private-instance',
+ node: 'synthetic-private-node',
+ name: 'synthetic-private-guest',
+ vmid: 9501,
+ guestType: 'vm',
+ memorySource: 'agent',
+ memory: { used: 128 },
+ raw: { hostAgentUsed: 128 },
+ notes: [],
+ },
+ ],
+ memorySourceBreakdown: [
+ {
+ instance: 'synthetic-private-instance',
+ scope: 'node',
+ source: 'agent',
+ trust: 'host',
+ count: 1,
+ fallback: false,
+ fallbackReasons: [],
+ },
+ ],
+ metricsStore: {
+ enabled: true,
+ status: 'buffering',
+ bufferSize: 17,
+ notes: ['dial tcp 10.20.30.40 failed'],
+ },
+};
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const playwrightVersion = require('playwright/package.json').version;
+ assert.equal(
+ playwrightVersion,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const hashes = Object.fromEntries(
+ runtime.map((p) => [
+ p,
+ crypto
+ .createHash('sha256')
+ .update(fs.readFileSync(path.join('/workspace', p)))
+ .digest('hex'),
+ ]),
+ );
+ process.chdir(root);
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5293, strictPort: true },
+ });
+ const results = [],
+ screenshots = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, dark] of [
+ ['desktop', chromium, 1365, false],
+ ['phone', webkit, 390, true],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ isMobile: width === 390,
+ hasTouch: width === 390,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ acceptDownloads: true,
+ });
+ page.setDefaultTimeout(20000);
+ const checks = [],
+ requests = [],
+ errors = [],
+ offOrigin = [],
+ writes = [];
+ let mode = 'held',
+ release;
+ page.on('pageerror', (error) => errors.push(error.message));
+ await page.addInitScript((d) => {
+ if (!d) return;
+ const apply = () => document.documentElement.classList.add('dark');
+ if (document.documentElement) apply();
+ else document.addEventListener('DOMContentLoaded', apply, { once: true });
+ }, dark);
+ await page.route('**/*', async (route) => {
+ const request = route.request(),
+ url = new URL(request.url());
+ if (url.origin !== origin) {
+ offOrigin.push(url.origin);
+ return route.abort();
+ }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ requests.push({ path: url.pathname, method: request.method(), mode });
+ if (request.method() !== 'GET') {
+ writes.push(url.pathname);
+ return route.abort();
+ }
+ if (url.pathname === '/api/diagnostics') {
+ if (mode === 'held')
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ return route.fulfill(
+ mode === 'failed'
+ ? { status: 400, json: { error: 'Synthetic diagnostic connection failure' } }
+ : { json: payload },
+ );
+ }
+ if (url.pathname === '/api/security/status')
+ return route.fulfill({ json: { hasAuthentication: true, requiresAuth: false } });
+ return route.fulfill({ json: {} });
+ });
+ const check = async (label, action) => {
+ await action();
+ checks.push({ label, passed: true });
+ };
+ const snapshot = async (state) => {
+ const file = `${name}-${state}.png`;
+ // Capture settled animations, not an unreadable toast entering the viewport.
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: path.join(output, file), fullPage: false });
+ screenshots.push(file);
+ };
+ const countRuns = () => requests.filter((r) => r.path === '/api/diagnostics').length;
+ const describedBy = async (locator) => {
+ return locator.evaluate((el) =>
+ (el.getAttribute('aria-describedby') || '')
+ .split(/\s+/)
+ .map((id) => document.getElementById(id)?.textContent || '')
+ .join(' '),
+ );
+ };
+ const verifyLayout = async () => {
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
+ for (const button of await page
+ .getByRole('button', { name: /Run Diagnostics|Full \(private\)|GitHub \(review first\)/ })
+ .all()) {
+ const box = await button.boundingBox();
+ assert(box && box.x >= 0 && box.x + box.width <= width + 1);
+ if (width === 390) assert(box.height >= 44);
+ }
+ };
+ try {
+ await page.goto(`${origin}/browser-tests/diagnostics-safety.html`);
+ await page.getByRole('heading', { name: 'System Diagnostics' }).waitFor();
+ const run = page.getByRole('button', { name: 'Run Diagnostics', exact: true }).first();
+ await check(
+ 'No automatic diagnostic request; safety precedes both run controls',
+ async () => {
+ assert.equal(countRuns(), 0);
+ for (const action of await page
+ .getByRole('button', { name: 'Run Diagnostics', exact: true })
+ .all())
+ assert.match(
+ await describedBy(action),
+ /Do not run it during a backup, freeze\/thaw/,
+ );
+ assert.equal(
+ await page.getByRole('button', { name: 'Full (private)', exact: true }).count(),
+ 0,
+ );
+ await verifyLayout();
+ },
+ );
+ await snapshot('empty');
+ await check(
+ 'Keyboard triggers only one explicit request; pending run is disabled',
+ async () => {
+ await run.focus();
+ await page.keyboard.press('Enter');
+ await page.getByRole('button', { name: 'Running...', exact: true }).waitFor();
+ assert.equal(countRuns(), 1);
+ assert(
+ await page.getByRole('button', { name: 'Running...', exact: true }).isDisabled(),
+ );
+ },
+ );
+ await snapshot('pending');
+ mode = 'success';
+ release();
+ const full = page.getByRole('button', { name: 'Full (private)', exact: true });
+ const github = page.getByRole('button', { name: 'GitHub (review first)', exact: true });
+ await full.waitFor();
+ await check(
+ 'Result offers truthful local-export labels and accessible review instructions',
+ async () => {
+ for (const button of [full, github])
+ assert.match(
+ await describedBy(button),
+ /Nothing is uploaded.*review even a sanitised file/,
+ );
+ await verifyLayout();
+ },
+ );
+ const download = async (button, kind) => {
+ const ready = page.waitForEvent('download');
+ await button.click();
+ const item = await ready;
+ assert.match(
+ item.suggestedFilename(),
+ new RegExp(`^pulse-diagnostics-${kind}-\\d{4}-\\d{2}-\\d{2}\\.json$`),
+ );
+ const file = path.join(output, `${name}-${kind}.json`);
+ await item.saveAs(file);
+ return JSON.parse(fs.readFileSync(file));
+ };
+ await check(
+ 'Sanitised download redacts actual identities, preserves joins and measurements; no new request',
+ async () => {
+ const out = await download(github, 'sanitized');
+ assert.equal(countRuns(), 1);
+ assert(!JSON.stringify(out).includes('synthetic-private-'));
+ assert(!JSON.stringify(out).includes('10.20.30.40'));
+ assert.equal(out.apiTokens.usage[0].tokenId, out.apiTokens.tokens[1].id);
+ assert.equal(out.apiTokens.usage[0].agents[0], out.apiTokens.usage[1].agents[0]);
+ assert.equal(out.guestSnapshots[0].instance, out.nodeSnapshots[0].instance);
+ assert.equal(out.memorySourceBreakdown[0].instance, out.nodeSnapshots[0].instance);
+ assert.equal(out.guestSnapshots[0].vmid, undefined);
+ assert.deepEqual(out.guestSnapshots[0].memory, payload.guestSnapshots[0].memory);
+ await page
+ .getByRole('heading', {
+ name: 'Sanitised diagnostics downloaded — review before sharing',
+ exact: true,
+ })
+ .waitFor();
+ },
+ );
+ await check(
+ 'Full private download is unchanged; no diagnostic rerun or upload',
+ async () => {
+ assert.deepEqual(await download(full, 'full'), payload);
+ assert.equal(countRuns(), 1);
+ await page
+ .getByRole('heading', {
+ name: 'Full diagnostics downloaded — keep this file private',
+ exact: true,
+ })
+ .waitFor();
+ },
+ );
+ await snapshot('result');
+ await check(
+ 'Refresh disables both downloads; failed request retains the previous result',
+ async () => {
+ mode = 'held';
+ release = undefined;
+ await run.click();
+ await page.getByRole('button', { name: 'Running...', exact: true }).waitFor();
+ assert(await full.isDisabled());
+ assert(await github.isDisabled());
+ assert.equal(countRuns(), 2);
+ mode = 'failed';
+ release();
+ await page
+ .getByRole('heading', {
+ name: 'Synthetic diagnostic connection failure',
+ exact: true,
+ })
+ .waitFor();
+ assert(!(await full.isDisabled()));
+ assert(!(await github.isDisabled()));
+ assert.match(
+ await page.getByText('Version 6.4.5', { exact: true }).textContent(),
+ /6\.4\.5/,
+ );
+ assert.equal(countRuns(), 2);
+ await verifyLayout();
+ },
+ );
+ await snapshot('failed');
+ await check('Existing shipped Docs viewer opens the exact safety section', async () => {
+ const link = page.getByRole('link', {
+ name: 'Safe diagnostics and sharing',
+ exact: true,
+ });
+ assert.equal(
+ await link.getAttribute('href'),
+ '/docs/TROUBLESHOOTING#collect-diagnostics-safely',
+ );
+ await link.click();
+ await page
+ .getByRole('heading', { name: 'Collect diagnostics safely', exact: true })
+ .waitFor();
+ assert.match(page.url(), /\/docs\/TROUBLESHOOTING#collect-diagnostics-safely$/);
+ assert(
+ await page
+ .locator('article')
+ .getByText(/not an upload/)
+ .count(),
+ );
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
+ });
+ await snapshot('docs');
+ assert.deepEqual(errors, []);
+ assert.deepEqual(offOrigin, []);
+ assert.deepEqual(writes, []);
+ results.push({
+ name,
+ browser_version: browser.version(),
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ dark,
+ checks,
+ requests,
+ errors,
+ offOrigin,
+ writes,
+ });
+ } finally {
+ if (release) release();
+ await browser.close();
+ browser = null;
+ }
+ }
+ fs.writeFileSync(
+ path.join(output, 'result.json'),
+ JSON.stringify(
+ {
+ passed: true,
+ playwright_version: playwrightVersion,
+ content_sha256: hashes,
+ changed_paths: runtime,
+ results,
+ screenshots,
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ console.log(
+ JSON.stringify({
+ passed: true,
+ checks: results.reduce((n, r) => n + r.checks.length, 0),
+ screenshots: screenshots.length,
+ playwright_version: playwrightVersion,
+ browsers: results.map((r) => ({ name: r.name, version: r.browser_version })),
+ }),
+ );
+ } catch (error) {
+ fs.writeFileSync(
+ path.join(output, 'failed.json'),
+ JSON.stringify({ passed: false, error: error.message, results, screenshots }, null, 2) + '\n',
+ );
+ throw error;
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/diagnostics-safety.html b/frontend-modern/browser-tests/diagnostics-safety.html
new file mode 100644
index 0000000000..fbbe4334b9
--- /dev/null
+++ b/frontend-modern/browser-tests/diagnostics-safety.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Diagnostics safety verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/diagnostics-safety.tsx b/frontend-modern/browser-tests/diagnostics-safety.tsx
new file mode 100644
index 0000000000..30fd919de9
--- /dev/null
+++ b/frontend-modern/browser-tests/diagnostics-safety.tsx
@@ -0,0 +1,29 @@
+// Production Diagnostics, its API reader, exporter and Docs viewer.
+// The browser script supplies only synthetic diagnostic responses; no target
+// API or guest-agent check is performed by this fixture.
+import { render } from 'solid-js/web';
+import { Route, Router } from '@solidjs/router';
+import DiagnosticsPanel from '../src/components/Settings/DiagnosticsPanel';
+import { ToastContainer } from '../src/components/Toast/Toast';
+import Docs from '../src/pages/Docs';
+import '../src/index.css';
+
+function Diagnostics() {
+ return (
+
+
Settings — Diagnostics
+
+
+
+ );
+}
+
+render(
+ () => (
+
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification/b1ce78ea7a48b4a8.json b/frontend-modern/browser-verification/b1ce78ea7a48b4a8.json
new file mode 100644
index 0000000000..6facb6a537
--- /dev/null
+++ b/frontend-modern/browser-verification/b1ce78ea7a48b4a8.json
@@ -0,0 +1,60 @@
+{
+ "version": 1,
+ "result": "passed",
+ "base_sha": "1ff46c4261e11b4a8edcd573df415a217ecd34cd",
+ "content_sha256": {
+ "frontend-modern/src/components/Settings/diagnosticsModel.ts": "11972278d12ffe6a0dd9bdd540c00df98f83b4038eab0620b3871112aacae451",
+ "frontend-modern/src/components/Settings/DiagnosticsPanel.tsx": "ad3ca811d42241f6f8bb51cebb2ff90d1daaf5d360438d4f7fa8c6d947cca975",
+ "frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx": "6188a848d9dde43b91cf30ee127820e545b0f10a9dcfbd6fe90dd73a9231e6cd",
+ "frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts": "acc6a054beb5cde80e387b1b2193712c29865c05512ae767c9e9faa821f0a2f7",
+ "frontend-modern/src/utils/diagnosticsPresentation.ts": "c14464ac48b915d1a7e6a3a4b3cbd93ec6cb916ab685d8668698abfe97950bc2"
+ },
+ "changed_paths": [
+ "frontend-modern/src/components/Settings/diagnosticsModel.ts",
+ "frontend-modern/src/components/Settings/DiagnosticsPanel.tsx",
+ "frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx",
+ "frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts",
+ "frontend-modern/src/utils/diagnosticsPresentation.ts"
+ ],
+ "routes": [
+ "Existing Settings Diagnostics through production-component fixture",
+ "Existing shipped Troubleshooting Docs viewer and safety-section anchor"
+ ],
+ "states": [
+ "No result: no automatic request, visible and accessible live-check warnings",
+ "Explicit request pending: both run controls disabled",
+ "Displayed result: private full export and reviewed sanitised export",
+ "Pending refresh: export controls disabled",
+ "Failed refresh: original result retained, readable error toast",
+ "Shipped help: exact safety section, desktop and phone wrapping"
+ ],
+ "interactions": [
+ "Keyboard Enter starts one explicit diagnostic request",
+ "Local Blob JSON downloads do not rerun diagnostics or upload",
+ "Sanitised download checked for identity redaction and real token/snapshot joins",
+ "Full download checked against unchanged synthetic displayed data",
+ "Touch-sized buttons, accessible descriptions and no horizontal overflow",
+ "Safety link opens the production Docs viewer at the exact anchor"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "verified_at": "2026-10-04T09:29:23.288646Z",
+ "browsers": [
+ "Chromium 141.0.7390.37 desktop",
+ "WebKit 26.0 phone emulation; not native iOS"
+ ],
+ "command": "pulse-worker-browser frontend-modern/browser-tests/diagnostics-safety.cjs",
+ "proof": "Playwright 1.56.1 matches integration lock. Fourteen behavioral groups, ten screenshots inspected. Production components/API reader/exporter/Docs with synthetic responses and real local downloads; no native API/QGA requests, installed or release acceptance. Earlier dependency and fixture failures retained privately.",
+ "document_sha256": {
+ "docs/TROUBLESHOOTING.md": "c7e35be379e2bdcda3162f21e629b04ca474132c71c2628c851aff5bcc2ff547",
+ "frontend-modern/public/docs/TROUBLESHOOTING.md": "c7e35be379e2bdcda3162f21e629b04ca474132c71c2628c851aff5bcc2ff547"
+ }
+}
diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md
index b5e498ebb3..547b336a02 100644
--- a/frontend-modern/public/docs/TROUBLESHOOTING.md
+++ b/frontend-modern/public/docs/TROUBLESHOOTING.md
@@ -520,6 +520,32 @@ not a guide to the current menu.
## 🛠️ Advanced Diagnostics
+### Collect diagnostics safely
+
+**Run Diagnostics** in **Settings → Diagnostics** is not just a passive export.
+It can make live Proxmox/PBS API and guest-agent requests; results may also come
+from a short-lived cache. Do not run it during a backup, freeze/thaw or an
+unresponsive-host incident merely to obtain a report. Keep the original errors
+and existing observations instead. A successful one-off check does not prove
+that normal monitoring has recovered or that a guest has thawed.
+
+After a result is displayed, the download buttons reuse that result without
+running the checks again. They save a local JSON file, **not an upload**:
+
+- **Full (private)** retains identifying diagnostic details. Keep it private;
+ do not attach it to a public issue or discussion.
+- **GitHub (review first)** replaces selected infrastructure and token
+ identifiers, private filesystem paths and raw disk-response fields. Counts,
+ measurements, collection times and diagnostic states remain useful for
+ triage. It is not a guarantee that every free-text error or future field is
+ free of private information. Open the file locally and review it before sharing.
+
+Remove credentials, cookies, secret URLs, private host/network or personal
+information, including details echoed in errors or notes. Do not paste a
+**Copy as cURL** command, full network export, configuration or data directory.
+Share only evidence relevant to the symptom; a screenshot or exact redacted
+error may be enough. See [Getting Help](#-getting-help).
+
### Inspect Notification Logs
Prefer **Recent delivery activity** in **Alerts → Notifications**. If a local log
@@ -684,8 +710,9 @@ If you're still stuck:
running image tag or digest. If installation never started Pulse, give the
attempted release and public installer/helper source, or say "unknown".
3. **Choose relevant, safe evidence**: if Pulse is running and collection is
- safe, use **Settings → Diagnostics → Export for GitHub (sanitized)** for
- connection or data failures. For a visual problem, a screenshot or the exact
+ safe, use **Settings → Diagnostics → GitHub (review first)** for
+ connection or data failures, following [safe diagnostics collection](#collect-diagnostics-safely).
+ For a visual problem, a screenshot or the exact
error may be enough. If logs are needed, inspect a bounded local excerpt
(`journalctl -u pulse -n 100 --no-pager` or `docker logs --tail 100 pulse`),
not a full configuration or data-directory upload.
diff --git a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
index f41422dce6..5a64dcb9ec 100644
--- a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
+++ b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
@@ -1,4 +1,4 @@
-import { Component, Show } from 'solid-js';
+import { Component, Show, createUniqueId } from 'solid-js';
import OperationsPanel from '@/components/Settings/OperationsPanel';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import Download from 'lucide-solid/icons/download';
@@ -6,10 +6,13 @@ import { DiagnosticsResultsPanel } from '@/components/Settings/DiagnosticsResult
import { useDiagnosticsPanelState } from '@/components/Settings/useDiagnosticsPanelState';
import { formatUptime } from '@/components/Settings/diagnosticsModel';
import { DIAGNOSTICS_PANEL_COPY } from '@/utils/diagnosticsPresentation';
+import { TROUBLESHOOTING_DOC_URL } from '@/utils/docsLinks';
export const DiagnosticsPanel: Component = () => {
const { diagnosticsData, exportDiagnostics, exportLoading, loading, runDiagnostics } =
useDiagnosticsPanelState();
+ const runSafetyId = createUniqueId();
+ const exportSafetyId = createUniqueId();
return (