mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed Diagnostics privacy and safe-collection maintenance
Change-source: pulse-maintainer
This commit is contained in:
+29
-2
@@ -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
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"baselineGzip": 6035
|
||||
},
|
||||
"DiagnosticsPanel": {
|
||||
"baselineGzip": 6009
|
||||
"baselineGzip": 7245
|
||||
},
|
||||
"SecurityAuthPanel": {
|
||||
"baselineGzip": 5851
|
||||
|
||||
@@ -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/i,
|
||||
);
|
||||
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,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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,274 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
sanitizeDiagnosticsData,
|
||||
stripInternalAnalyticsDiagnosticsFields,
|
||||
type DiagnosticsData,
|
||||
} from '../diagnosticsModel';
|
||||
|
||||
type ExportData = Omit<DiagnosticsData, 'nodes' | 'pbs' | 'apiTokens' | 'dockerAgents'> &
|
||||
Record<string, any> & {
|
||||
nodes: Array<DiagnosticsData['nodes'][number] & Record<string, any>>;
|
||||
pbs: Array<DiagnosticsData['pbs'][number] & { lastError: { kind: string; message: string } }>;
|
||||
apiTokens: NonNullable<DiagnosticsData['apiTokens']> & {
|
||||
tokens: Array<{ id: string; name: string; hint: string }>;
|
||||
usage: Array<{ tokenId: string; agentCount: number; agents: string[] }>;
|
||||
};
|
||||
dockerAgents: NonNullable<DiagnosticsData['dockerAgents']> & {
|
||||
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 => ({
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
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' },
|
||||
},
|
||||
],
|
||||
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'] },
|
||||
],
|
||||
},
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
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);
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -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,33 @@ 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');
|
||||
expect(DIAGNOSTICS_PANEL_COPY.runSafety).not.toContain(';');
|
||||
expect(DIAGNOSTICS_PANEL_COPY.exportSafety).not.toContain(';');
|
||||
});
|
||||
|
||||
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.');
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user