';
rerun.disabled = true;
rerun.textContent = "↻ Running…";
try {
// Category grouping needs the check→category map; fetch it once before
// the first render. Never fatal — grouping falls back to "Other".
await loadCategoryMap();
// Explicit load (first paint / Re-run) asks for a fresh scan; only an
// explicit Re-run records the run in history — background polling never
// does (it would churn the trend).
render(await fetchReport({ refresh: true, save: saveHistory }));
if (saveHistory) showToast("\u2713 Run recorded in history");
} catch (err) {
status.className = "status warn";
status.innerHTML = "";
status.textContent = "\u26a0\ufe0f Could not run checks: " + (err && err.message ? err.message : err);
report.innerHTML = '
Run failed \u2014 see the message above. ' +
(isDesktop()
? "The desktop app ships its own Node runtime, so a reinstall usually fixes a broken install."
: "Make sure the dashboard server is still running, then press Re-run checks.") +
"
";
} finally {
rerun.disabled = false;
rerun.textContent = "↻ Re-run checks";
}
}
$("#rerun").addEventListener("click", () => load({ saveHistory: true }));
// PDF export
const pdfBtn = $("#pdfbtn");
if (pdfBtn) {
pdfBtn.addEventListener("click", () => {
document.querySelectorAll(".group, .card, .crow, details.ev").forEach(el => el.setAttribute("open", ""));
setTimeout(() => window.print(), 120);
showToast("Print dialog opened \u2014 save as PDF");
});
}
// Markdown export — scrubbed, share-ready (same scrub() as CLI --md)
$("#export").addEventListener("click", async () => {
if (!lastData) return;
await copyText(reportMarkdown(lastData));
const btn = $("#export");
const old = btn.textContent;
btn.textContent = "\u2713 Copied";
showToast("\u2713 Report copied as Markdown (scrubbed)");
setTimeout(() => (btn.textContent = old), 1200);
});
$("#export-md-file")?.addEventListener("click", () => {
if (!lastData) return;
downloadMarkdown(lastData);
showToast("\u2713 Markdown file downloaded (scrubbed)");
});
// Banner buttons: copy fix / jump to finding
document.addEventListener("click", async (e) => {
// NH copy
const copyBtn = e.target.closest("[data-nhcopy]");
if (copyBtn) {
await copyText(copyBtn.dataset.nhcopy);
showToast("\u2713 Fix copied to clipboard");
return;
}
// NH jump
const jumpBtn = e.target.closest("[data-nhjump]");
if (jumpBtn) {
const code = jumpBtn.dataset.nhjump;
activeFilter = "all";
document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === "all"));
syncGroupsOpen();
applyFilters();
const card = code ? document.querySelector('#report details[data-code="' + code.replace(/"/g, '\\"') + '"]') : null;
if (card) {
card.setAttribute("open", "");
card.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
setTimeout(() => { card.classList.remove("flash"); }, 50);
const nh = $("#nexthep");
if (nh) nh.classList.add("flash");
setTimeout(() => nh && nh.classList.remove("flash"), 1700);
}
return;
}
// Evidence copy
if (e.target.classList.contains("ev-copy")) {
const id = e.target.dataset.evcopy;
const ev = document.getElementById(id);
if (ev) {
await copyText(ev.textContent);
const old = e.target.textContent;
e.target.textContent = "\u2713 Copied";
showToast("\u2713 Evidence copied");
setTimeout(() => e.target.textContent = old, 1200);
}
return;
}
// Evidence expand
if (e.target.classList.contains("ev-expand")) {
const id = e.target.dataset.ev;
const ev = document.getElementById(id);
if (ev) {
const expanded = ev.classList.toggle("expanded");
e.target.textContent = expanded ? "Show less" : "Show more";
}
return;
}
// Generic copy
if (e.target.dataset.copy !== undefined) {
// A code pill lives inside the card : copying must not also
// toggle the finding open/closed.
if (e.target.closest && e.target.closest(".codepill")) e.preventDefault();
await copyText(e.target.dataset.copy);
const old = e.target.textContent;
e.target.textContent = "\u2713 Copied";
showToast("\u2713 Copied to clipboard");
setTimeout(() => (e.target.textContent = old), 1200);
}
// Ignore
if (e.target.dataset.ignore !== undefined) {
await ignoreFinding(e.target.dataset.ignore, e.target);
}
// Undo
if (e.target.dataset.undo !== undefined) {
if (lastUndo && lastData) {
lastData.findings = [...lastUndo.findings];
render(lastData);
showToast("\u21a9 Restored");
lastUndo = null;
}
}
});
// Ignore finding
let lastUndo = null;
async function ignoreFinding(title, btn) {
const payload = JSON.stringify({ pattern: title });
const tryPost = async (url) => {
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload });
return res.ok;
};
let ok = false;
try { ok = await tryPost("http://127.0.0.1:17321/api/ignore"); } catch { ok = false; }
if (!ok) { try { ok = await tryPost("/api/ignore"); } catch { ok = false; } }
btn.disabled = true;
btn.textContent = ok ? "\u2713 Ignored" : "\u2717 Could not save";
if (ok && lastData) {
lastUndo = { findings: [...lastData.findings], title };
lastData.findings = lastData.findings.filter((f) => f.title !== title);
render(lastData);
showToast('Ignored "' + esc(title).slice(0, 40) + (title.length > 40 ? "\u2026" : "") + '" ', 4000);
setTimeout(() => { if (lastUndo && lastUndo.title === title) lastUndo = null; }, 4200);
} else if (!ok) {
showToast("\u2717 Could not save \u2014 check permissions");
}
}
// Setup modules
setupKeyboard();
setupPalette();
setupPolling();
setupThresholds();
setupNotifyButton();
setupSidebar();
setupModal();
setupChecksMatrix();
setupChecksView();
setupUrlSync();
setupViews();
setupBrand();
// JSON export — the same versioned envelope /api/report serves.
$("#copyjson")?.addEventListener("click", async () => {
if (!lastData) return;
await copyText(JSON.stringify(lastData, null, 2));
showToast("\u2713 JSON report copied");
});
// Keyboard shortcuts help (?)
$("#helpbtn")?.addEventListener("click", openHelp);
/* Empty-state escape hatches: the filtered-out message and any future one
carry a control, so a dead end is always one click from resolving. */
document.addEventListener("click", (e) => {
if (e.target.closest("[data-clear-filters]")) $("#clearbtn")?.click();
});
// The hero "N checks" chip opens the matrix too.
$("#checkschip")?.addEventListener("click", openChecksMatrix);
// Density toggle: compact card spacing, remembered across sessions.
// Technical users get compact by default (more findings per viewport, less scroll).
const densityBtn = $("#densitybtn");
if (densityBtn) {
try {
const pref = localStorage.getItem("ld-density");
if (pref === "compact" || pref === null) document.body.classList.add("compact");
} catch { document.body.classList.add("compact"); }
const syncDensityBtn = () => {
const compact = document.body.classList.contains("compact");
densityBtn.setAttribute("aria-pressed", String(compact));
densityBtn.title = compact ? "Density: compact — click for comfortable" : "Density: comfortable — click for compact";
};
syncDensityBtn();
densityBtn.addEventListener("click", () => {
const compact = document.body.classList.toggle("compact");
try { localStorage.setItem("ld-density", compact ? "compact" : "comfortable"); } catch {}
syncDensityBtn();
showToast(compact ? "Compact density" : "Comfortable density");
});
}
// Initial load
setupWideMode();
setupDetailPane();
if (STATIC_DATA) {
render(STATIC_DATA);
const rerun = $("#rerun");
if (rerun) { rerun.disabled = true; rerun.title = "Static report \u2014 regenerate with linux-doctor --html "; }
} else {
load();
startPolling();
}