From fd3ef5240e8d4b62a7c178aef0288bb289528484 Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 23 Oct 2026 08:41:35 -0300 Subject: [PATCH] =?UTF-8?q?Interface=20gr=C3=A1fica:=20identidade=20visual?= =?UTF-8?q?=20pr=C3=B3pria=20+=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logo conceitual em SVG (crates/sabia-cli/src/web/sabia-logo.svg): silhueta de pássaro construída por arcos concêntricos em duas cores (cobre/âmbar + ciano), reinterpretando os arcos como ondas de sinal Wi-Fi — mesma técnica de construção usada no projeto irmão, paleta e silhueta próprias. - Redesign completo da UI local (app.css, app.js, index.html): sidebar translúcida com blur, cards, pills, tipografia SF Pro, linha de sinal animada só enquanto a captura está ativa, barras de sinal por dBm, badges de segurança por cor, dark mode automático. Validado visualmente no Chrome headless (claro, escuro, lista de interfaces, tabela de resultados) via MCP. - Novas rotas /app.css, /app.js, /sabia-logo.svg, /api/version, /api/update/check no servidor local. --- crates/sabia-cli/src/web/app.css | 187 +++++++++++++++++++++++ crates/sabia-cli/src/web/app.js | 194 ++++++++++++++++++++++++ crates/sabia-cli/src/web/index.html | 189 +++++++---------------- crates/sabia-cli/src/web/mod.rs | 20 +++ crates/sabia-cli/src/web/sabia-logo.svg | 1 + 5 files changed, 453 insertions(+), 138 deletions(-) create mode 100644 crates/sabia-cli/src/web/app.css create mode 100644 crates/sabia-cli/src/web/app.js create mode 100644 crates/sabia-cli/src/web/sabia-logo.svg 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 = ` +
+ + + ${rows} +
RedeBSSIDCanalSinalSegurançaClientes
+
`; +} + +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.
- -
-
- - - — - - +
+
-
-
- Redes vistas - 0 +
Interfaces
+
    + +
    + — +
    - - - -
    BSSIDSSIDCanalSinalSegurançaClientes
    -
    -
    - +
    + + + + diff --git a/crates/sabia-cli/src/web/mod.rs b/crates/sabia-cli/src/web/mod.rs index 807195b..bf26188 100644 --- a/crates/sabia-cli/src/web/mod.rs +++ b/crates/sabia-cli/src/web/mod.rs @@ -13,6 +13,9 @@ use std::thread; use std::time::Duration; const INDEX_HTML: &str = include_str!("index.html"); +const APP_CSS: &str = include_str!("app.css"); +const APP_JS: &str = include_str!("app.js"); +const LOGO_SVG: &str = include_str!("sabia-logo.svg"); struct Session { table: Arc>, @@ -107,6 +110,14 @@ fn handle(mut stream: TcpStream, state: &Arc, expected_token: &str) -> return respond(&mut stream, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes()); } + // Assets estáticos: não carregam dado nenhum, então não exigem token. + match req.path.as_str() { + "/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()), + "/app.js" => return respond(&mut stream, 200, "application/javascript", APP_JS.as_bytes()), + "/sabia-logo.svg" => return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes()), + _ => {} + } + if !req.path.starts_with("/api/") { return respond(&mut stream, 404, "text/plain", b"not found"); } @@ -132,6 +143,15 @@ fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std:: fn api(state: &Arc, req: &Request) -> String { match (req.method.as_str(), req.path.as_str()) { + ("GET", "/api/version") => { + format!("{{\"version\":{:?}}}", env!("CARGO_PKG_VERSION")) + } + ("GET", "/api/update/check") => { + match sabia_core::update::check(env!("CARGO_PKG_VERSION")) { + Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()), + Err(e) => format!("{{\"error\":{:?}}}", e.to_string()), + } + } ("GET", "/api/ifaces") => { let list = iface::list_wireless().unwrap_or_default(); serde_json::to_string(&list).unwrap_or_else(|_| "[]".into()) diff --git a/crates/sabia-cli/src/web/sabia-logo.svg b/crates/sabia-cli/src/web/sabia-logo.svg new file mode 100644 index 0000000..fabdb8a --- /dev/null +++ b/crates/sabia-cli/src/web/sabia-logo.svg @@ -0,0 +1 @@ +