diff --git a/crates/sabia-cli/src/web/app.css b/crates/sabia-cli/src/web/app.css
new file mode 100644
index 0000000..4e7bae4
--- /dev/null
+++ b/crates/sabia-cli/src/web/app.css
@@ -0,0 +1,187 @@
+:root {
+ --bg: #f5f5f7;
+ --surface: #ffffff;
+ --surface-2: #fafafc;
+ --side: rgba(246, 246, 248, 0.82);
+ --text: #1d1d1f;
+ --text-2: #6e6e73;
+ --text-3: #8e8e93;
+ --line: rgba(0, 0, 0, 0.09);
+
+ /* "canto" — cobre quente, o peito do sabiá-laranjeira */
+ --accent: #b45309;
+ --accent-ink: #ffffff;
+ --accent-soft: rgba(180, 83, 9, 0.11);
+ /* "sinal" — ciano, a onda que o rádio enxerga */
+ --signal: #0891b2;
+ --signal-soft: rgba(8, 145, 178, 0.12);
+
+ --ok: #1a8f3c;
+ --warn: #b26a00;
+ --bad: #d12b2b;
+ --ok-soft: rgba(26, 143, 60, 0.12);
+ --warn-soft: rgba(178, 106, 0, 0.13);
+ --bad-soft: rgba(209, 43, 43, 0.11);
+
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
+ --radius: 16px;
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI Variable", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
+ --side-w: 280px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #000000;
+ --surface: #1c1c1e;
+ --surface-2: #232326;
+ --side: rgba(28, 28, 30, 0.8);
+ --text: #f5f5f7;
+ --text-2: #a1a1a6;
+ --text-3: #7d7d82;
+ --line: rgba(255, 255, 255, 0.1);
+ --accent: #f59e0b;
+ --accent-ink: #1f1300;
+ --accent-soft: rgba(245, 158, 11, 0.15);
+ --signal: #22d3ee;
+ --signal-soft: rgba(34, 211, 238, 0.14);
+ --ok: #32d74b;
+ --warn: #ffb340;
+ --bad: #ff6961;
+ --ok-soft: rgba(50, 215, 75, 0.15);
+ --warn-soft: rgba(255, 179, 64, 0.15);
+ --bad-soft: rgba(255, 105, 97, 0.15);
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
+ }
+}
+:root[data-theme="dark"] {
+ --bg: #000000; --surface: #1c1c1e; --surface-2: #232326; --side: rgba(28, 28, 30, 0.8);
+ --text: #f5f5f7; --text-2: #a1a1a6; --text-3: #7d7d82; --line: rgba(255, 255, 255, 0.1);
+ --accent: #f59e0b; --accent-ink: #1f1300; --accent-soft: rgba(245, 158, 11, 0.15);
+ --signal: #22d3ee; --signal-soft: rgba(34, 211, 238, 0.14);
+ --ok: #32d74b; --warn: #ffb340; --bad: #ff6961;
+ --ok-soft: rgba(50, 215, 75, 0.15); --warn-soft: rgba(255, 179, 64, 0.15); --bad-soft: rgba(255, 105, 97, 0.15);
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
+}
+:root { color-scheme: light dark; }
+:root[data-theme="light"] { color-scheme: light; }
+:root[data-theme="dark"] { color-scheme: dark; }
+
+* { box-sizing: border-box; }
+html, body { height: 100%; }
+body {
+ margin: 0; background: var(--bg); color: var(--text);
+ font: 15px/1.45 var(--font);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+button { font: inherit; color: inherit; }
+:focus-visible { outline: 3px solid color-mix(in srgb, var(--signal) 55%, transparent); outline-offset: 2px; border-radius: 8px; }
+h1, h2, h3 { margin: 0; letter-spacing: -0.02em; }
+code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
+
+/* ------------------------------------------------------------ layout */
+.app { display: grid; grid-template-columns: var(--side-w) 1fr; height: 100vh; height: 100dvh; }
+.sidebar {
+ background: var(--side);
+ backdrop-filter: saturate(180%) blur(24px);
+ -webkit-backdrop-filter: saturate(180%) blur(24px);
+ border-right: 1px solid var(--line);
+ display: flex; flex-direction: column; min-height: 0; overflow-y: auto;
+ padding: 18px 12px 12px;
+}
+.brand { display: flex; gap: 10px; align-items: center; padding: 4px 10px 20px; }
+.brand img { width: 34px; height: 34px; }
+.brand strong { display: block; font-size: 17px; letter-spacing: -0.02em; }
+.brand small { color: var(--text-2); font-size: 12px; }
+
+.side-title { padding: 14px 10px 6px; font-size: 11px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .07em; }
+.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
+.list:empty::after { content: attr(data-empty); display: block; padding: 6px 10px; color: var(--text-3); font-size: 13px; }
+.dev {
+ width: 100%; text-align: left; border: 0; background: transparent; cursor: pointer;
+ display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: center;
+ padding: 9px 10px; border-radius: 12px; transition: background .15s;
+}
+.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
+.dev[aria-current="true"] { background: var(--accent-soft); }
+.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
+.dev[aria-current="true"] .ico { color: var(--accent); }
+.dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
+.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
+.side-foot button:hover { color: var(--text-2); }
+
+.main { overflow-y: auto; min-width: 0; }
+.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
+@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+
+/* -------------------------------------------------------- topo + linha de sinal */
+.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.topbar h1 { font-size: 21px; }
+.topbar .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; }
+.wave { width: 100%; height: 34px; margin: 18px 0 26px; }
+.wave path { fill: none; stroke: var(--line); stroke-width: 1.6; }
+.wave path.live { stroke: var(--signal); opacity: 0; transition: opacity .25s; }
+.wave.scanning path.live { opacity: 1; }
+.wave.scanning path.flat { opacity: 0; }
+
+/* ------------------------------------------------------------ aviso */
+.notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; }
+.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
+
+/* ------------------------------------------------------------ controles */
+.controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
+.controls .group { display: flex; align-items: center; gap: 8px; }
+.pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); }
+.pill.on { background: var(--signal-soft); color: var(--signal); }
+.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
+.pill.on .dot { animation: pulse 1.6s ease-in-out infinite; }
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
+
+.btn { border: 0; border-radius: 999px; padding: 9px 18px; font-weight: 600; font-size: 13.5px; cursor: pointer; background: color-mix(in srgb, var(--text) 9%, transparent); transition: transform .12s, filter .15s, background .15s; display: inline-flex; gap: 8px; align-items: center; }
+.btn:hover { background: color-mix(in srgb, var(--text) 14%, transparent); }
+.btn:active { transform: scale(.97); }
+.btn:disabled { opacity: .4; cursor: not-allowed; }
+.btn.primary { background: var(--accent); color: var(--accent-ink); }
+.btn.primary:hover { filter: brightness(1.06); }
+.btn.live { background: var(--bad); color: #fff; }
+.btn.live:hover { filter: brightness(1.06); }
+
+/* ------------------------------------------------------------ tabela */
+.result-head { display: flex; align-items: baseline; justify-content: space-between; margin: 4px 2px 10px; }
+.result-head strong { font-size: 15px; }
+.result-head span { color: var(--text-3); font-size: 12.5px; }
+
+.ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
+table { width: 100%; border-collapse: collapse; }
+thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); }
+tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; }
+tbody tr:last-child td { border-bottom: 0; }
+tbody tr:hover { background: var(--surface-2); }
+td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
+td.ssid { font-weight: 600; }
+td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
+.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
+.sec-aberta { background: var(--bad-soft); color: var(--bad); }
+.sec-wep { background: var(--warn-soft); color: var(--warn); }
+.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
+.signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; }
+.signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; }
+.signal-bars i.on { background: var(--signal); }
+
+.empty-state { text-align: center; padding: 70px 20px; color: var(--text-2); }
+.empty-state svg { color: var(--text-3); margin-bottom: 14px; }
+.empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
+.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
+
+/* ------------------------------------------------------------ responsivo */
+@media (max-width: 760px) {
+ .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
+ .sidebar { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; align-items: center; overflow-x: auto; }
+ .brand { padding: 4px 10px; }
+ .side-title, .side-foot { display: none; }
+ .list { display: flex; }
+}
diff --git a/crates/sabia-cli/src/web/app.js b/crates/sabia-cli/src/web/app.js
new file mode 100644
index 0000000..e20fdba
--- /dev/null
+++ b/crates/sabia-cli/src/web/app.js
@@ -0,0 +1,194 @@
+const params = new URLSearchParams(location.search);
+const token = params.get('token');
+
+const els = {
+ list: document.getElementById('ifaceList'),
+ ifaceSub: document.getElementById('ifaceSub'),
+ modePill: document.getElementById('modePill'),
+ modeLabel: document.getElementById('modeLabel'),
+ btnMonitor: document.getElementById('btnMonitor'),
+ btnScan: document.getElementById('btnScan'),
+ resultArea: document.getElementById('resultArea'),
+ count: document.getElementById('count'),
+ wave: document.getElementById('wave'),
+ verInfo: document.getElementById('verInfo'),
+ btnUpdate: document.getElementById('btnUpdate'),
+};
+
+let ifaces = [];
+let current = null;
+let scanning = false;
+
+const ICON_WIFI = ` `;
+
+const ICON_EMPTY = ` `;
+
+async function api(path, opts) {
+ const url = new URL(path, location.origin);
+ url.searchParams.set('token', token);
+ const r = await fetch(url, opts);
+ return r.json();
+}
+
+function renderEmptyResult(message, hint) {
+ els.resultArea.innerHTML = `
${ICON_EMPTY}
${message} ${hint}
`;
+}
+
+function renderIfaceList() {
+ els.list.innerHTML = '';
+ for (const i of ifaces) {
+ const li = document.createElement('li');
+ const btn = document.createElement('button');
+ btn.className = 'dev';
+ btn.setAttribute('aria-current', String(i.name === current));
+ btn.innerHTML = `${ICON_WIFI} ${i.name} ${i.mac} `;
+ btn.addEventListener('click', () => selectIface(i.name));
+ li.appendChild(btn);
+ els.list.appendChild(li);
+ }
+}
+
+function selectIface(name) {
+ current = name;
+ scanning = false;
+ renderIfaceList();
+ refreshModeUi();
+ renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
+}
+
+function refreshModeUi() {
+ const iface = ifaces.find((i) => i.name === current);
+ const on = !!iface && iface.mode === 'Monitor';
+ els.ifaceSub.textContent = current ? `${current} · ${iface?.mac ?? ''}` : 'selecione uma interface';
+ els.modeLabel.textContent = on ? 'modo monitor' : 'modo gerenciado';
+ els.modePill.className = 'pill' + (on ? ' on' : '');
+ els.btnMonitor.disabled = !current;
+ els.btnMonitor.textContent = on ? 'desativar modo monitor' : 'ativar modo monitor';
+ els.btnScan.disabled = !on;
+}
+
+async function loadIfaces() {
+ ifaces = await api('/api/ifaces');
+ if (!current && ifaces.length) current = ifaces[0].name;
+ renderIfaceList();
+ refreshModeUi();
+ if (current) renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
+ else renderEmptyResult('Nenhuma interface Wi-Fi', 'Conecte um adaptador compatível com modo monitor e recarregue.');
+}
+
+els.btnMonitor.addEventListener('click', async () => {
+ const iface = ifaces.find((i) => i.name === current);
+ const enable = !(iface && iface.mode === 'Monitor');
+ els.btnMonitor.disabled = true;
+ const res = await api(`/api/monitor?iface=${encodeURIComponent(current)}&enable=${enable}`, { method: 'POST' });
+ if (!res.ok) {
+ alert('Não foi possível trocar o modo: ' + (res.error || 'erro desconhecido'));
+ }
+ await loadIfaces();
+});
+
+els.btnScan.addEventListener('click', async () => {
+ if (!scanning) {
+ await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
+ scanning = true;
+ els.btnScan.textContent = 'parar captura';
+ els.btnScan.classList.add('live');
+ els.wave.classList.add('scanning');
+ } else {
+ await api(`/api/scan/stop?iface=${encodeURIComponent(current)}`, { method: 'POST' });
+ scanning = false;
+ els.btnScan.textContent = 'iniciar captura';
+ els.btnScan.classList.remove('live');
+ els.wave.classList.remove('scanning');
+ }
+});
+
+els.btnUpdate.addEventListener('click', async () => {
+ els.btnUpdate.textContent = 'checando…';
+ const status = await api('/api/update/check');
+ els.btnUpdate.textContent = status.update_available
+ ? `atualizar para ${status.latest_version}`
+ : 'já atualizado';
+});
+
+function secClass(sec) {
+ if (!sec) return '';
+ return 'sec-' + sec.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
+}
+
+function signalBars(dbm) {
+ // dBm tipicamente entre -30 (ótimo) e -90 (péssimo)
+ let level = 0;
+ if (dbm != null) {
+ if (dbm >= -55) level = 4;
+ else if (dbm >= -65) level = 3;
+ else if (dbm >= -75) level = 2;
+ else level = 1;
+ }
+ const heights = [5, 8, 11, 14];
+ let bars = '';
+ for (let i = 0; i < 4; i++) {
+ bars += ` `;
+ }
+ return `${bars} `;
+}
+
+function renderResults(aps) {
+ els.count.textContent = aps.length;
+ if (!aps.length) {
+ renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.');
+ return;
+ }
+ const rows = aps.map((ap) => `
+
+ ${ap.ssid ? escapeHtml(ap.ssid) : 'oculta '}
+ ${ap.bssid}
+ ${ap.channel ?? '—'}
+ ${signalBars(ap.last_signal_dbm)}${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}
+ ${ap.security || '—'}
+ ${ap.clients.length}
+ `).join('');
+ els.resultArea.innerHTML = `
+
+
+ Rede BSSID Canal Sinal Segurança Clientes
+ ${rows}
+
+
`;
+}
+
+function escapeHtml(s) {
+ const d = document.createElement('div');
+ d.textContent = s;
+ return d.innerHTML;
+}
+
+let waveT = 0;
+function animateWave() {
+ waveT += 0.12;
+ if (scanning) {
+ let d = 'M0 17 ';
+ for (let x = 0; x <= 600; x += 12) {
+ const y = 17 + Math.sin(x * 0.045 + waveT) * 9 * Math.sin(waveT * 0.6 + x * 0.002);
+ d += `L${x} ${y.toFixed(1)} `;
+ }
+ document.getElementById('waveLive').setAttribute('d', d);
+ }
+ requestAnimationFrame(animateWave);
+}
+
+async function poll() {
+ if (scanning && current) {
+ const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
+ renderResults(aps);
+ }
+ setTimeout(poll, 1000);
+}
+
+(async function init() {
+ const status = await api('/api/version');
+ els.verInfo.textContent = `v${status.version}`;
+ await loadIfaces();
+ animateWave();
+ poll();
+})();
diff --git a/crates/sabia-cli/src/web/index.html b/crates/sabia-cli/src/web/index.html
index fe1df0b..a6674ba 100644
--- a/crates/sabia-cli/src/web/index.html
+++ b/crates/sabia-cli/src/web/index.html
@@ -4,154 +4,67 @@
Sabiá
-
+
+
-
- Sabiá
- auditoria de redes Wi-Fi
-
-
- Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura é registrada na trilha de auditoria local.
-
-
-
-
Interface
-
-
—
-
ativar modo monitor
-
iniciar captura
+
+
-
-
-
Redes vistas
-
0
+
Interfaces
+
+
+
-
- BSSID SSID Canal Sinal Segurança Clientes
-
-
-
-
-
+
+
+
+
+