Interface gráfica: identidade visual própria + redesign
- 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.
This commit is contained in:
1 parent
485f628374
commit
fd3ef5240e
5 files changed
+455
-140
No files matched your search
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
|
||||||
|
|
||||||
|
const ICON_EMPTY = `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
|
||||||
|
|
||||||
|
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 = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<span class="ico">${ICON_WIFI}</span><span><span class="t">${i.name}</span><span class="s">${i.mac}</span></span>`;
|
||||||
|
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 += `<i class="${i < level ? 'on' : ''}" style="height:${heights[i]}px"></i>`;
|
||||||
|
}
|
||||||
|
return `<span class="signal-bars">${bars}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<tr>
|
||||||
|
<td class="ssid">${ap.ssid ? escapeHtml(ap.ssid) : '<em>oculta</em>'}</td>
|
||||||
|
<td class="bssid">${ap.bssid}</td>
|
||||||
|
<td>${ap.channel ?? '—'}</td>
|
||||||
|
<td>${signalBars(ap.last_signal_dbm)}${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>
|
||||||
|
<td><span class="sec-tag ${secClass(ap.security)}">${ap.security || '—'}</span></td>
|
||||||
|
<td>${ap.clients.length}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
els.resultArea.innerHTML = `
|
||||||
|
<div class="ap-card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Rede</th><th>BSSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
})();
|
||||||
@@ -4,154 +4,67 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Sabiá</title>
|
<title>Sabiá</title>
|
||||||
<style>
|
<link rel="icon" href="/sabia-logo.svg">
|
||||||
:root {
|
<link rel="stylesheet" href="/app.css">
|
||||||
--bg: #0f1115; --panel: #171a21; --line: #262b35; --text: #e7e9ee;
|
|
||||||
--muted: #8b93a3; --accent: #3ddc97; --warn: #e2b23d; --danger: #e2543d;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin:0; background:var(--bg); color:var(--text); font: 14px/1.4 system-ui, sans-serif; }
|
|
||||||
header { display:flex; align-items:center; gap:.75rem; padding:1rem 1.25rem; border-bottom:1px solid var(--line); }
|
|
||||||
header h1 { font-size:1.1rem; margin:0; font-weight:600; letter-spacing:.02em; }
|
|
||||||
header h1 span { color:var(--accent); }
|
|
||||||
main { padding:1.25rem; max-width:1080px; margin:0 auto; }
|
|
||||||
.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:1rem 1.1rem; margin-bottom:1.1rem; }
|
|
||||||
.row { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; }
|
|
||||||
select, button { background:#1e222b; color:var(--text); border:1px solid var(--line); border-radius:7px; padding:.5rem .7rem; font-size:.9rem; }
|
|
||||||
button { cursor:pointer; }
|
|
||||||
button.primary { background:var(--accent); color:#06231a; border-color:var(--accent); font-weight:600; }
|
|
||||||
button.danger { background:var(--danger); color:#2a0b06; border-color:var(--danger); }
|
|
||||||
button:disabled { opacity:.45; cursor:not-allowed; }
|
|
||||||
.pill { font-size:.75rem; padding:.15rem .55rem; border-radius:999px; border:1px solid var(--line); color:var(--muted); }
|
|
||||||
.pill.on { color:var(--accent); border-color:var(--accent); }
|
|
||||||
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
|
|
||||||
th, td { text-align:left; padding:.45rem .5rem; border-bottom:1px solid var(--line); font-size:.85rem; }
|
|
||||||
th { color:var(--muted); font-weight:500; text-transform:uppercase; font-size:.7rem; letter-spacing:.04em; }
|
|
||||||
td.sec-open { color:var(--danger); } td.sec-wep { color:var(--warn); } td.sec-wpa, td.sec-wpa2, td.sec-wpa3 { color:var(--accent); }
|
|
||||||
.muted { color:var(--muted); }
|
|
||||||
.warning { background:#241a10; border:1px solid #4a3414; color:#e2b23d; border-radius:8px; padding:.6rem .8rem; font-size:.82rem; margin-bottom:1rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<div class="app">
|
||||||
<h1>Sabi<span>á</span></h1>
|
<aside class="sidebar">
|
||||||
<span class="muted">auditoria de redes Wi-Fi</span>
|
<div class="brand">
|
||||||
</header>
|
<img src="/sabia-logo.svg" alt="">
|
||||||
<main>
|
<div>
|
||||||
<div class="warning">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.</div>
|
<strong>Sabiá</strong>
|
||||||
|
<small>auditoria de Wi-Fi</small>
|
||||||
<div class="panel">
|
|
||||||
<div class="row">
|
|
||||||
<label class="muted">Interface</label>
|
|
||||||
<select id="iface"></select>
|
|
||||||
<span id="mode" class="pill">—</span>
|
|
||||||
<button id="btnMonitor">ativar modo monitor</button>
|
|
||||||
<button id="btnScan" class="primary" disabled>iniciar captura</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="side-title">Interfaces</div>
|
||||||
<div class="row" style="justify-content:space-between">
|
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
|
||||||
|
|
||||||
|
<div class="side-foot">
|
||||||
|
<span id="verInfo" class="mono">—</span>
|
||||||
|
<button id="btnUpdate" type="button">checar atualização</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="page">
|
||||||
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Painel de captura</h1>
|
||||||
|
<div class="sub" id="ifaceSub">selecione uma interface</div>
|
||||||
|
</div>
|
||||||
|
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
|
||||||
|
<path class="flat" d="M0 17 L600 17"/>
|
||||||
|
<path class="live" id="waveLive" d="M0 17 L600 17"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
<span>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 fica registrada na trilha de auditoria local.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="group">
|
||||||
|
<button id="btnMonitor" class="btn" disabled>ativar modo monitor</button>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<button id="btnScan" class="btn primary" disabled>iniciar captura</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-head">
|
||||||
<strong>Redes vistas</strong>
|
<strong>Redes vistas</strong>
|
||||||
<span id="count" class="muted">0</span>
|
<span id="count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
|
||||||
<thead><tr><th>BSSID</th><th>SSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
|
<div id="resultArea"></div>
|
||||||
<tbody id="rows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
</div>
|
||||||
const params = new URLSearchParams(location.search);
|
<script src="/app.js"></script>
|
||||||
const token = params.get('token');
|
|
||||||
let currentIface = null;
|
|
||||||
let monitorOn = false;
|
|
||||||
let scanning = false;
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadIfaces() {
|
|
||||||
const list = await api('/api/ifaces');
|
|
||||||
const sel = document.getElementById('iface');
|
|
||||||
sel.innerHTML = '';
|
|
||||||
for (const i of list) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = i.name;
|
|
||||||
opt.textContent = `${i.name} (${i.mac})`;
|
|
||||||
opt.dataset.mode = i.mode;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
}
|
|
||||||
if (list.length) onIfaceChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onIfaceChange() {
|
|
||||||
const sel = document.getElementById('iface');
|
|
||||||
const opt = sel.selectedOptions[0];
|
|
||||||
currentIface = sel.value;
|
|
||||||
monitorOn = opt && opt.dataset.mode === 'Monitor';
|
|
||||||
refreshModeUi();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshModeUi() {
|
|
||||||
const pill = document.getElementById('mode');
|
|
||||||
pill.textContent = monitorOn ? 'modo monitor' : 'modo managed';
|
|
||||||
pill.className = 'pill' + (monitorOn ? ' on' : '');
|
|
||||||
document.getElementById('btnMonitor').textContent = monitorOn ? 'desativar modo monitor' : 'ativar modo monitor';
|
|
||||||
document.getElementById('btnScan').disabled = !monitorOn;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('iface').addEventListener('change', onIfaceChange);
|
|
||||||
|
|
||||||
document.getElementById('btnMonitor').addEventListener('click', async () => {
|
|
||||||
await api(`/api/monitor&iface=${encodeURIComponent(currentIface)}&enable=${!monitorOn}`.replace('&', '?'), { method: 'POST' });
|
|
||||||
monitorOn = !monitorOn;
|
|
||||||
refreshModeUi();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btnScan').addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('btnScan');
|
|
||||||
if (!scanning) {
|
|
||||||
await api(`/api/scan/start?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
|
|
||||||
scanning = true;
|
|
||||||
btn.textContent = 'parar captura';
|
|
||||||
btn.classList.add('danger');
|
|
||||||
} else {
|
|
||||||
await api(`/api/scan/stop?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
|
|
||||||
scanning = false;
|
|
||||||
btn.textContent = 'iniciar captura';
|
|
||||||
btn.classList.remove('danger');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function secClass(sec) {
|
|
||||||
if (!sec) return '';
|
|
||||||
return 'sec-' + sec.toLowerCase().replace('á', 'a');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
if (scanning && currentIface) {
|
|
||||||
const aps = await api(`/api/scan?iface=${encodeURIComponent(currentIface)}`);
|
|
||||||
const rows = document.getElementById('rows');
|
|
||||||
rows.innerHTML = '';
|
|
||||||
document.getElementById('count').textContent = aps.length;
|
|
||||||
for (const ap of aps) {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = `<td>${ap.bssid}</td><td>${ap.ssid || '<em class="muted">oculto</em>'}</td>` +
|
|
||||||
`<td>${ap.channel ?? '—'}</td><td>${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>` +
|
|
||||||
`<td class="${secClass(ap.security)}">${ap.security || '—'}</td><td>${ap.clients.length}</td>`;
|
|
||||||
rows.appendChild(tr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(poll, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadIfaces();
|
|
||||||
poll();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -13,6 +13,9 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("index.html");
|
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 {
|
struct Session {
|
||||||
table: Arc<Mutex<ScanTable>>,
|
table: Arc<Mutex<ScanTable>>,
|
||||||
@@ -107,6 +110,14 @@ fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) ->
|
|||||||
return respond(&mut stream, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes());
|
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/") {
|
if !req.path.starts_with("/api/") {
|
||||||
return respond(&mut stream, 404, "text/plain", b"not found");
|
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<AppState>, req: &Request) -> String {
|
fn api(state: &Arc<AppState>, req: &Request) -> String {
|
||||||
match (req.method.as_str(), req.path.as_str()) {
|
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") => {
|
("GET", "/api/ifaces") => {
|
||||||
let list = iface::list_wireless().unwrap_or_default();
|
let list = iface::list_wireless().unwrap_or_default();
|
||||||
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
|
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Sabiá"><style>.a{stroke:#b45309}.b{stroke:#0891b2}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#f59e0b}.b{stroke:#22d3ee}.t{fill:#f5f5f7}}</style><defs><clipPath id="s-bird"><path d="M78 14 C70 8 60 6 52 10 C46 6 38 6 32 10 C20 16 12 28 12 42 C12 54 18 64 28 70 C26 76 28 82 34 85 C33 80 35 76 39 74 C46 78 55 79 63 76 C73 72 80 62 81 50 C88 48 93 42 94 34 C87 36 81 34 77 29 C81 25 83 19 78 14 Z"/></clipPath></defs><g clip-path="url(#s-bird)" fill="none" stroke-width="3.6"><path class="a" d="M78 14A6 6 0 0 1 78 26"/><path class="b" d="M78 14A6 6 0 0 0 78 26" stroke-dasharray="3 3"/><path class="a" d="M78 7A13 13 0 0 1 78 33"/><path class="b" d="M78 7A13 13 0 0 0 78 33" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M78 0A20 20 0 0 1 78 40"/><path class="b" d="M78 0A20 20 0 0 0 78 40" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M78 -7A27 27 0 0 1 78 47"/><path class="b" d="M78 -7A27 27 0 0 0 78 47" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M78 -14A34 34 0 0 1 78 54"/><path class="b" d="M78 -14A34 34 0 0 0 78 54" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M78 -21A41 41 0 0 1 78 61"/><path class="b" d="M78 -21A41 41 0 0 0 78 61" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M78 -28A48 48 0 0 1 78 68"/><path class="b" d="M78 -28A48 48 0 0 0 78 68" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M78 -35A55 55 0 0 1 78 75"/><path class="b" d="M78 -35A55 55 0 0 0 78 75" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M78 -42A62 62 0 0 1 78 82"/><path class="b" d="M78 -42A62 62 0 0 0 78 82" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M78 -49A69 69 0 0 1 78 89"/><path class="b" d="M78 -49A69 69 0 0 0 78 89" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M78 -56A76 76 0 0 1 78 96"/><path class="b" d="M78 -56A76 76 0 0 0 78 96" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M78 -63A83 83 0 0 1 78 103"/><path class="b" d="M78 -63A83 83 0 0 0 78 103" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M78 -70A90 90 0 0 1 78 110"/><path class="b" d="M78 -70A90 90 0 0 0 78 110" stroke-dasharray="3 3" stroke-dashoffset="36"/><rect class="t" stroke="none" x="20" y="60" width="64" height="40"/></g><circle class="t" cx="40" cy="36" r="2.6"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
Reference in new issue
Block a user