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 1/5] 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; From 006b460dfb62c175dafa5dbf6f81b947bdd5467f 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:34:08 +0100 Subject: [PATCH 2/5] test(diagnostics): bind shipped safety guidance to current controls Replace the obsolete help-label assertion and verify that the shipped collection guidance distinguishes live checks, private local downloads and manual sharing review. Change-source: pulse-maintainer --- .../src/utils/__tests__/docsLinks.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 2ef6600add..3532d0b933 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -21,6 +21,7 @@ import apiTokenManagerModelSource from '@/components/Settings/apiTokenManagerMod import securityOverviewPanelSource from '@/components/Settings/SecurityOverviewPanel.tsx?raw'; import selfHostedCommercialRecoverySectionSource from '@/components/Settings/SelfHostedCommercialRecoverySection.tsx?raw'; import securityWarningSource from '@/components/SecurityWarning.tsx?raw'; +import { DIAGNOSTICS_PANEL_COPY } from '@/utils/diagnosticsPresentation'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -419,12 +420,32 @@ describe('docsLinks', () => { const help = troubleshooting.split('## 🆘 Getting Help')[1]; expect(help).toContain('Do not repeat an update, outage or notification storm'); - expect(help).toContain('Export for GitHub (sanitized)'); + expect(help).toContain(DIAGNOSTICS_PANEL_COPY.exportGithubLabel); + expect(help).toContain('#collect-diagnostics-safely'); expect(help).toContain('Review before posting'); expect(help).toContain('session cookies, webhook URLs'); expect(help).toContain('If installation never started Pulse'); }); + it('separates live diagnostics from private local downloads and manual sharing review', () => { + const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8'); + const collection = troubleshooting + .split('### Collect diagnostics safely')[1] + .split('\n### ')[0] + .replace(/\s+/g, ' '); + expect(collection).toContain('live Proxmox/PBS API and guest-agent requests'); + expect(collection).toContain('Do not run it during a backup, freeze/thaw'); + expect(collection).toContain('unresponsive-host incident'); + expect(collection).toContain('does not prove that normal monitoring has recovered'); + expect(collection).toContain('without running the checks again'); + expect(collection).toContain('not an upload'); + expect(collection).toContain(DIAGNOSTICS_PANEL_COPY.exportFullLabel); + expect(collection).toContain(DIAGNOSTICS_PANEL_COPY.exportGithubLabel); + expect(collection).toContain('not a guarantee'); + expect(collection).toContain('review it before sharing'); + expect(collection).toContain('Do not paste a **Copy as cURL** command'); + }); + it('keeps port troubleshooting specific without exposing deployment credentials', () => { const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8'); const ports = troubleshooting.split("### Port change didn't take effect")[1].split('\n### ')[0]; From d78aabaf3959aa1e7b90b94543a6a211b1062c24 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:41:03 +0100 Subject: [PATCH 3/5] test(diagnostics): type the current export privacy fixture Model the current server token usage and attention fields explicitly so the privacy regression fixture participates in the strict frontend type check rather than relying on the smaller presentation-only interface. Change-source: pulse-maintainer --- .../diagnosticsExportPrivacy.test.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts b/frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts index bec6c0b478..f14fef3c7e 100644 --- a/frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/diagnosticsExportPrivacy.test.ts @@ -5,7 +5,24 @@ import { type DiagnosticsData, } from '../diagnosticsModel'; -type ExportData = DiagnosticsData & Record; +type ExportData = Omit & + Record & { + nodes: Array>; + pbs: Array; + apiTokens: NonNullable & { + tokens: Array<{ id: string; name: string; hint: string }>; + usage: Array<{ tokenId: string; agentCount: number; agents: string[] }>; + }; + dockerAgents: NonNullable & { + attention: Array<{ + agentId: string; + name: string; + tokenHint: string; + status: string; + issues: string[]; + }>; + }; + }; // 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 => ({ @@ -66,7 +83,7 @@ const currentPayload = (): ExportData => ({ }, ], }, - } as DiagnosticsData['nodes'][number], + }, ], pbs: [ { @@ -76,7 +93,7 @@ const currentPayload = (): ExportData => ({ 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', @@ -98,7 +115,7 @@ const currentPayload = (): ExportData => ({ { 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, @@ -115,7 +132,7 @@ const currentPayload = (): ExportData => ({ issues: ['dial tcp 10.20.30.42 failed'], }, ], - } as DiagnosticsData['dockerAgents'], + }, nodeSnapshots: [ { instance: 'private-instance-b', From 85e22d2c1276e202f8475fb237bf5d24e312f1fe 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:50:13 +0100 Subject: [PATCH 4/5] fix(diagnostics): keep safety guidance in plain sentences Use sentence boundaries without changing the collection, privacy or sharing instructions. Keep the existing copy-style audit, connected tests and final browser receipt in agreement. Contract-Neutral: Sentence punctuation only in existing Diagnostics safety copy; collection and export contracts are unchanged. Change-source: pulse-maintainer --- .../browser-tests/diagnostics-safety.cjs | 2 +- .../7d51b51f05acd3b8.json | 59 +++++++++++++++++++ .../__tests__/diagnosticsPresentation.test.ts | 4 +- .../src/utils/diagnosticsPresentation.ts | 4 +- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 frontend-modern/browser-verification/7d51b51f05acd3b8.json diff --git a/frontend-modern/browser-tests/diagnostics-safety.cjs b/frontend-modern/browser-tests/diagnostics-safety.cjs index f85ba89ab8..eb1007b09b 100644 --- a/frontend-modern/browser-tests/diagnostics-safety.cjs +++ b/frontend-modern/browser-tests/diagnostics-safety.cjs @@ -259,7 +259,7 @@ const payload = { for (const button of [full, github]) assert.match( await describedBy(button), - /Nothing is uploaded.*review even a sanitised file/, + /Nothing is uploaded.*review even a sanitised file/i, ); await verifyLayout(); }, diff --git a/frontend-modern/browser-verification/7d51b51f05acd3b8.json b/frontend-modern/browser-verification/7d51b51f05acd3b8.json new file mode 100644 index 0000000000..ee85dae7ba --- /dev/null +++ b/frontend-modern/browser-verification/7d51b51f05acd3b8.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "d78aabaf3959aa1e7b90b94543a6a211b1062c24", + "content_sha256": { + "frontend-modern/src/utils/diagnosticsPresentation.ts": "6956ea3a37927309d840cf076a178f84e25726639316f04158c64f37e80cde9f" + }, + "changed_paths": [ + "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:50:07.646466Z", + "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": "Fresh complete production-component/browser pass after punctuation repair. Playwright 1.56.1 matches the integration lock; fourteen behavioral groups and ten settled screenshots inspected. All five component/model source hashes measured. Synthetic API responses and real local JSON downloads, no native API/QGA target, installed or release acceptance.", + "document_sha256": { + "docs/TROUBLESHOOTING.md": "c7e35be379e2bdcda3162f21e629b04ca474132c71c2628c851aff5bcc2ff547", + "frontend-modern/public/docs/TROUBLESHOOTING.md": "c7e35be379e2bdcda3162f21e629b04ca474132c71c2628c851aff5bcc2ff547" + }, + "verified_component_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": "6956ea3a37927309d840cf076a178f84e25726639316f04158c64f37e80cde9f" + } +} diff --git a/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts b/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts index 5fbb4489ff..7efc91b89d 100644 --- a/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/diagnosticsPresentation.test.ts @@ -24,7 +24,9 @@ describe('diagnosticsPresentation', () => { 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'); + expect(DIAGNOSTICS_PANEL_COPY.exportSafety).toContain('Review even a sanitised file'); + expect(DIAGNOSTICS_PANEL_COPY.runSafety).not.toContain(';'); + expect(DIAGNOSTICS_PANEL_COPY.exportSafety).not.toContain(';'); }); it('exports canonical diagnostics empty-state copy', () => { diff --git a/frontend-modern/src/utils/diagnosticsPresentation.ts b/frontend-modern/src/utils/diagnosticsPresentation.ts index abe0a29f3f..1488a64e8e 100644 --- a/frontend-modern/src/utils/diagnosticsPresentation.ts +++ b/frontend-modern/src/utils/diagnosticsPresentation.ts @@ -3,9 +3,9 @@ export const DIAGNOSTICS_PANEL_COPY = { description: 'Review connection health, configuration status, and troubleshooting tools.', 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.', + '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.', + '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...', From ea490f6503d93d6f27440fd2d83b7c30b42da17d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:10:55 +0100 Subject: [PATCH 5/5] build(diagnostics): record measured privacy-control bundle cost Exact-source source-4x1h3apo measures 7,245 gzip bytes versus the 6,009-byte Diagnostics baseline. Accept the intentional 1,236-byte privacy and safety cost in this existing lazy-loaded panel. Update only its measured baseline, preserving every growth limit, other chunk baseline, total budget and startup-loading check. Change-source: pulse-maintainer --- frontend-modern/.bundlesize.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-modern/.bundlesize.json b/frontend-modern/.bundlesize.json index d5c73eb737..cea9cb20ad 100644 --- a/frontend-modern/.bundlesize.json +++ b/frontend-modern/.bundlesize.json @@ -98,7 +98,7 @@ "baselineGzip": 6035 }, "DiagnosticsPanel": { - "baselineGzip": 6009 + "baselineGzip": 7245 }, "SecurityAuthPanel": { "baselineGzip": 5851