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
This commit is contained in:
pulse-triage[bot]
2026-10-04 10:29:28 +01:00
parent 1ff46c4261
commit 5cd4b44390
18 changed files with 1097 additions and 39 deletions
+29 -2
View File
@@ -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.
@@ -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
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Diagnostics safety verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./diagnostics-safety.tsx"></script>
</body>
</html>
@@ -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 (
<main class="mx-auto max-w-5xl p-3 sm:p-6 text-base-content">
<h1 class="mb-4 text-xl font-semibold">Settings — Diagnostics</h1>
<DiagnosticsPanel />
<ToastContainer />
</main>
);
}
render(
() => (
<Router>
<Route path="/docs/*docPath" component={Docs} />
<Route path="/*" component={Diagnostics} />
</Router>
),
document.getElementById('root')!,
);
@@ -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"
}
}
+29 -2
View File
@@ -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.
@@ -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 (
<div class="space-y-6">
@@ -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"
>
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
@@ -49,6 +58,16 @@ export const DiagnosticsPanel: Component = () => {
</div>
}
>
<div class="px-4 sm:px-6 py-3 sm:py-4 space-y-2 text-sm text-muted">
<p id={runSafetyId}>{DIAGNOSTICS_PANEL_COPY.runSafety}</p>
<p id={exportSafetyId}>{DIAGNOSTICS_PANEL_COPY.exportSafety}</p>
<a
href={`${TROUBLESHOOTING_DOC_URL}#collect-diagnostics-safely`}
class="inline-flex min-h-11 items-center text-link hover:underline"
>
Safe diagnostics and sharing
</a>
</div>
<div class="px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p class="text-xs text-muted">{DIAGNOSTICS_PANEL_COPY.summary}</p>
<Show when={diagnosticsData()}>
@@ -56,7 +75,8 @@ export const DiagnosticsPanel: Component = () => {
<button
type="button"
onClick={() => exportDiagnostics(false)}
disabled={exportLoading()}
disabled={exportLoading() || loading()}
aria-describedby={exportSafetyId}
class="flex min-h-11 sm:min-h-9 items-center gap-1.5 px-3 py-2 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover transition-colors"
>
<Download class="w-3.5 h-3.5" />
@@ -65,7 +85,8 @@ export const DiagnosticsPanel: Component = () => {
<button
type="button"
onClick={() => exportDiagnostics(true)}
disabled={exportLoading()}
disabled={exportLoading() || loading()}
aria-describedby={exportSafetyId}
class="flex min-h-11 sm:min-h-9 items-center gap-1.5 px-3 py-2 text-sm font-medium text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-900/25 border border-green-200 dark:border-green-800 rounded-md hover:bg-green-100 dark:hover:bg-green-900/25 transition-colors"
>
<Download class="w-3.5 h-3.5" />
@@ -79,6 +100,7 @@ export const DiagnosticsPanel: Component = () => {
diagnosticsData={diagnosticsData()}
loading={loading()}
onRunDiagnostics={runDiagnostics}
runDescriptionId={runSafetyId}
/>
</div>
);
@@ -68,6 +68,7 @@ interface DiagnosticsResultsPanelProps {
diagnosticsData: DiagnosticsData | null;
loading: boolean;
onRunDiagnostics: () => void;
runDescriptionId?: string;
}
export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> = (props) => {
@@ -89,6 +90,7 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
class="gap-2"
onClick={props.onRunDiagnostics}
disabled={props.loading}
aria-describedby={props.runDescriptionId}
>
<RefreshCw class={`h-4 w-4 ${props.loading ? 'animate-spin' : ''}`} />
{DIAGNOSTICS_EMPTY_STATE_COPY.actionLabel}
@@ -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(() => (
<>
<p id="run-safety">Do not run during a backup or freeze/thaw.</p>
<DiagnosticsResultsPanel
diagnosticsData={null}
loading={false}
onRunDiagnostics={run}
runDescriptionId="run-safety"
/>
</>
));
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',
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import {
sanitizeDiagnosticsData,
stripInternalAnalyticsDiagnosticsFields,
type DiagnosticsData,
} from '../diagnosticsModel';
type ExportData = DiagnosticsData & Record<string, any>;
// 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);
});
});
@@ -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' }]);
});
});
@@ -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
@@ -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),
@@ -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<string, string>();
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<Record<string, unknown>>;
guestSnapshots?: Array<Record<string, unknown>>;
memorySources?: Array<Record<string, unknown>>;
memorySourceBreakdown?: Array<Record<string, unknown>>;
};
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<string, unknown>;
physicalDisks?: Record<string, unknown>;
};
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 {
@@ -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);
}
@@ -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.');
@@ -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;