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 (
@@ -32,6 +35,12 @@ export const DiagnosticsPanel: Component = () => { type="button" onClick={runDiagnostics} disabled={loading()} + aria-label={ + loading() + ? DIAGNOSTICS_PANEL_COPY.runningActionLabel + : DIAGNOSTICS_PANEL_COPY.runActionLabel + } + aria-describedby={runSafetyId} class="flex min-h-11 sm:min-h-9 min-w-11 sm:min-w-10 items-center gap-2 px-3 sm:px-4 py-2.5 rounded-md font-medium text-sm transition-colors whitespace-nowrap bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:bg-surface disabled:text-muted" > @@ -49,6 +58,16 @@ export const DiagnosticsPanel: Component = () => {
} > +
+

{DIAGNOSTICS_PANEL_COPY.runSafety}

+

{DIAGNOSTICS_PANEL_COPY.exportSafety}

+ + Safe diagnostics and sharing + +

{DIAGNOSTICS_PANEL_COPY.summary}

@@ -56,7 +75,8 @@ export const DiagnosticsPanel: Component = () => {
); diff --git a/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx index 940fd848f0..3397f0596d 100644 --- a/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx +++ b/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx @@ -68,6 +68,7 @@ interface DiagnosticsResultsPanelProps { diagnosticsData: DiagnosticsData | null; loading: boolean; onRunDiagnostics: () => void; + runDescriptionId?: string; } export const DiagnosticsResultsPanel: Component = (props) => { @@ -89,6 +90,7 @@ export const DiagnosticsResultsPanel: Component = class="gap-2" onClick={props.onRunDiagnostics} disabled={props.loading} + aria-describedby={props.runDescriptionId} > {DIAGNOSTICS_EMPTY_STATE_COPY.actionLabel} diff --git a/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx index 66c7bc360a..76bea92ccd 100644 --- a/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx @@ -1,6 +1,6 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { Route, Router } from '@solidjs/router'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DiagnosticsResultsPanel } from '@/components/Settings/DiagnosticsResultsPanel'; import type { DiagnosticsData } from '@/components/Settings/diagnosticsModel'; @@ -9,6 +9,26 @@ describe('DiagnosticsResultsPanel', () => { cleanup(); }); + it('keeps the empty-state action linked to its live-check safety explanation', () => { + const run = vi.fn(); + render(() => ( + <> +

Do not run during a backup or freeze/thaw.

+ + + )); + const action = screen.getByRole('button', { name: 'Run Diagnostics' }); + expect(action).toHaveAccessibleDescription('Do not run during a backup or freeze/thaw.'); + expect(run).not.toHaveBeenCalled(); + fireEvent.click(action); + expect(run).toHaveBeenCalledOnce(); + }); + it('distinguishes live heap, retained runtime memory, and process RSS', () => { const diagnosticsData = { version: '6.4.1', diff --git a/frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts b/frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts new file mode 100644 index 0000000000..bec6c0b478 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import { + sanitizeDiagnosticsData, + stripInternalAnalyticsDiagnosticsFields, + type DiagnosticsData, +} from '../diagnosticsModel'; + +type ExportData = DiagnosticsData & Record; +// Synthetic fields use the CURRENT server JSON names, not a second exporter +// schema. No real report, credential, host or customer data enters this fixture. +const currentPayload = (): ExportData => ({ + version: '6.4.5', + runtime: 'go', + uptime: 300, + nodes: [ + { + id: 'private-connection-a', + name: 'private-pve-a', + host: '10.20.30.40', + type: 'pve', + authMethod: 'token', + connected: false, + error: 'dial tcp 10.20.30.40:8006 failed', + vmDiskCheck: { + vmsFound: 2, + vmsWithAgent: 1, + vmsWithDiskData: 0, + testVMID: 9051, + testVMName: 'private-guest-a', + testResult: 'Guest agent request timed out', + permissions: ['VM.GuestAgent.Audit'], + recommendations: ['Read 10.20.30.40 locally'], + problematicVMs: [ + { + vmid: 9051, + name: 'private-guest-a', + status: 'running', + issue: 'Guest agent unavailable', + }, + ], + filesystemsFound: [ + { + mountpoint: '/private-team/data', + type: 'ext4', + total: 4096, + used: 2048, + filtered: false, + }, + ], + }, + physicalDisks: { + nodesChecked: 2, + totalDisks: 1, + nodeResults: [ + { + nodeName: 'private-host-a', + diskCount: 1, + diskDevices: ['/dev/disk/by-id/private-disk-a'], + apiResponse: 'private upstream body', + }, + { + nodeName: 'private-host-b', + diskCount: 0, + diskDevices: [], + apiResponse: 'Permission denied', + }, + ], + }, + } as DiagnosticsData['nodes'][number], + ], + pbs: [ + { + id: 'private-pbs-id', + name: 'private-pbs-a', + host: '10.20.30.41', + connected: false, + lastError: { kind: 'network', message: 'dial tcp 10.20.30.41:8007 failed' }, + probe: { connected: false, errorKind: 'network' }, + } as DiagnosticsData['pbs'][number], + ], + system: { + os: 'linux', + arch: 'amd64', + goVersion: 'go1.26', + numCPU: 4, + numGoroutine: 30, + memoryMB: 128, + }, + apiTokens: { + enabled: true, + tokenCount: 2, + recommendTokenSetup: false, + tokens: [ + { id: 'private-token-a', name: 'private-purpose-a', hint: 'private-hint-a' }, + { id: 'private-token-b', name: 'private-purpose-b', hint: 'private-hint-b' }, + ], + usage: [ + { tokenId: 'private-token-b', agentCount: 2, agents: ['private-agent-b', 'private-agent-a'] }, + { tokenId: 'private-token-a', agentCount: 1, agents: ['private-agent-a'] }, + ], + } as DiagnosticsData['apiTokens'], + dockerAgents: { + agentsTotal: 2, + agentsOnline: 1, + agentsReportingVersion: 2, + agentsWithTokenBinding: 2, + agentsWithoutTokenBinding: 0, + agentsNeedingAttention: 1, + attention: [ + { + agentId: 'private-agent-id-a', + name: 'private-agent-a', + tokenHint: 'private-hint-a', + status: 'offline', + issues: ['dial tcp 10.20.30.42 failed'], + }, + ], + } as DiagnosticsData['dockerAgents'], + nodeSnapshots: [ + { + instance: 'private-instance-b', + node: 'private-host-b', + memorySource: 'agent', + memory: { used: 512 }, + raw: { total: 1024 }, + }, + { + instance: 'private-instance-a', + node: 'private-host-a', + memorySource: 'proxmox', + memory: { used: 2048 }, + raw: { total: 4096 }, + }, + ], + guestSnapshots: [ + { + instance: 'private-instance-a', + node: 'private-host-a', + guestType: 'vm', + vmid: 9051, + name: 'private-guest-a', + memorySource: 'agent', + memory: { used: 256 }, + raw: { hostAgentUsed: 256 }, + notes: [], + }, + ], + memorySources: [{ instance: 'private-instance-a', source: 'proxmox', nodeCount: 1 }], + memorySourceBreakdown: [ + { + instance: 'private-instance-b', + scope: 'node', + source: 'agent', + trust: 'guest', + count: 1, + fallback: false, + fallbackReasons: [], + }, + ], + metricsStore: { + enabled: true, + status: 'buffering', + bufferSize: 17, + notes: ['Read 10.20.30.43 locally'], + }, + errors: [], +}); + +describe('current diagnostics export privacy', () => { + it('anonymises actual tokenId/agents fields and preserves token and agent joins across reordered arrays', () => { + const out = sanitizeDiagnosticsData(currentPayload()) as ExportData; + expect(out.apiTokens.usage[0].tokenId).toBe(out.apiTokens.tokens[1].id); + expect(out.apiTokens.usage[1].tokenId).toBe(out.apiTokens.tokens[0].id); + expect(out.apiTokens.usage[0].agents[1]).toBe(out.apiTokens.usage[1].agents[0]); + expect(out.apiTokens.usage[1].agents[0]).toBe(out.dockerAgents.attention[0].name); + expect(out.apiTokens.usage[0].agentCount).toBe(2); + expect(JSON.stringify(out.apiTokens)).not.toMatch(/private-/); + }); + + it('redacts node/guest identities and the actual memorySourceBreakdown without inventing position-based joins', () => { + const out = sanitizeDiagnosticsData(currentPayload()) as ExportData; + expect(out.guestSnapshots[0].instance).toBe(out.nodeSnapshots[1].instance); + expect(out.memorySources[0].instance).toBe(out.nodeSnapshots[1].instance); + expect(out.memorySourceBreakdown[0].instance).toBe(out.nodeSnapshots[0].instance); + expect(out.guestSnapshots[0].node).toBe(out.nodeSnapshots[1].node); + expect(out.guestSnapshots[0]).not.toHaveProperty('vmid'); + expect(out.guestSnapshots[0].memory).toEqual({ used: 256 }); + expect(out.guestSnapshots[0].raw).toEqual({ hostAgentUsed: 256 }); + expect(out.memorySourceBreakdown[0]).toMatchObject({ + count: 1, + source: 'agent', + fallback: false, + trust: 'guest', + }); + for (const key of ['nodeSnapshots', 'guestSnapshots', 'memorySources', 'memorySourceBreakdown']) + expect(JSON.stringify(out[key])).not.toMatch(/private-/); + }); + + it('removes nested guest IDs/names and mount paths while retaining the observed disk failure and measurements', () => { + const out = sanitizeDiagnosticsData(currentPayload()) as ExportData; + const check = out.nodes[0].vmDiskCheck; + expect(check).not.toHaveProperty('testVMID'); + expect(check.problematicVMs[0]).not.toHaveProperty('vmid'); + expect(JSON.stringify(check)).not.toMatch(/private-/); + expect(check).toMatchObject({ + vmsFound: 2, + vmsWithAgent: 1, + vmsWithDiskData: 0, + testResult: 'Guest agent request timed out', + }); + expect(check.permissions).toEqual(['VM.GuestAgent.Audit']); + expect(check.filesystemsFound[0]).toMatchObject({ + total: 4096, + used: 2048, + type: 'ext4', + filtered: false, + }); + }); + + it('redacts private disk paths and unknown response bodies but keeps known failure categories', () => { + const out = sanitizeDiagnosticsData(currentPayload()) as ExportData; + const disks = out.nodes[0].physicalDisks; + expect(JSON.stringify(disks)).not.toMatch(/private/); + expect(disks).toMatchObject({ nodesChecked: 2, totalDisks: 1 }); + expect(disks.nodeResults[0]).toMatchObject({ diskCount: 1, apiResponse: '[REDACTED]' }); + expect(disks.nodeResults[1].apiResponse).toBe('Permission denied'); + }); + + it('redacts IPs in nested errors/notes without erasing state or failure kinds', () => { + const out = sanitizeDiagnosticsData(currentPayload()) as ExportData; + expect(JSON.stringify(out)).not.toMatch(/10\.20\.30\./); + expect(out.pbs[0].lastError).toEqual({ + kind: 'network', + message: 'dial tcp [REDACTED_IP]:8007 failed', + }); + expect(out.pbs[0].probe).toEqual({ connected: false, errorKind: 'network' }); + expect(out.metricsStore).toMatchObject({ status: 'buffering', bufferSize: 17 }); + expect(out.dockerAgents.attention[0].issues).toEqual(['dial tcp [REDACTED_IP] failed']); + }); + + it('keeps the full download and on-screen source unchanged and scopes aliases to each download', () => { + const raw = currentPayload(); + const before = JSON.stringify(raw); + const out = sanitizeDiagnosticsData(raw); + expect(JSON.stringify(raw)).toBe(before); + expect(stripInternalAnalyticsDiagnosticsFields(raw)).toEqual(raw); + expect(sanitizeDiagnosticsData(raw)).toEqual(out); + expect(raw.guestSnapshots[0].vmid).toBe(9051); + expect(raw.apiTokens.usage[0].tokenId).toBe('private-token-b'); + }); + + it('retains useful arbitrary prose rather than claiming complete automatic privacy', () => { + const raw = currentPayload(); + raw.errors = ['DNS problem; review this free text before sharing']; + expect(sanitizeDiagnosticsData(raw).errors).toEqual(raw.errors); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.branchcov2.test.ts index 41dd142b82..27adc78427 100644 --- a/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.branchcov2.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.branchcov2.test.ts @@ -326,7 +326,7 @@ describe('sanitizeDiagnosticsData / apiTokens', () => { }); }); - it('redacts token hint/name/id by position and nulls usage hosts (both Array guards true)', () => { + it('redacts token hint/name/id and keeps anonymous usage joins (both Array guards true)', () => { const sanitized = sanitizeDiagnosticsData({ ...baseData(), apiTokens: { @@ -350,8 +350,8 @@ describe('sanitizeDiagnosticsData / apiTokens', () => { { id: 'token-2', name: 'token-2', hint: '[REDACTED]' }, ]); expect(apiTokens.usage).toStrictEqual([ - { tokenId: 'tok-aaa', hosts: undefined, count: 3 }, - { tokenId: 'tok-bbb', hosts: undefined, count: 0 }, + { tokenId: 'token-1', hosts: undefined, count: 3 }, + { tokenId: 'token-2', hosts: undefined, count: 0 }, ]); }); }); @@ -553,8 +553,8 @@ describe('sanitizeDiagnosticsData / snapshot arrays', () => { const out = sanitized as unknown as DiagnosticsDataWithSnapshots; expect(out.nodeSnapshots).toStrictEqual([ - { instance: 'node-1', cpu: 0.4 }, - { instance: 'node-2', mem: 8192 }, + { instance: 'instance-1', cpu: 0.4 }, + { instance: 'instance-2', mem: 8192 }, ]); }); @@ -565,7 +565,7 @@ describe('sanitizeDiagnosticsData / snapshot arrays', () => { } as DiagnosticsDataWithSnapshots); const out = sanitized as unknown as DiagnosticsDataWithSnapshots; - expect(out.guestSnapshots).toStrictEqual([{ instance: 'node-1', status: 'running' }]); + expect(out.guestSnapshots).toStrictEqual([{ instance: 'instance-1', status: 'running' }]); }); it('stamps an instance placeholder on every memorySources entry', () => { @@ -575,7 +575,7 @@ describe('sanitizeDiagnosticsData / snapshot arrays', () => { } as DiagnosticsDataWithSnapshots); const out = sanitized as unknown as DiagnosticsDataWithSnapshots; - expect(out.memorySources).toStrictEqual([{ instance: 'node-1', source: 'balloon' }]); + expect(out.memorySources).toStrictEqual([{ instance: 'instance-1', source: 'balloon' }]); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index f09e623ab2..08657be631 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -2077,6 +2077,25 @@ describe('settings architecture guardrails', () => { expect(brandingSettingsCardSource).toContain('BRAND_LOGO_MAX_BYTES = 36 * 1024'); }); + it('keeps actual token usage names and IDs inside the same export privacy boundary', () => { + const payload = { + apiTokens: { + enabled: true, + tokenCount: 1, + recommendTokenSetup: false, + tokens: [{ id: 'private-token', name: 'private-purpose' }], + usage: [{ tokenId: 'private-token', agents: ['private-agent'], agentCount: 1 }], + }, + } as unknown as DiagnosticsData; + const sanitized = sanitizeDiagnosticsData(payload); + expect(JSON.stringify(sanitized)).not.toContain('private-'); + expect(sanitized.apiTokens).toMatchObject({ + tokenCount: 1, + usage: [{ tokenId: 'token-1', agents: ['docker-host-1'], agentCount: 1 }], + }); + expect(JSON.stringify(payload)).toContain('private-agent'); + }); + it('keeps every PBS diagnostic failure string inside the export redaction boundary', () => { // Behavioural guard, not a source scan: the redaction gap that shipped was // a field added to the payload after the sanitizer was written, which no diff --git a/frontend-modern/src/components/Settings/__tests__/useDiagnosticsPanelState.test.ts b/frontend-modern/src/components/Settings/__tests__/useDiagnosticsPanelState.test.ts index dd01e96900..40fc1bd052 100644 --- a/frontend-modern/src/components/Settings/__tests__/useDiagnosticsPanelState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useDiagnosticsPanelState.test.ts @@ -160,7 +160,9 @@ describe('useDiagnosticsPanelState', () => { expect(anchorClickMock).toHaveBeenCalledOnce(); expect(createdAnchor?.download).toBe(`pulse-diagnostics-full-${currentExportDate()}.json`); expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:diagnostics-export'); - expect(showSuccessMock).toHaveBeenCalledWith('Diagnostics exported (full)'); + expect(showSuccessMock).toHaveBeenCalledWith( + 'Full diagnostics downloaded — keep this file private', + ); const payload = JSON.parse( await readBlobText(createObjectURLMock.mock.calls[0][0] as Blob), @@ -183,7 +185,9 @@ describe('useDiagnosticsPanelState', () => { expect(anchorClickMock).toHaveBeenCalledOnce(); expect(createdAnchor?.download).toBe(`pulse-diagnostics-sanitized-${currentExportDate()}.json`); - expect(showSuccessMock).toHaveBeenCalledWith('Diagnostics exported (sanitized)'); + expect(showSuccessMock).toHaveBeenCalledWith( + 'Sanitised diagnostics downloaded — review before sharing', + ); const payload = JSON.parse( await readBlobText(createObjectURLMock.mock.calls[0][0] as Blob), diff --git a/frontend-modern/src/components/Settings/diagnosticsModel.ts b/frontend-modern/src/components/Settings/diagnosticsModel.ts index b66bf7e0b8..f514570ccc 100644 --- a/frontend-modern/src/components/Settings/diagnosticsModel.ts +++ b/frontend-modern/src/components/Settings/diagnosticsModel.ts @@ -180,6 +180,22 @@ export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData { const data = stripInternalAnalyticsDiagnosticsFields(raw); const ipv4Re = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?\b/g; const redactString = (value: string): string => value.replace(ipv4Re, '[REDACTED_IP]'); + // Aliases belong to this download, not to array positions. In particular, + // token usage and memory breakdowns must join the same anonymised identity + // as the corresponding summary even when their arrays have different orders. + const aliases = (prefix: string) => { + const values = new Map(); + return (value: unknown): string => { + if (typeof value !== 'string' || !value) return '[REDACTED]'; + if (!values.has(value)) values.set(value, `${prefix}-${values.size + 1}`); + return values.get(value)!; + }; + }; + const tokenAlias = aliases('token'); + const agentAlias = aliases('docker-host'); + const instanceAlias = aliases('instance'); + const nodeAlias = aliases('host'); + const guestAlias = aliases('guest'); if (Array.isArray(data.nodes)) { data.nodes = data.nodes.map((node, index) => ({ @@ -246,7 +262,7 @@ export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData { apiTokens.tokens = apiTokens.tokens.map((token, index) => ({ ...token, hint: '[REDACTED]', - id: `token-${index + 1}`, + id: tokenAlias(token.id ?? `missing-token-${index}`), name: `token-${index + 1}`, })); } @@ -254,6 +270,10 @@ export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData { if (Array.isArray(apiTokens.usage)) { apiTokens.usage = apiTokens.usage.map((usage) => ({ ...usage, + tokenId: tokenAlias(usage.tokenId), + // The server emits agents, not hosts. Retain the count and joins, + // never the configured agent names or the original token ID. + ...(Array.isArray(usage.agents) ? { agents: usage.agents.map(agentAlias) } : {}), hosts: undefined, })); } @@ -268,7 +288,7 @@ export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData { dockerAgents.attention = dockerAgents.attention.map((attention, index) => ({ ...attention, agentId: `docker-host-${index + 1}`, - name: `docker-host-${index + 1}`, + name: agentAlias(attention.name ?? `missing-agent-${index}`), tokenHint: attention.tokenHint ? '[REDACTED]' : undefined, })); } @@ -296,30 +316,118 @@ export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData { nodeSnapshots?: Array>; guestSnapshots?: Array>; memorySources?: Array>; + memorySourceBreakdown?: Array>; }; if (Array.isArray(rawSnapshotData.nodeSnapshots)) { - rawSnapshotData.nodeSnapshots = rawSnapshotData.nodeSnapshots.map((snapshot, index) => ({ + rawSnapshotData.nodeSnapshots = rawSnapshotData.nodeSnapshots.map((snapshot) => ({ ...snapshot, - instance: `node-${index + 1}`, + instance: instanceAlias(snapshot.instance), + ...(snapshot.node !== undefined ? { node: nodeAlias(snapshot.node) } : {}), })); } if (Array.isArray(rawSnapshotData.guestSnapshots)) { - rawSnapshotData.guestSnapshots = rawSnapshotData.guestSnapshots.map((snapshot, index) => ({ - ...snapshot, - instance: `node-${index + 1}`, - })); + rawSnapshotData.guestSnapshots = rawSnapshotData.guestSnapshots.map((snapshot) => { + const { vmid, ...rest } = snapshot; + return { + ...rest, + instance: instanceAlias(snapshot.instance), + ...(snapshot.node !== undefined ? { node: nodeAlias(snapshot.node) } : {}), + ...(snapshot.name !== undefined + ? { + name: guestAlias( + JSON.stringify([snapshot.instance, snapshot.node, vmid, snapshot.name]), + ), + } + : {}), + }; + }); } if (Array.isArray(rawSnapshotData.memorySources)) { - rawSnapshotData.memorySources = rawSnapshotData.memorySources.map((snapshot, index) => ({ + rawSnapshotData.memorySources = rawSnapshotData.memorySources.map((snapshot) => ({ ...snapshot, - instance: `node-${index + 1}`, + instance: instanceAlias(snapshot.instance), })); } - return data; + if (Array.isArray(rawSnapshotData.memorySourceBreakdown)) { + rawSnapshotData.memorySourceBreakdown = rawSnapshotData.memorySourceBreakdown.map( + (snapshot) => ({ + ...snapshot, + instance: instanceAlias(snapshot.instance), + }), + ); + } + + // These nested PVE results were added after the original export redactor. + // Keep status, permissions and measurements, but not guest names/IDs, + // private mount paths, disk paths or raw upstream response bodies. + for (const node of Array.isArray(data.nodes) ? data.nodes : []) { + const nested = node as DiagnosticsNode & { + vmDiskCheck?: Record; + physicalDisks?: Record; + }; + if (nested.vmDiskCheck) { + const check = nested.vmDiskCheck; + delete check.testVMID; + if (check.testVMName !== undefined) check.testVMName = '[REDACTED]'; + if (Array.isArray(check.problematicVMs)) { + check.problematicVMs = check.problematicVMs.map( + ({ vmid: _vmid, name: _name, ...issue }) => ({ + ...issue, + name: '[REDACTED]', + }), + ); + } + if (Array.isArray(check.filesystemsFound)) { + check.filesystemsFound = check.filesystemsFound.map((filesystem) => ({ + ...filesystem, + mountpoint: '[REDACTED_PATH]', + })); + } + } + if (nested.physicalDisks && Array.isArray(nested.physicalDisks.nodeResults)) { + nested.physicalDisks.nodeResults = nested.physicalDisks.nodeResults.map((result) => ({ + ...result, + nodeName: nodeAlias(result.nodeName), + ...(Array.isArray(result.diskDevices) + ? { diskDevices: result.diskDevices.map(() => '[REDACTED_PATH]') } + : {}), + ...(result.apiResponse + ? { + apiResponse: [ + 'Permission denied', + 'Timeout', + 'Endpoint not available', + 'API error', + 'Empty response (no traditional disks found)', + ].includes(result.apiResponse) + ? result.apiResponse + : '[REDACTED]', + } + : {}), + })); + } + } + + // Apply the existing IP redaction to every nested prose field, including + // lastError, notes and recommendations. This is deliberately not a promise + // that arbitrary error text or future fields are safe to publish: the UI + // and help require a manual privacy review before sharing any download. + const redactNestedText = (value: unknown): unknown => { + if (typeof value === 'string') return redactString(value); + if (Array.isArray(value)) return value.map(redactNestedText); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, field]) => [key, redactNestedText(field)]), + ); + } + return value; + }; + + return redactNestedText(data) as DiagnosticsData; } export function buildDiagnosticsExportFilename(sanitize: boolean, now = new Date()): string { diff --git a/frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts b/frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts index a51566ea44..d60642c8f0 100644 --- a/frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts +++ b/frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts @@ -1,6 +1,7 @@ import { createSignal } from 'solid-js'; import { apiFetchJSON } from '@/utils/apiClient'; import { showError, showSuccess } from '@/utils/toast'; +import { DIAGNOSTICS_PANEL_COPY } from '@/utils/diagnosticsPresentation'; import { buildDiagnosticsExportFilename, sanitizeDiagnosticsData, @@ -47,7 +48,11 @@ export const useDiagnosticsPanelState = () => { anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); - showSuccess(`Diagnostics exported (${sanitize ? 'sanitized' : 'full'})`); + showSuccess( + sanitize + ? DIAGNOSTICS_PANEL_COPY.exportGithubSuccess + : DIAGNOSTICS_PANEL_COPY.exportFullSuccess, + ); } finally { setExportLoading(false); } diff --git a/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts b/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts index c76b182474..5fbb4489ff 100644 --- a/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts @@ -7,25 +7,31 @@ import { describe('diagnosticsPresentation', () => { it('exports canonical diagnostics panel framing copy', () => { - expect(DIAGNOSTICS_PANEL_COPY).toEqual({ + expect(DIAGNOSTICS_PANEL_COPY).toMatchObject({ title: 'System Diagnostics', description: 'Review connection health, configuration status, and troubleshooting tools.', - summary: 'Test all connections and inspect runtime configuration.', + summary: 'Collect runtime information and check configured Proxmox and PBS connections.', runActionLabel: 'Run Diagnostics', runShortLabel: 'Run', runningActionLabel: 'Running...', - exportFullLabel: 'Full', - exportGithubLabel: 'GitHub', + exportFullLabel: 'Full (private)', + exportGithubLabel: 'GitHub (review first)', versionLabel: 'Version', uptimeLabel: 'Uptime', recommendedVersionLabel: 'Recommended version', }); + expect(DIAGNOSTICS_PANEL_COPY.runSafety).toContain('live API and guest-agent requests'); + expect(DIAGNOSTICS_PANEL_COPY.runSafety).toContain('backup, freeze/thaw'); + expect(DIAGNOSTICS_PANEL_COPY.runSafety).toContain('does not prove ongoing collection'); + expect(DIAGNOSTICS_PANEL_COPY.exportSafety).toContain('Nothing is uploaded'); + expect(DIAGNOSTICS_PANEL_COPY.exportSafety).toContain('review even a sanitised file'); }); it('exports canonical diagnostics empty-state copy', () => { expect(DIAGNOSTICS_EMPTY_STATE_COPY).toEqual({ title: 'No diagnostics data available', - description: 'Run diagnostics to test connections and inspect system status.', + description: + 'Collect diagnostics only when live checks are safe. Keep existing evidence if the host is unresponsive or a backup is running.', actionLabel: 'Run Diagnostics', }); expect(DIAGNOSTICS_EMPTY_PBS_MESSAGE).toBe('No Proxmox Backup Server instances configured.'); diff --git a/frontend-modern/src/utils/diagnosticsPresentation.ts b/frontend-modern/src/utils/diagnosticsPresentation.ts index 6cbb2ebe88..abe0a29f3f 100644 --- a/frontend-modern/src/utils/diagnosticsPresentation.ts +++ b/frontend-modern/src/utils/diagnosticsPresentation.ts @@ -1,12 +1,18 @@ export const DIAGNOSTICS_PANEL_COPY = { title: 'System Diagnostics', description: 'Review connection health, configuration status, and troubleshooting tools.', - summary: 'Test all connections and inspect runtime configuration.', + summary: 'Collect runtime information and check configured Proxmox and PBS connections.', + runSafety: + 'Diagnostics can make live API and guest-agent requests. Do not run it during a backup, freeze/thaw or an unresponsive-host incident; keep the existing evidence instead. A successful check does not prove ongoing collection has recovered.', + exportSafety: + 'Downloads use the displayed result without running diagnostics again. Nothing is uploaded. Keep the full file private; review even a sanitised file for credentials, secret URLs and private host or personal information before sharing.', runActionLabel: 'Run Diagnostics', runShortLabel: 'Run', runningActionLabel: 'Running...', - exportFullLabel: 'Full', - exportGithubLabel: 'GitHub', + exportFullLabel: 'Full (private)', + exportGithubLabel: 'GitHub (review first)', + exportFullSuccess: 'Full diagnostics downloaded — keep this file private', + exportGithubSuccess: 'Sanitised diagnostics downloaded — review before sharing', versionLabel: 'Version', uptimeLabel: 'Uptime', recommendedVersionLabel: 'Recommended version', @@ -14,7 +20,8 @@ export const DIAGNOSTICS_PANEL_COPY = { export const DIAGNOSTICS_EMPTY_STATE_COPY = { title: 'No diagnostics data available', - description: 'Run diagnostics to test connections and inspect system status.', + description: + 'Collect diagnostics only when live checks are safe. Keep existing evidence if the host is unresponsive or a backup is running.', actionLabel: 'Run Diagnostics', } as const;