Files
MyIP/frontend/utils/report-export.js
T
jason5ng32andClaude Fable 5 89141aefc7 Feat(report): share dialog with link, AI-markdown and JSON exports
New dock entry opens the diagnostic-report dialog: pick collected
sections (original-site-only tools hidden for self-hosts), optionally
mask IP tails, preview exactly what leaves the device, then export —
a KV-backed /r/:id link (TTL 1/3/7 days, default 1; the backend forces
out-of-whitelist values down to 1 day), a self-describing Markdown
prompt for AI assistants, or a JSON download. Copy localized in all
four locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:10:47 +08:00

213 lines
9.0 KiB
JavaScript

// Turns collected section snapshots into the share outputs: the versioned
// report envelope (POSTed for the /r/:id link and offered as JSON download)
// and the self-describing Markdown handed to AI assistants. Pure functions —
// the share dialog owns all interaction state. Markdown prose is localized
// via the caller's t(); data tables keep the schema's English field names on
// purpose (stable identifiers for AI consumption).
import { REPORT_VERSION, REPORT_SECTION_IDS, maskReportIps } from './report-schema.js';
// Section id → the site's existing i18n title key (dialog checklist and
// Markdown headings reuse the same titles the homepage sections show).
export const SECTION_TITLE_KEYS = {
ipinfo: 'ipInfos.Title',
connectivity: 'connectivity.Title',
webrtc: 'webrtc.Title',
dnsleak: 'dnsleaktest.Title',
speedtest: 'speedtest.Title',
pingtest: 'pingtest.Title',
mtrtest: 'mtrtest.Title',
ruletest: 'ruletest.Title',
browserinfo: 'browserinfo.Title',
invisibility: 'invisibilitytest.Title',
enhanceddnsleak: 'enhanceddnsleaktest.Title',
};
// Assemble the envelope from collected snapshots. Sections keep homepage
// order regardless of selection order; snapshots are deep-copied (they're
// JSON-safe by construction) so later test re-runs can't mutate an already
// exported report; masking applies at this boundary so all outputs share
// the exact same data.
export const buildShareReport = ({ sections, selectedIds, maskTail, locale, origin }) => {
const picked = {};
for (const id of REPORT_SECTION_IDS) {
if (selectedIds.includes(id) && sections[id]) {
picked[id] = JSON.parse(JSON.stringify(sections[id]));
}
}
const report = {
v: REPORT_VERSION,
generatedAt: new Date().toISOString(),
origin,
locale,
sections: picked,
};
return maskTail ? maskReportIps(report) : report;
};
// --- Markdown rendering -------------------------------------------------------
const cell = (value) => {
if (value === undefined || value === null || value === '') return '—';
if (value === true) return 'yes';
if (value === false) return 'no';
return String(value).replace(/\|/g, '\\|').replace(/\n/g, ' ');
};
const mdTable = (headers, rows) => [
`| ${headers.join(' | ')} |`,
`| ${headers.map(() => '---').join(' | ')} |`,
...rows.map((row) => `| ${row.map(cell).join(' | ')} |`),
].join('\n');
const kvLines = (pairs) => pairs
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `- ${key}: ${cell(value)}`)
.join('\n');
// MTR hop columns vary by mtr build — render the union of stats the probe
// actually carries, in a stable order.
const MTR_HOP_STATS = ['lossPct', 'sntCount', 'drop', 'rcv', 'lastMs', 'avgMs', 'bestMs', 'worstMs', 'stdevMs', 'javgMs'];
const SECTION_RENDERERS = {
ipinfo: (section) => mdTable(
['source', 'ip', 'countryCode', 'region', 'city', 'asn', 'isp'],
section.cards.map((c) => [c.source, c.ip, c.countryCode, c.region, c.city, c.asn, c.isp]),
),
connectivity: (section) => mdTable(
['id', 'name', 'status', 'timeMs', 'minTimeMs', 'custom'],
section.targets.map((t) => [t.id, t.name, t.status, t.timeMs, t.minTimeMs, t.custom]),
),
webrtc: (section) => mdTable(
['id', 'url', 'ip', 'natType', 'countryCode', 'org'],
section.servers.map((s) => [s.id, s.url, s.ip, s.natType, s.countryCode, s.org]),
),
dnsleak: (section) => mdTable(
['id', 'ip', 'countryCode', 'org'],
section.providers.map((p) => [p.id, p.ip, p.countryCode, p.org]),
),
speedtest: (section) => {
const parts = [kvLines([
['downloadMbps', section.downloadMbps],
['uploadMbps', section.uploadMbps],
['latencyMs', section.latencyMs],
['jitterMs', section.jitterMs],
['loadedLatencyDownMs', section.loadedLatencyDownMs],
['loadedLatencyUpMs', section.loadedLatencyUpMs],
])];
if (section.scores) {
parts.push(kvLines(Object.entries(section.scores).map(
([key, score]) => [`score.${key}`, `${score} (${section.qualities?.[key] ?? '—'})`],
)));
}
if (section.connection) {
parts.push(kvLines(Object.entries(section.connection).map(
([key, value]) => [`connection.${key}`, value],
)));
}
return parts.filter(Boolean).join('\n');
},
pingtest: (section) => [
`target: ${cell(section.target)}`,
mdTable(
['countryCode', 'min', 'avg', 'max', 'loss%', 'total', 'rcv', 'drop'],
section.probes.map((p) => [p.countryCode, p.stats.min, p.stats.avg, p.stats.max, p.stats.loss, p.stats.total, p.stats.rcv, p.stats.drop]),
),
].join('\n'),
mtrtest: (section) => {
const blocks = [`target: ${cell(section.target)}`];
for (const probe of section.probes) {
const stats = MTR_HOP_STATS.filter((key) => probe.hops.some((hop) => hop[key] !== undefined));
// "DE Falkenstein — Hetzner (AS24940)", absent parts omitted.
const place = [probe.countryCode, probe.city].filter(Boolean).join(' ');
const network = [probe.network, probe.asn !== undefined ? `(AS${probe.asn})` : undefined]
.filter(Boolean).join(' ');
blocks.push(`### probe: ${[place, network].filter(Boolean).join(' — ')}`);
blocks.push(mdTable(
['n', 'host', 'ip', 'asn', ...stats],
probe.hops.map((hop) => [hop.n, hop.host, hop.ip, hop.asn, ...stats.map((key) => hop[key])]),
));
}
return blocks.join('\n');
},
ruletest: (section) => [
`uniqueIPCount: ${section.uniqueIPCount}`,
mdTable(
['id', 'ip', 'countryCode', 'org'],
section.workers.map((w) => [w.id, w.ip, w.countryCode, w.org]),
),
].join('\n'),
browserinfo: (section) => kvLines([
['browser', section.browser && `${section.browser.name ?? ''} ${section.browser.version ?? ''}`.trim()],
['os', section.os && `${section.os.name ?? ''} ${section.os.version ?? ''}`.trim()],
['engine', section.engine && `${section.engine.name ?? ''} ${section.engine.version ?? ''}`.trim()],
['timezone', section.timezone],
['languages', section.languages?.join(', ')],
['display', section.display && `${section.display.width}x${section.display.height} @${section.display.pixelRatio}x`],
['connection', section.connection && `${section.connection.effectiveType ?? ''} downlink=${section.connection.downlink ?? '—'}Mbps rtt=${section.connection.rtt ?? '—'}ms`.trim()],
]),
invisibility: (section) => [
kvLines([
['ip', section.ip],
['score.proxy', section.scores.proxy],
['score.vpn', section.scores.vpn],
]),
mdTable(['signal', 'flagged'], section.flags.map((f) => [f.key, f.flagged])),
].join('\n'),
enhanceddnsleak: (section) => [
kvLines([
['rawCount', section.rawCount],
['resolverCount', section.resolverCount],
['dnssec', section.dnssec],
]),
mdTable(
['ip', 'countryCode', 'asn', 'org', 'transport', 'ecs', 'do', 'cd'],
section.queries.map((q) => [q.ip, q.countryCode, q.asn, q.org, q.transport, q.ecs, q.do, q.cd]),
),
].join('\n'),
};
// Render the whole report as a self-describing Markdown prompt: localized
// prose explains each test and how to read it, the closing line asks the AI
// for a diagnosis. `masked` adds the privacy note about masked IP tails.
export const reportToMarkdown = (report, t, { masked = false } = {}) => {
const lines = [
`# ${t('report.ai.Heading')}`,
'',
t('report.ai.Intro', { origin: report.origin, time: report.generatedAt }),
];
if (masked) lines.push('', t('report.ai.Masked'));
for (const id of REPORT_SECTION_IDS) {
const section = report.sections[id];
if (!section) continue;
lines.push(
'',
`## ${t(SECTION_TITLE_KEYS[id])}`,
'',
t(`report.ai.Sections.${id}`),
`testedAt: ${section.testedAt}`,
'',
SECTION_RENDERERS[id](section),
);
}
lines.push('', '---', '', t('report.ai.Instruction'), '');
return lines.join('\n');
};
// --- JSON download --------------------------------------------------------------
export const reportFileName = (generatedAt) =>
`myip-report-${String(generatedAt).slice(0, 10)}.json`;
export const downloadReportJson = (report) => {
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = reportFileName(report.generatedAt);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};