Arx 0.5.6: password/lockout fixes on Mac, Linux, Windows; Fix problems on Windows (fix.ps1); Linux pwquality read order
458b1d7 verified Download app/renderer/app.js from Devseis/Arx: direct link, hf CLI and curl.
- Browser
- Download file 24.9 kB
-
https://huggingface.co/spaces/Devseis/Arx/resolve/main/app/renderer/app.js
- Command line
-
hf download hf://spaces/Devseis/Arx/app/renderer/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Devseis/Arx/resolve/main/app/renderer/app.js
24.9 kB
| // Screen flow: prepare model -> approval -> live progress -> findings -> results + save popup (60 s auto-save). | |
| import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer } from './auditor-core.js'; | |
| import { buildReportHtml } from './report.js'; | |
| const $ = id => document.getElementById(id); | |
| const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); | |
| const SAVE_TIMEOUT_SECONDS = 60; | |
| let plan = null, lib = null, fixes = {}, model = null, lastReport = null, countdownTimer = null, lastEvidence = null, pendingFix = null; | |
| function show(name) { | |
| for (const screen of document.querySelectorAll('.screen')) screen.classList.toggle('hidden', screen.id !== `screen-${name}`); | |
| } | |
| function setBar(id, fraction) { $(id).style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`; } | |
| function stopped(title, text) { $('stopped-title').textContent = title; $('stopped-text').textContent = text; show('stopped'); } | |
| // 1. Prepare the auditor model (downloaded once, then cached by WebLLM) | |
| async function prepare() { | |
| show('model'); | |
| plan = await window.devseis.plan(); | |
| const libraryData = await window.devseis.library(); | |
| lib = new Library(libraryData); | |
| fixes = libraryData.fixes || {}; | |
| $('device').textContent = `${plan.hostname} · ${plan.platform}`; | |
| if (!model && !new URLSearchParams(location.search).has('smoke')) { | |
| try { | |
| const { loadModel } = await import('./model.js'); | |
| $('model-name').textContent = 'Devseis auditor model'; | |
| $('model-status').textContent = 'Downloading the auditor model (first run only)…'; | |
| const version = await window.devseis.modelVersion(); | |
| model = await loadModel(({ progress, text }) => { | |
| setBar('model-bar', progress); | |
| $('model-percent').textContent = `${Math.round(progress * 100)}%`; | |
| $('model-status').textContent = text; | |
| }, version); | |
| $('model-name').textContent = model.name + (model.fineTuned ? '' : ' (base model)'); | |
| $('model-status').textContent = 'The auditor is ready and runs offline on this computer.'; | |
| setBar('model-bar', 1); | |
| $('model-percent').textContent = '100%'; | |
| await sleep(800); | |
| } catch (error) { | |
| model = null; | |
| $('model-name').textContent = 'Built-in report writer'; | |
| $('model-percent').textContent = ''; | |
| $('model-status').textContent = `${error.message} The audit will use the built-in report writer instead.`; | |
| await sleep(2500); | |
| } | |
| } | |
| showApproval(); | |
| } | |
| // 2. Approval: show everything that will be checked (row 1: baseline + GDPR, row 2: AI Act + organisation) | |
| const SECTION_INFO = { | |
| baseline: { icon: '🛡️', title: 'Security baseline', sub: 'Passwords, accounts, antivirus, firewall, screen lock, USB, encryption, updates' }, | |
| gdpr: { icon: '🔐', title: 'GDPR technical controls', sub: 'Updates, logging, time, backup, guest and remote access, diagnostics' }, | |
| aiact: { icon: '🤖', title: 'EU AI Act', sub: 'AI tools on this computer and how they are governed' }, | |
| org: { icon: '🏢', title: 'Organisation', sub: 'Company records and policies, answered in organisational.conf' }, | |
| }; | |
| function showApproval() { | |
| $('plan').innerHTML = ''; | |
| const total = plan.sections.reduce((n, s) => n + s.checks.length, 0); | |
| $('plan-total').textContent = `${total} checks in ${plan.sections.length} groups`; | |
| for (const section of plan.sections) { | |
| const info = SECTION_INFO[section.id] || { icon: '•', title: section.title, sub: '' }; | |
| const box = document.createElement('div'); | |
| box.className = `section section-${section.id}`; | |
| box.innerHTML = `<div class="section-head"><span class="section-icon"></span><div class="section-titles"><h3></h3><p></p></div><span class="count"></span></div><ul></ul>`; | |
| box.querySelector('.section-icon').textContent = info.icon; | |
| box.querySelector('h3').textContent = info.title; | |
| box.querySelector('p').textContent = info.sub; | |
| box.querySelector('.count').textContent = `${section.checks.length} checks`; | |
| const list = box.querySelector('ul'); | |
| for (const check of section.checks) { | |
| const item = document.createElement('li'); | |
| item.textContent = check.name; | |
| list.appendChild(item); | |
| } | |
| $('plan').appendChild(box); | |
| } | |
| show('approve'); | |
| } | |
| // 3. Run the audit and show live progress. fix = { action: 'apply'|'undo', ids }: fix first, then audit again. | |
| async function runAudit(fix = null) { | |
| const elevated = fix ? true : $('elevated').checked; | |
| show('progress'); | |
| $('progress-title').textContent = fix ? (fix.action === 'undo' ? 'Undoing fixes and checking again' : 'Fixing and checking again') : 'Auditing this computer'; | |
| $('progress-step').textContent = 'Collecting evidence'; | |
| $('progress-current').textContent = elevated ? 'Waiting for administrator approval…' : 'Starting…'; | |
| setBar('progress-bar', 0); | |
| const rows = new Map(); | |
| const order = []; | |
| $('checklist').innerHTML = ''; | |
| for (const section of plan.sections) { | |
| for (const check of section.checks) { | |
| const row = document.createElement('div'); | |
| row.className = 'check'; | |
| row.innerHTML = '<span class="icon"></span><span class="name"></span><span class="group"></span>'; | |
| row.querySelector('.name').textContent = check.name; | |
| row.querySelector('.group').textContent = section.title.replace(' CONTROLS', '').replace('COMPLIANCE RESULTS', 'BASELINE'); | |
| $('checklist').appendChild(row); | |
| rows.set(check.id, row); | |
| order.push(check.id); | |
| } | |
| } | |
| const total = order.length; | |
| let finished = 0; | |
| $('progress-count').textContent = `0 / ${total}`; | |
| const markRunning = () => { const next = order.find(id => !rows.get(id).classList.contains('done')); if (next) rows.get(next).classList.add('running'); }; | |
| const markDone = (id, status) => { | |
| const row = rows.get(id); | |
| if (!row || row.classList.contains('done')) return; | |
| row.classList.remove('running'); | |
| row.classList.add('done', `s-${status}`); | |
| row.querySelector('.group').textContent = status; | |
| finished += 1; | |
| $('progress-count').textContent = `${finished} / ${total}`; | |
| setBar('progress-bar', finished / total); | |
| }; | |
| const unsubscribe = window.devseis.onProgress(item => { | |
| markDone(item.id, item.status); | |
| $('progress-current').textContent = `Checked: ${item.name}`; | |
| markRunning(); | |
| }); | |
| let result; | |
| try { | |
| result = await window.devseis.runAudit(elevated, fix); | |
| } catch (error) { | |
| unsubscribe(); | |
| const cancelled = /cancelled/i.test(error.message); | |
| stopped(cancelled ? (fix ? 'Fix cancelled' : 'Audit cancelled') : 'The audit stopped', cancelled ? 'Administrator approval was not given. Nothing was checked and nothing was changed.' : error.message); | |
| return; | |
| } | |
| unsubscribe(); | |
| const evidence = result.evidence; | |
| for (const r of evidence.results) markDone(r.id, r.status); | |
| // 4. Write the findings (AI model when available, otherwise the built-in writer) | |
| const targets = evidence.results.filter(r => ['Non-Compliant', 'Error', 'Pending'].includes(r.status)); | |
| const ai = aiPrompt(evidence, lib); | |
| const steps = targets.length + 1 + (ai ? 1 : 0); | |
| let written = 0; | |
| const sources = { model: 0, template: 0 }; | |
| const generate = model ? model.generate : null; | |
| const tick = label => { | |
| written += 1; | |
| $('progress-count').textContent = `${written} / ${steps}`; | |
| setBar('progress-bar', written / steps); | |
| $('progress-current').textContent = label; | |
| }; | |
| $('progress-title').textContent = 'Writing the audit report'; | |
| $('progress-step').textContent = model ? 'AI auditor is writing findings' : 'Writing findings'; | |
| $('progress-count').textContent = `0 / ${steps}`; | |
| setBar('progress-bar', 0); | |
| const findings = {}, findingSource = {}; | |
| for (const r of targets) { | |
| $('progress-current').textContent = `Writing: ${r.name}`; | |
| const { answer, source } = await writeAnswer('finding', findingPrompt(evidence, r, lib), findingTemplate(evidence, r, lib), generate); | |
| findings[r.id] = answer; | |
| findingSource[r.id] = source; | |
| sources[source] += 1; | |
| tick(`Written: ${r.name}`); | |
| } | |
| // Score, counts and top gaps always come from the evidence; the model may only phrase the text. | |
| const summaryBase = summaryTemplate(evidence, lib); | |
| const summaryOut = await writeAnswer('summary', summaryPrompt(evidence, lib), summaryBase, generate); | |
| const summary = summaryOut.source === 'model' | |
| ? { ...summaryBase, summary: summaryOut.answer.summary, next_steps: summaryOut.answer.next_steps } | |
| : summaryBase; | |
| sources[summaryOut.source] += 1; | |
| tick('Written: summary'); | |
| let aiResult = null; | |
| if (ai) { | |
| const out = await writeAnswer('ai_classification', ai, aiTemplate(evidence, lib), generate); | |
| aiResult = out.answer; | |
| sources[out.source] += 1; | |
| tick('Written: AI tool classification'); | |
| } | |
| const html = buildReportHtml({ evidence, findings, summary, ai: aiResult, sha256: result.sha256, model: model ? model.name : null, sources }); | |
| lastReport = { html, evidenceText: result.evidenceText, hostname: evidence.host.hostname }; | |
| lastEvidence = evidence; | |
| showResults(evidence, summary, sources); | |
| renderAllResults(evidence, findings, findingSource); | |
| await renderFixes(evidence, result.fixResults); | |
| openSaveModal(); | |
| } | |
| // 6. Fixes: one-click fixes (tested scripts, undo saved) and settings pages for the rest. The AI never runs commands. | |
| const OUTCOME = { applied: '✅', undone: '↩️', unchanged: '✔️', skipped: '⚠️', failed: '❌' }; | |
| async function renderFixes(evidence, fixResults) { | |
| const os = plan.os; | |
| const name = id => (evidence.results.find(r => r.id === id) || {}).name || id; | |
| if (fixResults && fixResults.length) { | |
| $('fix-banner').innerHTML = `<b>${fixResults.some(r => r.outcome === 'undone') ? 'Undo finished' : 'Fixes finished'}</b><ul class="fix-results">${fixResults.map(r => | |
| `<li>${OUTCOME[r.outcome] || '•'} <b>${escapeHtml(name(r.id))}</b>: ${escapeHtml(r.message)}</li>`).join('')}</ul><p class="muted">The results below come from checking this computer again after the change.</p>`; | |
| $('fix-banner').classList.remove('hidden'); | |
| } else $('fix-banner').classList.add('hidden'); | |
| // A fix can depend on software being present (e.g. ClamAV updates only where ClamAV runs): requires.check's result must contain requires.contains. | |
| const usable = f => !f.requires || ((evidence.results.find(x => x.id === f.requires.check) || {}).actual || '').toLowerCase().includes(f.requires.contains.toLowerCase()); | |
| const gaps = evidence.results.filter(r => r.status === 'Non-Compliant' && fixes[r.id] && fixes[r.id][os] && usable(fixes[r.id][os])); | |
| const auto = gaps.filter(r => fixes[r.id][os].kind === 'auto'); | |
| // Several checks can share one fix (lockout threshold, duration and observation): show it once, fixing all of them. | |
| const autoGroups = []; | |
| for (const r of auto) { | |
| const f = fixes[r.id][os], g = autoGroups.find(x => x.f.title === f.title && x.f.changes === f.changes); | |
| if (g) g.ids.push(r.id); else autoGroups.push({ f, ids: [r.id] }); | |
| } | |
| // Several checks can share one settings page and the same steps (screen lock): show them once. | |
| const seen = new Set(); | |
| const guided = gaps.filter(r => fixes[r.id][os].kind === 'settings') | |
| .filter(r => { const f = fixes[r.id][os], key = f.url + f.steps.join('|'); if (seen.has(key)) return false; seen.add(key); return true; }); | |
| const history = await window.devseis.fixHistory().catch(() => []); | |
| const last = [...history].reverse().find(h => h.action === 'apply'); | |
| const undoable = last ? last.results.filter(r => r.outcome === 'applied' && fixes[r.id]?.[os]?.undo).map(r => r.id) : []; | |
| if (!auto.length && !guided.length && !undoable.length) { $('fixes').classList.add('hidden'); return; } | |
| $('fixes').innerHTML = `<h2>🛠 Fix problems</h2> | |
| ${auto.length ? `<p class="muted">Arx can fix these for you. Most changes can be undone.</p> | |
| <div class="fix-auto">${autoGroups.map(({ f, ids }) => `<label class="fix-item"><input type="checkbox" data-fix="${ids.join(',')}" checked> | |
| <span><b>${escapeHtml(f.title)}</b><br><span class="muted">${escapeHtml(f.changes)}${f.note ? ` ${escapeHtml(f.note)}` : ''}</span></span></label>`).join('')}</div> | |
| <div class="actions"><button class="primary" id="fix-selected">Fix selected (${autoGroups.length})</button></div>` : ''} | |
| ${guided.length ? `<h3>${os === 'macos' ? 'Fix in System Settings' : 'Fix in Settings'}</h3><p class="muted">These need your password, a recovery key or a restart, so you make the change. Arx opens the right page.</p> | |
| <div class="fix-guided">${guided.map(r => { const f = fixes[r.id][os]; return `<div class="fix-item"><span><b>${escapeHtml(f.title)}</b> | |
| <ol>${f.steps.map(x => `<li>${escapeHtml(x)}</li>`).join('')}</ol></span>${f.url ? `<button class="secondary" data-settings="${r.id}">Open Settings</button>` : ''}</div>`; }).join('')}</div> | |
| <p class="muted">When you are done, choose <b>Run another audit</b> to check again.</p>` : ''} | |
| ${undoable.length ? `<div class="undo-row"><span class="muted">Last fixes (${new Date(last.at).toLocaleString()}): ${[...new Set(undoable.map(id => fixes[id][os].title))].map(escapeHtml).join(', ')}</span> | |
| <button class="secondary" id="fix-undo">Undo last fixes</button></div>` : ''}`; | |
| $('fixes').classList.remove('hidden'); | |
| const selected = () => [...$('fixes').querySelectorAll('input[data-fix]:checked')].flatMap(i => i.dataset.fix.split(',')); | |
| const updateCount = () => { const b = $('fix-selected'); if (b) { const n = $('fixes').querySelectorAll('input[data-fix]:checked').length; b.textContent = `Fix selected (${n})`; b.disabled = !n; } }; | |
| $('fixes').querySelectorAll('input[data-fix]').forEach(i => i.addEventListener('change', updateCount)); | |
| $('fixes').querySelectorAll('[data-settings]').forEach(b => b.addEventListener('click', () => window.devseis.openFixSettings(b.dataset.settings))); | |
| if ($('fix-selected')) $('fix-selected').addEventListener('click', () => confirmFix('apply', selected())); | |
| if ($('fix-undo')) $('fix-undo').addEventListener('click', () => confirmFix('undo', undoable)); | |
| } | |
| function confirmFix(action, ids) { | |
| if (!ids.length) return; | |
| const os = plan.os; | |
| pendingFix = { action, ids }; | |
| const shown = [...new Map(ids.map(id => [fixes[id][os].title, fixes[id][os]])).values()]; // shared fixes listed once | |
| $('fix-title').textContent = action === 'undo' ? 'Undo these fixes?' : `Fix ${shown.length} problem${shown.length > 1 ? 's' : ''}?`; | |
| $('fix-intro').textContent = action === 'undo' ? 'Arx will put these settings back as they were before, then check this computer again.' | |
| : 'Arx will change these settings, then check this computer again. You can undo most fixes afterwards.'; | |
| $('fix-confirm-list').innerHTML = shown.map(f => { return `<li><b>${escapeHtml(f.title)}</b><br><span class="muted">${escapeHtml(action === 'undo' ? 'Back to the previous setting.' : f.changes)}</span></li>`; }).join(''); | |
| $('fix-password-note').textContent = { macos: 'macOS will ask for your administrator password.', linux: 'Your system will ask for your password (administrator rights).', windows: 'Windows will ask you to allow the change (User Account Control).' }[os] || 'Your computer will ask for your administrator password.'; | |
| $('fix-modal').classList.remove('hidden'); | |
| } | |
| $('fix-cancel').addEventListener('click', () => { pendingFix = null; $('fix-modal').classList.add('hidden'); }); | |
| $('fix-go').addEventListener('click', () => { | |
| const fix = pendingFix; pendingFix = null; $('fix-modal').classList.add('hidden'); | |
| clearInterval(countdownTimer); $('save-modal').classList.add('hidden'); | |
| if (fix) runAudit(fix); | |
| }); | |
| // 5. Results and saving | |
| function showResults(evidence, summary, sources) { | |
| const pills = Object.entries(evidence.summary).map(([k, v]) => `<button type="button" class="pill s-${k}" data-filter="${k}" title="Show these checks">${v} ${STATUS_LABEL[k] || k}</button>`).join(''); | |
| const gaps = summary.top_gaps.map(g => `<li><b class="s-Non-Compliant">${g.risk}</b> — ${escapeHtml(g.name)}: ${escapeHtml(g.found)}</li>`).join(''); | |
| $('result-summary').innerHTML = ` | |
| <div class="row"><div><div class="score">${summary.score.percent}%</div> | |
| <div class="muted">${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant</div></div></div> | |
| <div class="pills">${pills}</div> | |
| <p>${escapeHtml(summary.summary)}</p> | |
| ${gaps ? `<b>Top gaps</b><ul class="gaps">${gaps}</ul>` : ''} | |
| <p class="muted">Findings: ${sources.model} written by the AI auditor, ${sources.template} by the built-in writer.</p>`; | |
| $('saved-note').textContent = ''; | |
| $('result-summary').querySelectorAll('[data-filter]').forEach(b => b.addEventListener('click', () => { | |
| setResultFilter(b.dataset.filter); | |
| $('all-results').scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| })); | |
| show('done'); | |
| } | |
| // 7. All results: every check with its status, and on click the reasons behind it. | |
| const STATUS_LABEL = { Compliant: 'Compliant', 'Non-Compliant': 'Non-Compliant', Pending: 'Pending', Error: 'Error', NotApplicable: 'Not applicable' }; | |
| const STATUS_ORDER = ['Non-Compliant', 'Error', 'Pending', 'Compliant', 'NotApplicable']; | |
| const RISK_RANK = { High: 0, Medium: 1, Low: 2 }; | |
| let resultFilter = 'all'; | |
| function whyText(r) { | |
| const found = r.actual || 'nothing reported', need = r.expected || 'the baseline value'; | |
| if (r.status === 'Compliant') return 'What the audit found meets the requirement, so this check passes.'; | |
| if (r.status === 'Non-Compliant') return 'What the audit found does not meet the requirement, so this check fails.'; | |
| if (r.status === 'Pending') return `Not decided yet: ${r.detail || 'it needs a decision or an answer from you or your organisation'}. Current state: ${found}.`; | |
| if (r.status === 'Error') return `This setting could not be read${r.detail ? `: ${r.detail}` : ''}. Running the audit with administrator rights usually fixes this.`; | |
| return `This check does not apply here: ${r.detail || found}.`; | |
| } | |
| function resultDetails(evidence, r, finding, source) { | |
| let f = finding; | |
| if (!f) { try { f = findingTemplate(evidence, r, lib); } catch { f = null; } } | |
| const guide = lib.guide(r.id) || {}; | |
| const fix = (fixes[r.id] || {})[plan.os]; | |
| const parts = [`<div class="rd-block"><h4>Why it is ${escapeHtml((STATUS_LABEL[r.status] || r.status).toLowerCase())}</h4><p>${escapeHtml(whyText(r))}</p> | |
| <div class="rd-kv"><span>Found</span><b>${escapeHtml(r.actual || '—')}</b><span>Required</span><b>${escapeHtml(r.expected || '—')}</b>${r.detail && r.status !== 'Error' && r.status !== 'NotApplicable' && r.status !== 'Pending' ? `<span>Note</span><b>${escapeHtml(r.detail)}</b>` : ''}</div></div>`]; | |
| if (f && f.finding && r.status !== 'Compliant') parts.push(`<div class="rd-block"><h4>Finding${source === 'model' ? ' <span class="rd-tag">written by the AI auditor</span>' : ''}</h4><p>${escapeHtml(f.finding)}</p></div>`); | |
| if (guide.why && !(f && f.finding && r.status !== 'Compliant' && f.finding.includes(guide.why))) parts.push(`<div class="rd-block"><h4>Why it matters</h4><p>${escapeHtml(guide.why)}</p></div>`); | |
| if (f && f.risk && f.risk.level && !['None', 'Unknown'].includes(f.risk.level)) parts.push(`<div class="rd-block"><h4>Risk</h4><p><b class="risk risk-${escapeHtml(f.risk.level)}">${escapeHtml(f.risk.level)}</b> ${escapeHtml(f.risk.reason || '')}</p></div>`); | |
| if (f && f.recommendation && f.recommendation.length && r.status !== 'Compliant') | |
| parts.push(`<div class="rd-block"><h4>How to fix</h4><ol>${f.recommendation.map(x => `<li>${escapeHtml(x)}</li>`).join('')}</ol>${fix && r.status === 'Non-Compliant' ? `<p class="rd-fixnote">🛠 Arx can help: <b>${escapeHtml(fix.title)}</b> in <i>Fix problems</i> above.</p>` : ''}</div>`); | |
| if (f && f.verification && r.status !== 'Compliant') parts.push(`<div class="rd-block"><h4>Check it worked</h4><p>${escapeHtml(f.verification)}</p></div>`); | |
| const refs = (f && f.references && f.references.length) ? f.references : lib.references(r.id).map(([label]) => label); | |
| if (refs.length) parts.push(`<div class="rd-refs">${refs.map(x => `<span class="ref">${escapeHtml(x)}</span>`).join('')}</div>`); | |
| parts.push(`<p class="muted rd-evidence">${r.evidence === 'self-reported' ? 'Self-reported by the user, not verified on this computer.' : 'Read from this computer by the audit.'} Check id: <code>${escapeHtml(r.id)}</code></p>`); | |
| return parts.join(''); | |
| } | |
| function setResultFilter(filter) { | |
| resultFilter = filter; | |
| $('all-results').querySelectorAll('[data-rf]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.rf === filter))); | |
| $('all-results').querySelectorAll('.res').forEach(d => d.classList.toggle('hidden', filter !== 'all' && d.dataset.status !== filter)); | |
| } | |
| function renderAllResults(evidence, findings, findingSource) { | |
| const risk = r => RISK_RANK[(lib.guide(r.id) || {}).risk?.level] ?? 3; | |
| const order = new Map(evidence.results.map((r, i) => [r.id, i])); | |
| const rows = [...evidence.results].sort((a, b) => (STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)) || (risk(a) - risk(b)) || (order.get(a.id) - order.get(b.id))); | |
| const counts = Object.fromEntries(STATUS_ORDER.map(s => [s, rows.filter(r => r.status === s).length])); | |
| $('all-results').innerHTML = `<h2>All results</h2><p class="muted">Every check of this audit. Click a check to see why it has this status and what to do.</p> | |
| <div class="rf-tabs"><button type="button" data-rf="all">All <span>${rows.length}</span></button>${STATUS_ORDER.filter(s => counts[s]).map(s => `<button type="button" class="rf-${s}" data-rf="${s}">${STATUS_LABEL[s]} <span>${counts[s]}</span></button>`).join('')}</div> | |
| <div class="res-list">${rows.map(r => `<details class="res res-${r.status}" data-status="${r.status}" data-id="${escapeHtml(r.id)}"><summary> | |
| <span class="res-badge">${escapeHtml(STATUS_LABEL[r.status] || r.status)}</span><span class="res-name">${escapeHtml(r.name)}</span><span class="res-found">${escapeHtml(r.actual || '')}</span></summary><div class="res-body"></div></details>`).join('')}</div>`; | |
| // Details are built when a check is opened (fast with 60+ checks). | |
| $('all-results').querySelectorAll('details.res').forEach(d => d.addEventListener('toggle', () => { | |
| const body = d.querySelector('.res-body'); | |
| if (d.open && !body.innerHTML) { const r = evidence.results.find(x => x.id === d.dataset.id); body.innerHTML = resultDetails(evidence, r, findings[r.id], findingSource[r.id]); } | |
| })); | |
| $('all-results').querySelectorAll('[data-rf]').forEach(b => b.addEventListener('click', () => setResultFilter(b.dataset.rf))); | |
| setResultFilter('all'); | |
| $('all-results').classList.remove('hidden'); | |
| } | |
| function escapeHtml(value) { const d = document.createElement('div'); d.textContent = String(value ?? ''); return d.innerHTML; } | |
| function openSaveModal() { | |
| let remaining = SAVE_TIMEOUT_SECONDS; | |
| $('countdown').textContent = remaining; | |
| setBar('countdown-bar', 1); | |
| $('save-modal').classList.remove('hidden'); | |
| clearInterval(countdownTimer); | |
| countdownTimer = setInterval(() => { | |
| remaining -= 1; | |
| $('countdown').textContent = remaining; | |
| setBar('countdown-bar', remaining / SAVE_TIMEOUT_SECONDS); | |
| if (remaining <= 0) save('auto', true); | |
| }, 1000); | |
| } | |
| async function save(mode, automatic = false) { | |
| clearInterval(countdownTimer); | |
| $('save-modal').classList.add('hidden'); | |
| if (!lastReport) return; | |
| $('saved-note').textContent = 'Saving…'; | |
| try { | |
| const result = await window.devseis.saveReport({ ...lastReport, mode }); | |
| if (!result.saved) { | |
| $('saved-note').textContent = 'The report was not saved. Use "Save report as PDF" to save it.'; | |
| return; | |
| } | |
| $('saved-note').innerHTML = ''; | |
| const prefix = document.createTextNode(automatic ? 'No answer after 60 seconds — saved automatically to ' : 'Saved to '); | |
| const link = document.createElement('a'); | |
| link.href = '#'; | |
| link.textContent = result.pdfPath; | |
| link.addEventListener('click', event => { event.preventDefault(); window.devseis.showInFolder(result.pdfPath); }); | |
| $('saved-note').append(prefix, link); | |
| } catch (error) { | |
| $('saved-note').textContent = `Saving failed: ${error.message}`; | |
| } | |
| } | |
| $('run').addEventListener('click', () => runAudit()); | |
| $('cancel').addEventListener('click', () => stopped('Audit cancelled', 'Nothing was checked and nothing was changed.')); | |
| $('restart').addEventListener('click', showApproval); | |
| $('again').addEventListener('click', showApproval); | |
| $('save-again').addEventListener('click', () => save('choose')); | |
| $('ask-chat').addEventListener('click', () => { if (lastReport) window.devseis.openChat(lastReport.evidenceText); }); | |
| $('save-docs').addEventListener('click', () => save('auto')); | |
| $('save-choose').addEventListener('click', () => save('choose')); | |
| prepare(); | |