feat(4.0.0): web console — lead board + live findings + real CLI REPL

New web/ app (zero npm deps, Node http built-ins only):
- server.js reads agents_md/ to build a categorized lead board (435 agents
  auto-classified into Business Logic / Broken Access Control / Injection /
  LLM Application / Auth & Session / SSRF / API / Cloud & Infra / etc.),
  reads runs/ for history, and spawns the compiled neurosploit CLI binary
  for every exploitation job — structured findings/phase/progress are parsed
  from its stdout (finding_json:/phase lines), same signal the TUI uses.
- REPL drawer spawns `neurosploit` with no subcommand (real interactive
  session, Reader::Plain over the piped stdin) and streams stdin/stdout —
  every /command works exactly as in a terminal, nothing reimplemented.
- SSE endpoints for both job and REPL streams; run/finding/report assets
  served under /api/runs/:id/asset/*.
- public/{index,app.js,style.css}: lead board with category toggles + custom
  leads + Start Exploitation, live run view (progress/findings/log), run
  detail view, REPL drawer — screenshot-inspired layout.
- web/API.md: full endpoint reference. web/README.md: quick start.

Bump version 3.6.9 -> 4.0.0 (Cargo.toml, CLI banners, README/TUTORIAL).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
CyberSecurityUP
2026-08-23 13:59:19 -03:00
co-authored by Claude Sonnet 5
parent 3ddb22ee25
commit d1d1c71e24
14 changed files with 1795 additions and 14 deletions
+441
View File
@@ -0,0 +1,441 @@
'use strict';
/* NeuroSploit v4.0.0 — web console frontend. Vanilla JS, no build step. */
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const state = {
categories: [], // from /api/agents
selected: new Set(), // agent ids toggled on
customLeads: [], // free-text custom leads (folded into --focus)
filter: 'all', // all | selected | excluded
search: '',
runs: [], // from /api/runs
currentJob: null, // {id, es} for the live view
currentDetailId: null, // run id shown in detail view
detailPoll: null,
askFocus: '', askObjective: '', askOutOfScope: '',
replId: null, replEs: null,
};
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function api(path, opts) {
const res = await fetch(path, opts);
if (!res.ok) throw new Error(`${path}${res.status}`);
return res.headers.get('content-type')?.includes('json') ? res.json() : res.text();
}
function sevClass(sev) {
const s = (sev || '').toLowerCase();
if (s.includes('crit')) return 'sev-critical';
if (s.includes('high')) return 'sev-high';
if (s.includes('med')) return 'sev-medium';
if (s.includes('low')) return 'sev-low';
return 'sev-info';
}
function show(el, on) { el.hidden = !on; }
// ---------------------------------------------------------------------------
// agents / lead board
// ---------------------------------------------------------------------------
async function loadAgents() {
const data = await api('/api/agents');
state.categories = data.categories;
renderBoard();
}
function renderBoard() {
const root = $('#categories');
root.innerHTML = '';
for (const group of state.categories) {
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
const card = document.createElement('div');
card.className = 'cat-card';
card.dataset.category = group.category;
card.innerHTML = `
<div class="cat-head">
<label class="switch">
<input type="checkbox" class="cat-toggle" ${selCount === group.agents.length ? 'checked' : ''} />
<span class="track"></span><span class="thumb"></span>
</label>
<span class="cat-name">${esc(group.category)}</span>
<span class="cat-count">${selCount} / ${group.agents.length}</span>
<span class="caret">▾</span>
</div>
<div class="agent-rows"></div>
`;
const rows = card.querySelector('.agent-rows');
for (const a of group.agents) {
const row = document.createElement('div');
row.className = 'agent-row';
row.dataset.id = a.id;
row.dataset.title = (a.title + ' ' + a.name).toLowerCase();
row.innerHTML = `
<label class="switch">
<input type="checkbox" class="agent-toggle" data-id="${esc(a.id)}" ${state.selected.has(a.id) ? 'checked' : ''} />
<span class="track"></span><span class="thumb"></span>
</label>
<span class="agent-title">${esc(a.title)}</span>
${a.cwe ? `<span class="agent-cwe">${esc(a.cwe)}</span>` : ''}
`;
rows.appendChild(row);
}
card.querySelector('.cat-head').addEventListener('click', (e) => {
if (e.target.closest('.switch')) return;
card.classList.toggle('collapsed');
});
card.querySelector('.cat-toggle').addEventListener('change', (e) => {
const on = e.target.checked;
for (const a of group.agents) {
if (on) state.selected.add(a.id); else state.selected.delete(a.id);
}
renderBoard();
updateChips();
});
rows.querySelectorAll('.agent-toggle').forEach((input) => {
input.addEventListener('change', (e) => {
const id = e.target.dataset.id;
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
renderBoard();
updateChips();
applyFilters();
});
});
root.appendChild(card);
}
updateChips();
applyFilters();
}
function allAgents() {
return state.categories.flatMap((g) => g.agents);
}
function updateChips() {
const total = allAgents().length;
$('#chipAll').textContent = total;
$('#chipSelected').textContent = state.selected.size;
$('#chipExcluded').textContent = total - state.selected.size;
}
function applyFilters() {
const q = state.search.trim().toLowerCase();
$$('.agent-row').forEach((row) => {
const id = row.dataset.id;
const isSel = state.selected.has(id);
let visible = true;
if (state.filter === 'selected') visible = isSel;
if (state.filter === 'excluded') visible = !isSel;
if (visible && q) visible = row.dataset.title.includes(q);
row.classList.toggle('hidden-by-search', !visible);
});
$$('.cat-card').forEach((card) => {
const anyVisible = [...card.querySelectorAll('.agent-row')].some((r) => !r.classList.contains('hidden-by-search'));
card.style.display = anyVisible ? '' : 'none';
});
}
// ---------------------------------------------------------------------------
// engagement bar / ask panel
// ---------------------------------------------------------------------------
function currentMode() { return $('#fieldMode').value; }
$('#fieldMode').addEventListener('change', () => {
show($('.eb-repo'), currentMode() === 'greybox');
$('.eb-target label').textContent = currentMode() === 'whitebox' ? 'Repo / path' : 'Target';
});
$('#btnAskSend').addEventListener('click', () => {
const kind = $('#askKind').value;
const text = $('#askInput').value.trim();
if (!text) return;
if (kind === 'focus') state.askFocus = text;
if (kind === 'objective') state.askObjective = text;
if (kind === 'scope-out') state.askOutOfScope = text;
$('#askHint').textContent = `${kind} definido — aplicado no próximo "Start Exploitation"`;
$('#askInput').value = '';
});
$('#btnCustomLead').addEventListener('click', () => {
const text = prompt('Descreva a lead customizada (linguagem livre — vira contexto de foco para os agentes):');
if (text && text.trim()) {
state.customLeads.push(text.trim());
$('#askHint').textContent = `✓ custom lead adicionada (${state.customLeads.length} total)`;
}
});
$$('.chip').forEach((chip) => {
chip.addEventListener('click', () => {
$$('.chip').forEach((c) => c.classList.remove('chip-active'));
chip.classList.add('chip-active');
state.filter = chip.dataset.filter;
applyFilters();
});
});
$('#leadSearch').addEventListener('input', (e) => { state.search = e.target.value; applyFilters(); });
// ---------------------------------------------------------------------------
// start exploitation → live run view
// ---------------------------------------------------------------------------
$('#btnStartExploitation').addEventListener('click', startExploitation);
async function startExploitation() {
const mode = currentMode();
const target = $('#fieldTarget').value.trim();
const repo = $('#fieldRepo').value.trim();
if (mode !== 'whitebox' && !target) { alert('Defina o target.'); return; }
if (mode === 'whitebox' && !target && !repo) { alert('Defina o repo/path.'); return; }
if (mode === 'greybox' && !repo) { alert('Grey-box precisa de repo + target.'); return; }
const modelField = $('#fieldModel').value.trim();
const focusParts = [state.askFocus, ...state.customLeads].filter(Boolean);
const body = {
mode,
target: mode === 'whitebox' ? undefined : target,
repo: mode === 'whitebox' ? (target || repo) : (repo || undefined),
models: modelField ? [modelField] : [],
votes: Number($('#fieldVotes').value) || 3,
chainDepth: Number($('#fieldChain').value),
recon: Number($('#fieldRecon').value),
subscription: $('#fieldSubscription').checked,
mcp: $('#fieldMcp').checked,
agents: [...state.selected],
focus: focusParts.join('; ') || undefined,
objective: state.askObjective || undefined,
outOfScope: state.askOutOfScope || undefined,
};
$('#btnStartExploitation').disabled = true;
try {
const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
attachLiveJob(id, body.target || body.repo);
} catch (e) {
alert('Falha ao iniciar: ' + e.message);
} finally {
$('#btnStartExploitation').disabled = false;
}
}
function attachLiveJob(id, target) {
if (state.currentJob) state.currentJob.es.close();
state.currentJob = { id, es: null, findings: [], target, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null };
show($('#boardView'), false);
show($('#detailView'), false);
show($('#liveView'), true);
$('#liveTarget').textContent = target || '—';
$('#livePhase').textContent = 'starting';
$('#findingsList').innerHTML = '';
$('#logList').innerHTML = '';
$('#findingsCount').textContent = '0';
$('#progressFill').style.width = '0%';
$('#progressLabel').textContent = '0 / 0 agents';
show($('#btnOpenReport'), false);
const es = new EventSource(`/api/exploit/${id}/events`);
state.currentJob.es = es;
es.addEventListener('log', (e) => appendLog(JSON.parse(e.data).line));
es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding));
es.addEventListener('snapshot', (e) => applySnapshot(JSON.parse(e.data)));
es.addEventListener('done', (e) => { applySnapshot(JSON.parse(e.data)); refreshRuns(); });
es.onerror = () => { /* browser auto-retries; fine for a long-running engagement */ };
}
function appendLog(line) {
const div = document.createElement('div');
div.className = 'log-line';
div.textContent = line;
const list = $('#logList');
list.appendChild(div);
list.scrollTop = list.scrollHeight;
}
function addFinding(f) {
state.currentJob.findings.push(f);
const card = document.createElement('div');
card.className = 'finding-card';
card.innerHTML = `
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
`;
$('#findingsList').appendChild(card);
$('#findingsCount').textContent = state.currentJob.findings.length;
}
function applySnapshot(snap) {
$('#livePhase').textContent = snap.phase;
$('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`;
if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
if (snap.reportUrl) {
const link = $('#btnOpenReport');
link.href = `/api/runs/${snap.runId}/asset/report.html`;
show(link, !!snap.runId);
}
if (snap.done) $('#phaseDot').style.background = 'var(--green)';
}
$('#btnStopRun').addEventListener('click', async () => {
if (!state.currentJob) return;
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
});
$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#boardView'), true); });
// ---------------------------------------------------------------------------
// sidebar — runs history
// ---------------------------------------------------------------------------
async function refreshRuns() {
try {
state.runs = await api('/api/runs');
} catch { state.runs = []; }
renderSidebar();
}
function stepClassFor(phase, step) {
const order = ['recon', 'planning', 'exploiting', 'remediation'];
const idx = { recon: 0, starting: 0, planning: 1, exploiting: 2, validating: 2, chaining: 2, complete: 3 }[phase] ?? 0;
const stepIdx = order.indexOf(step);
if (step === 'remediation') return 'pending'; // not automated yet — shown for roadmap parity only
if (stepIdx < idx) return 'done';
if (stepIdx === idx) return 'active';
return 'pending';
}
function renderSidebar() {
const root = $('#sbGroups');
root.innerHTML = '';
const running = state.runs.filter((r) => r.state === 'running');
const completed = state.runs.filter((r) => r.state !== 'running');
const groups = [
{ label: 'Running', items: running, open: true },
{ label: 'Completed', items: completed, open: true },
];
for (const g of groups) {
const wrap = document.createElement('div');
wrap.className = 'sb-group';
wrap.innerHTML = `<div class="sb-group-head"><span class="caret">▾</span><span>${g.label}</span><span class="count">${g.items.length}</span></div><div class="sb-items"></div>`;
wrap.querySelector('.sb-group-head').addEventListener('click', () => wrap.classList.toggle('collapsed'));
const items = wrap.querySelector('.sb-items');
for (const r of g.items) {
const btn = document.createElement('button');
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
btn.innerHTML = `${esc(r.target)}<span class="sub">${esc(r.id)} · ${r.findings} finding(s)</span>`;
btn.addEventListener('click', () => openRun(r));
items.appendChild(btn);
if (r.state === 'running' && state.currentJob) {
const phase = state.currentJob.target === r.target ? $('#livePhase').textContent : null;
const steps = document.createElement('div');
steps.className = 'sb-steps';
steps.innerHTML = ['recon', 'planning', 'exploiting', 'remediation'].map((s) =>
`<div class="sb-step ${stepClassFor(phase || 'recon', s)}">${s[0].toUpperCase() + s.slice(1)}</div>`).join('');
items.appendChild(steps);
}
}
root.appendChild(wrap);
}
}
function openRun(run) {
state.currentDetailId = run.id;
if (run.state === 'running' && state.currentJob) {
show($('#boardView'), false); show($('#detailView'), false); show($('#liveView'), true);
return;
}
show($('#boardView'), false); show($('#liveView'), false); show($('#detailView'), true);
loadDetail(run.id);
renderSidebar();
}
async function loadDetail(id) {
clearInterval(state.detailPoll);
const detail = await api(`/api/runs/${encodeURIComponent(id)}`);
$('#detailTarget').textContent = detail.status?.target || detail.meta?.target || id;
$('#detailState').textContent = detail.status?.state || 'unknown';
const list = $('#detailFindings');
list.innerHTML = detail.findings.length
? detail.findings.map((f) => `
<div class="finding-card">
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
</div>`).join('')
: '<div class="f-meta">nenhum finding validado.</div>';
const reportLink = $('#detailOpenReport');
if (detail.assets.includes('report.html')) {
reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`;
show(reportLink, true);
} else show(reportLink, false);
if (detail.status?.state === 'running') {
state.detailPoll = setInterval(() => loadDetail(id), 4000);
}
}
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#boardView'), true); });
$('#btnNewEngagement').addEventListener('click', () => { show($('#detailView'), false); show($('#liveView'), false); show($('#boardView'), true); });
// ---------------------------------------------------------------------------
// REPL drawer — real CLI harness session
// ---------------------------------------------------------------------------
function openReplDrawer() { show($('#replDrawer'), true); if (!state.replId) startRepl(); }
async function startRepl() {
$('#replOutput').textContent = '';
const { id } = await api('/api/repl', { method: 'POST' });
state.replId = id;
const es = new EventSource(`/api/repl/${id}/events`);
state.replEs = es;
es.addEventListener('data', (e) => {
const { chunk } = JSON.parse(e.data);
const out = $('#replOutput');
out.textContent += chunk;
out.scrollTop = out.scrollHeight;
});
es.addEventListener('close', () => { es.close(); });
}
$('#fabRepl').addEventListener('click', openReplDrawer);
$('#btnOpenRepl').addEventListener('click', openReplDrawer);
$('#btnReplClose').addEventListener('click', () => show($('#replDrawer'), false));
$('#btnReplRestart').addEventListener('click', async () => {
if (state.replId) await api(`/api/repl/${state.replId}/stop`, { method: 'POST' }).catch(() => {});
state.replEs?.close();
state.replId = null;
startRepl();
});
$('#replInput').addEventListener('keydown', async (e) => {
if (e.key !== 'Enter') return;
const line = e.target.value;
e.target.value = '';
const out = $('#replOutput');
out.textContent += `${line}\n`;
out.scrollTop = out.scrollHeight;
if (!state.replId) await startRepl();
await api(`/api/repl/${state.replId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }) });
});
// ---------------------------------------------------------------------------
// boot
// ---------------------------------------------------------------------------
async function boot() {
const meta = await api('/api/meta').catch(() => ({}));
$('#sbMeta').textContent = `v${meta.version || '4.0.0'}`;
await loadAgents();
await refreshRuns();
setInterval(refreshRuns, 6000);
}
boot();
+191
View File
@@ -0,0 +1,191 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NeuroSploit v4.0.0 — Console</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>">
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div class="app">
<!-- ============ SIDEBAR ============ -->
<aside class="sidebar">
<div class="sb-top">
<div class="brand">🧠</div>
<div class="sb-icons">
<button class="icon-btn" id="btnSearchRuns" title="Buscar engagement"></button>
<button class="icon-btn" id="btnCollapseSidebar" title="Recolher">⟨⟩</button>
</div>
</div>
<button class="new-engagement" id="btnNewEngagement">+ Novo engagement</button>
<div class="sb-groups" id="sbGroups">
<!-- populated by app.js -->
</div>
<div class="sb-footer">
<div class="sb-meta" id="sbMeta">v4.0.0</div>
<button class="icon-btn" id="btnOpenRepl" title="Abrir REPL">⌘_</button>
</div>
</aside>
<!-- ============ MAIN ============ -->
<main class="main">
<!-- top bar -->
<header class="topbar">
<div class="search-wrap">
<span class="search-icon"></span>
<input id="leadSearch" type="text" placeholder="Search lead" />
</div>
<div class="chips">
<button class="chip chip-active" data-filter="all">All <span id="chipAll">0</span></button>
<button class="chip" data-filter="selected">Selected <span id="chipSelected">0</span></button>
<button class="chip" data-filter="excluded">Excluded <span id="chipExcluded">0</span></button>
</div>
<div class="topbar-spacer"></div>
<button class="btn btn-ghost" id="btnCustomLead">+ Custom lead</button>
<button class="btn btn-primary" id="btnStartExploitation">Start Exploitation →</button>
</header>
<!-- engagement setup strip -->
<section class="engagement-bar" id="engagementBar">
<div class="eb-field eb-target">
<label>Target</label>
<input id="fieldTarget" type="text" placeholder="https://alvo.com ou owner/repo" />
</div>
<div class="eb-field">
<label>Modo</label>
<select id="fieldMode">
<option value="run">Black-box (run)</option>
<option value="whitebox">White-box</option>
<option value="greybox">Grey-box</option>
<option value="host">Host/Infra</option>
<option value="aitest">AI/LLM</option>
</select>
</div>
<div class="eb-field eb-repo" hidden>
<label>Repo (greybox)</label>
<input id="fieldRepo" type="text" placeholder="owner/repo ou path local" />
</div>
<div class="eb-field eb-narrow">
<label>Modelo</label>
<input id="fieldModel" type="text" placeholder="anthropic:claude-opus-4-8" />
</div>
<div class="eb-field eb-narrow">
<label>Votes</label>
<input id="fieldVotes" type="number" min="1" max="9" value="3" />
</div>
<div class="eb-field eb-narrow">
<label>Recon</label>
<select id="fieldRecon">
<option value="1">1 · quick</option>
<option value="2">2 · standard</option>
<option value="3" selected>3 · deep</option>
<option value="4">4 · exhaustive</option>
</select>
</div>
<div class="eb-field eb-narrow">
<label>Chain</label>
<input id="fieldChain" type="number" min="0" max="5" value="2" />
</div>
<label class="eb-check"><input type="checkbox" id="fieldSubscription" /> subscription</label>
<label class="eb-check"><input type="checkbox" id="fieldMcp" /> MCP</label>
</section>
<!-- ============ BOARD VIEW (lead picker) ============ -->
<section class="board" id="boardView">
<div class="board-scroll" id="boardScroll">
<h2 class="board-title">Set and modify the action plan</h2>
<div class="categories" id="categories"><!-- populated --></div>
</div>
<aside class="ask-panel">
<div class="ask-title">Ask anything</div>
<textarea id="askInput" placeholder="e.g. Prioritize the paths most likely to cause data leak"></textarea>
<div class="ask-row">
<select id="askKind">
<option value="focus">Focus</option>
<option value="objective">Objective</option>
<option value="scope-out">Out of scope</option>
</select>
<button class="btn btn-icon" id="btnAskSend" title="Aplicar"></button>
</div>
<div class="ask-hint" id="askHint"></div>
</aside>
</section>
<!-- ============ LIVE RUN VIEW ============ -->
<section class="liverun" id="liveView" hidden>
<div class="liverun-head">
<div>
<div class="liverun-target" id="liveTarget"></div>
<div class="liverun-phase"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
</div>
<div class="liverun-actions">
<a class="btn btn-ghost" id="btnOpenReport" target="_blank" hidden>Abrir report</a>
<button class="btn btn-danger" id="btnStopRun">Stop</button>
<button class="btn btn-ghost" id="btnBackToBoard">← Board</button>
</div>
</div>
<div class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-label" id="progressLabel">0 / 0 agents</div>
</div>
<div class="liverun-body">
<div class="findings-col">
<div class="col-head">Findings <span id="findingsCount">0</span></div>
<div class="findings-list" id="findingsList"></div>
</div>
<div class="log-col">
<div class="col-head">Activity feed</div>
<div class="log-list" id="logList"></div>
</div>
</div>
</section>
<!-- ============ RUN DETAIL VIEW (past run) ============ -->
<section class="rundetail" id="detailView" hidden>
<div class="liverun-head">
<div>
<div class="liverun-target" id="detailTarget"></div>
<div class="liverun-phase" id="detailState"></div>
</div>
<div class="liverun-actions">
<a class="btn btn-ghost" id="detailOpenReport" target="_blank" hidden>Abrir report</a>
<button class="btn btn-ghost" id="btnDetailBack">← Board</button>
</div>
</div>
<div class="findings-list" id="detailFindings"></div>
</section>
</main>
</div>
<!-- ============ REPL DRAWER ============ -->
<div class="repl-drawer" id="replDrawer" hidden>
<div class="repl-head">
<span>NeuroSploit CLI harness — REPL</span>
<div>
<button class="icon-btn" id="btnReplRestart" title="Reiniciar sessão"></button>
<button class="icon-btn" id="btnReplClose" title="Fechar"></button>
</div>
</div>
<div class="repl-output" id="replOutput"></div>
<div class="repl-input-row">
<span class="repl-prompt"></span>
<input id="replInput" type="text" autocomplete="off" spellcheck="false" placeholder="/help · /run · /status · ou descreva em linguagem natural" />
</div>
</div>
<button class="fab" id="fabRepl" title="Abrir REPL">❭_</button>
<script src="/app.js"></script>
</body>
</html>
+242
View File
@@ -0,0 +1,242 @@
:root {
--bg: #f3efe9;
--panel: #ffffff;
--panel-2: #faf8f5;
--border: #e6e1d8;
--text: #1c1a17;
--text-dim: #7a746a;
--text-faint: #a39d92;
--accent: #1e2a5e;
--accent-2: #2f3f8f;
--accent-soft: #eef0fa;
--gold: #e8b93f;
--gold-soft: #fdf3d9;
--green: #2e8b57;
--red: #c0392b;
--orange: #d97a1f;
--radius: 12px;
--radius-sm: 8px;
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
--shadow: 0 1px 2px rgba(20, 16, 8, 0.04), 0 8px 24px rgba(20, 16, 8, 0.06);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #16151a;
--panel: #1d1c22;
--panel-2: #23222a;
--border: #302f38;
--text: #ecebf0;
--text-dim: #9a97a6;
--text-faint: #6d6a78;
--accent: #6d7fe0;
--accent-2: #8b9af0;
--accent-soft: #262c4a;
--gold: #e8b93f;
--gold-soft: #3a3320;
--green: #4fbf82;
--red: #e0665a;
--orange: #e69a4b;
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.35);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); font-family: var(--sans); }
button { font-family: inherit; cursor: pointer; }
input, select, textarea { font-family: inherit; color: inherit; }
.app { display: flex; height: 100vh; overflow: hidden; }
/* ---------------- sidebar ---------------- */
.sidebar {
width: 260px; flex: none; background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 14px 12px;
}
.sb-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.brand { width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 16px; }
.sb-icons { display: flex; gap: 4px; }
.icon-btn { background: transparent; border: 1px solid transparent; color: var(--text-dim); width: 28px; height: 28px; border-radius: var(--radius-sm); font-size: 13px; }
.icon-btn:hover { background: var(--panel-2); border-color: var(--border); }
.new-engagement {
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
border-radius: var(--radius-sm); padding: 9px 10px; font-size: 13px; text-align: left; margin-bottom: 12px;
}
.new-engagement:hover { border-color: var(--accent); color: var(--accent); }
.sb-groups { flex: 1; overflow-y: auto; }
.sb-group { margin-bottom: 6px; }
.sb-group-head {
display: flex; align-items: center; gap: 6px; padding: 6px 6px; font-size: 12px; color: var(--text-dim);
text-transform: none; cursor: pointer; user-select: none;
}
.sb-group-head .count { margin-left: auto; opacity: .7; }
.sb-group-head .caret { font-size: 10px; transition: transform .15s; }
.sb-group.collapsed .caret { transform: rotate(-90deg); }
.sb-group.collapsed .sb-items { display: none; }
.sb-items { padding-left: 4px; }
.sb-run {
display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text);
padding: 7px 8px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 2px;
}
.sb-run:hover { background: var(--panel-2); }
.sb-run.active { background: var(--gold-soft); color: #7a5a00; font-weight: 600; }
.sb-run .sub {
display: block; font-size: 11px; color: var(--text-faint); font-weight: 400; margin-top: 1px;
}
.sb-steps { padding: 2px 8px 8px 20px; }
.sb-step { font-size: 12px; padding: 3px 0; color: var(--text-faint); display: flex; align-items: center; gap: 6px; }
.sb-step.done { color: var(--text-dim); }
.sb-step.done::before { content: "✓"; color: var(--green); }
.sb-step.active { color: var(--gold); font-weight: 600; }
.sb-step.active::before { content: "◐"; }
.sb-step.pending::before { content: "○"; opacity: .5; }
.sb-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--border); }
.sb-meta { font-size: 11px; color: var(--text-faint); }
/* ---------------- main / topbar ---------------- */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { display: flex; align-items: center; gap: 14px; padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--bg); }
.search-wrap { position: relative; }
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-faint); font-size: 13px; }
#leadSearch {
width: 220px; padding: 8px 10px 8px 28px; border-radius: 999px; border: 1px solid var(--border);
background: var(--panel); color: var(--text); font-size: 13px;
}
.chips { display: flex; gap: 8px; }
.chip {
border: 1px solid var(--border); background: var(--panel); color: var(--text-dim); border-radius: 999px;
padding: 7px 12px; font-size: 12px; display: flex; gap: 6px; align-items: center;
}
.chip span { color: var(--text-faint); }
.chip-active { background: var(--gold-soft); border-color: var(--gold); color: #7a5a00; }
.chip-active span { color: #7a5a00; }
.topbar-spacer { flex: 1; }
.btn { border-radius: var(--radius-sm); border: 1px solid var(--border); padding: 9px 14px; font-size: 13px; background: var(--panel); color: var(--text); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.btn-primary:hover { background: var(--accent-2); }
.btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
.btn-icon { padding: 8px 10px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.engagement-bar {
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; padding: 12px 20px; background: var(--panel);
border-bottom: 1px solid var(--border);
}
.eb-field { display: flex; flex-direction: column; gap: 4px; }
.eb-field label { font-size: 11px; color: var(--text-faint); }
.eb-field input, .eb-field select {
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 7px 9px; font-size: 13px;
background: var(--panel-2); color: var(--text); min-width: 120px;
}
.eb-target input { min-width: 260px; }
.eb-narrow input, .eb-narrow select { width: 88px; min-width: 0; }
.eb-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); margin-bottom: 2px; }
/* ---------------- board ---------------- */
.board { flex: 1; display: flex; overflow: hidden; }
.board-scroll { flex: 1; overflow-y: auto; padding: 18px 20px 40px; }
.board-title { font-size: 15px; font-weight: 600; margin: 4px 0 16px; color: var(--text); }
.categories { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
.cat-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.cat-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; user-select: none; }
.cat-head .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); }
.cat-head .cat-name { font-weight: 600; font-size: 13.5px; flex: 1; }
.cat-head .cat-count { font-size: 12px; color: var(--text-faint); }
.cat-head .caret { font-size: 10px; color: var(--text-faint); transition: transform .15s; }
.cat-card.collapsed .caret { transform: rotate(-90deg); }
.cat-card.collapsed .agent-rows { display: none; }
.agent-rows { border-top: 1px solid var(--border); }
.agent-row { display: flex; align-items: center; gap: 10px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 13px; }
.agent-row:last-child { border-bottom: none; }
.agent-row.hidden-by-search { display: none; }
.agent-row .agent-title { flex: 1; }
.agent-row .agent-cwe { font-size: 11px; color: var(--text-faint); }
.agent-row .agent-frac { font-size: 11px; color: var(--text-faint); width: 46px; text-align: right; }
.agent-row .chevron { color: var(--text-faint); font-size: 12px; }
.switch { position: relative; width: 34px; height: 19px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch .track { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: background .15s; }
.switch .thumb { position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track + .thumb { transform: translateX(15px); }
.ask-panel {
width: 300px; flex: none; border-left: 1px solid var(--border); background: var(--panel); padding: 16px;
display: flex; flex-direction: column; gap: 10px;
}
.ask-title { font-size: 12px; color: var(--text-faint); }
#askInput {
flex: 1; min-height: 90px; resize: vertical; border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 10px; background: var(--panel-2); font-size: 13px;
}
.ask-row { display: flex; gap: 8px; }
.ask-row select { flex: 1; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--panel-2); padding: 8px; font-size: 12px; }
.ask-hint { font-size: 11px; color: var(--text-faint); min-height: 14px; }
/* ---------------- live run / detail ---------------- */
.liverun, .rundetail { flex: 1; display: flex; flex-direction: column; overflow: hidden; padding: 18px 20px; }
.liverun-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.liverun-target { font-weight: 600; font-size: 15px; }
.liverun-phase { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; margin-top: 3px; }
.phase-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gold); animation: pulse 1.4s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.liverun-actions { display: flex; gap: 8px; }
.progress-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.progress-bar { flex: 1; height: 8px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--border); overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width .3s; }
.progress-label { font-size: 12px; color: var(--text-faint); white-space: nowrap; }
.liverun-body { flex: 1; display: flex; gap: 16px; overflow: hidden; }
.findings-col, .log-col { flex: 1; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.col-head { padding: 10px 14px; font-size: 12px; font-weight: 600; color: var(--text-dim); border-bottom: 1px solid var(--border); background: var(--panel-2); }
.findings-list, .log-list { flex: 1; overflow-y: auto; padding: 8px 10px; }
.finding-card { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; margin-bottom: 8px; background: var(--panel-2); }
.finding-card .f-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px; letter-spacing: .03em; }
.sev-critical { background: #f6d9d5; color: #8c2a1c; }
.sev-high { background: #fbe1cf; color: #8c4a10; }
.sev-medium { background: #fdf0c8; color: #7a5a00; }
.sev-low { background: #dcecdd; color: #235c34; }
.sev-info { background: #e2e6f5; color: #2c3a7a; }
.finding-card .f-title { font-size: 13px; font-weight: 600; }
.finding-card .f-meta { font-size: 11px; color: var(--text-faint); margin-top: 3px; }
.log-line { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); padding: 2px 0; white-space: pre-wrap; word-break: break-word; }
/* ---------------- REPL drawer ---------------- */
.repl-drawer {
position: fixed; right: 20px; bottom: 20px; width: 560px; height: 400px; background: #0e0e12; color: #dcdce0;
border-radius: var(--radius); box-shadow: 0 20px 60px rgba(0,0,0,.45); display: flex; flex-direction: column;
overflow: hidden; z-index: 50; border: 1px solid #2a2a33;
}
.repl-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #17171d; font-size: 12px; color: #9a97a6; border-bottom: 1px solid #2a2a33; }
.repl-head .icon-btn { color: #9a97a6; }
.repl-head .icon-btn:hover { background: #22222b; color: #fff; }
.repl-output { flex: 1; overflow-y: auto; padding: 10px 12px; font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; }
.repl-input-row { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid #2a2a33; }
.repl-prompt { color: #6d7fe0; font-family: var(--mono); }
#replInput { flex: 1; background: transparent; border: none; color: #ecebf0; font-family: var(--mono); font-size: 13px; outline: none; }
.fab {
position: fixed; right: 20px; bottom: 20px; width: 52px; height: 52px; border-radius: 50%; background: var(--accent);
color: #fff; border: none; font-family: var(--mono); font-size: 16px; box-shadow: 0 10px 24px rgba(30,42,94,.35);
z-index: 40;
}
.fab:hover { background: var(--accent-2); }
@media (max-width: 900px) {
.ask-panel { display: none; }
.sidebar { width: 210px; }
.repl-drawer { width: calc(100vw - 24px); left: 12px; right: 12px; }
}