/* app.js — Núcleo do dashboard: autenticação, navegação e renderização das páginas (Visão geral, Serviços, Acesso remoto, Documentação, Administração). */ (() => { const NAV = [ { id: "overview", label: "Visão geral", crumb: "Estado do servidor e dos serviços" }, { id: "services", label: "Serviços", crumb: "Iniciar, parar, reiniciar e logs" }, { id: "remote", label: "Acesso remoto", crumb: "URLs locais/LAN e alcançabilidade" }, { id: "profiles", label: "Perfis", crumb: "Perfis de serviços: requisitos, modelos e uso remoto" }, { id: "benchmarks", label: "Benchmarks", crumb: "Executar, acompanhar e ver resultados de benchmarks" }, { id: "reports", label: "Relatórios", crumb: "Comparativos de benchmarks salvos" }, { id: "docs", label: "Documentação", crumb: "Guias e referências do projeto" }, { id: "admin", label: "Administração", crumb: "Saúde, disco, versão e manutenção", adminOnly: true }, { id: "users", label: "Usuários", crumb: "Gestão de acesso remoto", adminOnly: true }, { id: "logs", label: "Logs", crumb: "Auditoria de acesso", adminOnly: true }, ]; const state = { page: "overview", user: null, docName: null, isAdmin: false }; const content = () => document.getElementById("content"); // Polling de página: um único timer, trocado a cada navegação. Páginas de // status (Visão geral, Benchmarks) o re-armam ao final do seu render. let pagePoll = null; function stopPoll() { if (pagePoll) { clearInterval(pagePoll); pagePoll = null; } } function armPoll(ms, fn) { stopPoll(); pagePoll = setInterval(fn, ms); } // ------------------------------------------------------------------ auth async function boot() { loadTagline(); try { const me = await API.me(); if (me.authenticated) { // Usuário "só-chat" (sem painel) é mandado para a HOME dos chats. if (!me.can_dashboard && !me.is_admin) { location.href = "/home"; return; } showApp(me.user, me.is_admin); if (me.must_change_password) showChangePasswordModal(); } else { showLogin(); } } catch { showLogin(); } } // Tagline rotativa (login + rodapé da sidebar). Sorteada pelo backend a cada // carregamento de página. Falha silenciosa: a tagline é puramente decorativa. async function loadTagline() { try { const r = await fetch("/api/tagline"); if (!r.ok) return; const d = await r.json(); if (!d.tagline) return; document.querySelectorAll("[data-tagline]").forEach((el) => { el.textContent = d.tagline; }); } catch { /* decorativo */ } } function showLogin() { state.isAdmin = false; document.getElementById("login").style.display = "grid"; document.getElementById("app").classList.add("hidden"); } function showApp(user, isAdmin) { state.user = user; state.isAdmin = !!isAdmin; document.getElementById("login").style.display = "none"; document.getElementById("app").classList.remove("hidden"); document.getElementById("user-name").textContent = user; document.getElementById("user-avatar").textContent = (user[0] || "?").toUpperCase(); buildNav(); navigate(location.hash.replace("#", "") || "overview"); } document.getElementById("login-form").addEventListener("submit", async (e) => { e.preventDefault(); const btn = document.getElementById("login-btn"); const err = document.getElementById("login-error"); err.classList.remove("show"); btn.disabled = true; btn.textContent = "Entrando…"; try { const u = document.getElementById("username").value.trim(); const p = document.getElementById("password").value; const res = await API.login(u, p); document.getElementById("password").value = ""; // Usuário "só-chat" (sem painel) vai direto para a HOME dos chats. if (!res.can_dashboard && !res.is_admin) { location.href = "/home"; return; } showApp(res.user, res.is_admin); if (res.must_change_password) showChangePasswordModal(); } catch (ex) { err.textContent = ex.message || "Falha no login."; err.classList.add("show"); } finally { btn.disabled = false; btn.textContent = "Entrar"; } }); document.getElementById("btn-logout").addEventListener("click", async () => { try { await API.logout(); } catch {} location.hash = ""; showLogin(); }); // ------------------------------------------------------------------ nav function buildNav() { const nav = document.getElementById("nav"); nav.innerHTML = NAV .filter((n) => !n.adminOnly || state.isAdmin) .map((n) => `` ).join(""); nav.querySelectorAll(".nav-item").forEach((b) => b.addEventListener("click", () => navigate(b.dataset.page))); } function navigate(page) { const navItem = NAV.find((n) => n.id === page); if (!navItem || (navItem.adminOnly && !state.isAdmin)) page = "overview"; state.page = page; location.hash = page; const meta = NAV.find((n) => n.id === page); document.getElementById("page-title").textContent = meta.label; document.getElementById("page-crumb").textContent = meta.crumb; document.querySelectorAll(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.page === page)); document.getElementById("sidebar").classList.remove("open"); render(); } window.addEventListener("hashchange", () => { const p = location.hash.replace("#", ""); if (p && p !== state.page) navigate(p); }); document.getElementById("btn-refresh").addEventListener("click", () => render()); document.getElementById("menu-toggle").addEventListener("click", () => document.getElementById("sidebar").classList.toggle("open")); // ------------------------------------------------------------------ render async function render() { stopPoll(); // navegação/refresh manual cancela o polling da página anterior const ico = document.getElementById("refresh-ico"); ico.classList.add("spin"); content().innerHTML = `
Carregando…
`; try { await Pages[state.page](); } catch (ex) { if (ex.status === 401) { showLogin(); return; } content().innerHTML = `
Erro ao carregar: ${UI.escapeHtml(ex.message)}
`; } finally { ico.classList.remove("spin"); } } // chip do host removido do cabeçalho; mantém no-op seguro para os chamadores. function setHostChip(text) { const el = document.getElementById("host-chip-text"); if (el) el.textContent = text; } // ------------------------------------------------------------------ pages const Pages = {}; // Bloco de status de um benchmark em execução (compartilhado: visão geral e // página de Benchmarks). opts.onBenchmarks troca a ação de cabeçalho. function runningBenchBlock(bench, opts = {}) { if (!bench) return ""; const cur = bench.current; if (!cur || (cur.status !== "running" && cur.status !== "queued")) { // Mesmo sem job atual, sinaliza fila pendente (raro, mas informativo). if (!bench.queued) return ""; } const job = cur || {}; const pr = job.progress; const pct = pr && pr.total ? Math.round(pr.done / pr.total * 100) : null; const elapsed = job.startedAt ? (Date.now() - Date.parse(job.startedAt)) / 1000 : null; const queuedExtra = bench.queued > 0 ? ` · ${bench.queued} na fila` : ""; const progLine = pct != null ? `
${pr.done}/${pr.total} tarefas · ${pct}%
` : `
carregando modelo / preparando…
`; // Ação no cabeçalho: na própria página de Benchmarks, Cancelar (reaproveita o // wiring data-bm-cancel da página); na visão geral, atalho para os Benchmarks. const headAction = opts.onBenchmarks ? (job.id ? `` : "") : ``; return `
${UI.icon("benchmarks")}

Benchmark em andamento

${UI.benchBadge(job.status || "queued")} ${headAction}
${UI.escapeHtml(job.profileName || "—")} ${UI.escapeHtml(job.typeLabel || "—")} ${job.linkedService ? `${UI.escapeHtml(UI.svcLabel(job.linkedService))}` : ""} decorrido ${UI.duration(elapsed)}${queuedExtra}
${progLine}
`; } // ---------- Visão geral ---------- Pages.overview = async () => { const [d, bench, diskData, vpsData] = await Promise.all([ API.overview(), API.benchmarks().catch(() => null), API.adminDisk().catch(() => null), API.vpsStats().catch(() => null), ]); const wanHost = d.host.wan_hostname; setHostChip( wanHost ? `${d.host.hostname || "host"} · ${d.host.lan_ip} · ${wanHost}` : `${d.host.hostname || "host"} · ${d.host.lan_ip}` ); const mem = d.host.memory; const memPct = mem.total ? Math.round(mem.used / mem.total * 100) : 0; const load1 = d.host.loadavg[0]; const loadPct = d.host.cpu_count ? Math.min(100, Math.round(load1 / d.host.cpu_count * 100)) : 0; const gpu = d.gpu[0]; const gpuMemPct = gpu && gpu.memory_total_mib ? Math.round(gpu.memory_used_mib / gpu.memory_total_mib * 100) : 0; const fans = d.cpu_fans || []; const cpuTemp = d.cpu_temp || { max: null, packages: [] }; const cpuTempTxt = cpuTemp.max != null ? ` · ${Math.round(cpuTemp.max)}°C` : ""; const stat = (label, value, sub, ico, bar) => `
${UI.icon(ico, 18)}
${label}
${value}
${sub || ""}
${bar != null ? `
` : ""}
`; let html = `
${stat("CPU (load 1m)", load1.toFixed(2), `${d.host.cpu_count} threads · ${loadPct}%${cpuTempTxt}`, "cpu", loadPct)} ${stat("Memória", UI.bytes(mem.used), `de ${UI.bytes(mem.total)} · ${memPct}%`, "mem", memPct)} ${gpu ? stat("GPU", `${gpu.utilization != null ? gpu.utilization + "%" : "—"}`, `${gpu.name} · ${gpu.memory_used_mib ? UI.bytes(gpu.memory_used_mib * 1048576) : "—"}/${gpu.memory_total_mib ? UI.bytes(gpu.memory_total_mib * 1048576) : "—"}` + (gpu.temperature_c != null ? ` · ${gpu.temperature_c}°C` : "") + (gpu.power_w != null ? ` · ${gpu.power_w} W` : ""), "gpu", gpu.utilization != null ? gpu.utilization : gpuMemPct) : stat("GPU", "—", "nenhuma GPU detectada", "gpu")} ${stat("Fans CPU", fans.length ? fans.map((f) => `${Math.round(f.rpm)}`).join(" / ") + " rpm" : "—", fans.length ? fans.map((f) => f.label).join(" · ") : "sem leitura (sensors)", "fan")}
`; // Uptime + serviços resumo html += `
${stat("Uptime", UI.uptime(d.host.uptime_seconds), "tempo ligado", "clock")} ${stat("Serviços ativos", `${d.services.running} / ${d.services.total}`, "llama-server", "services")} ${stat("Rede", d.host.lan_ip, "IP da LAN", "remote")}
`; // Bloco de benchmark em execução (logo abaixo dos cards de status). html += runningBenchBlock(bench); // Bloco VPS (carregado em paralelo via API.vpsStats) if (wanHost) { const vps = vpsData || {}; const hasStats = vps.uptime_seconds != null; const vpsOnline = !vps.ssh_error && hasStats; const memPctVps = (vps.memory_total_mb && vps.memory_used_mb) ? Math.round(vps.memory_used_mb / vps.memory_total_mb * 100) : null; const loadVps = vps.load1 != null ? vps.load1.toFixed(2) : "—"; const hostSub = [ vps.ip || "—", vpsOnline ? `up ${UI.uptime(vps.uptime_seconds)}` : null, ].filter(Boolean).join(" · "); html += `
${UI.icon("remote")}

VPS relay

${vps.ssh_error ? `${vps.ssh_error === "timeout" ? "timeout SSH" : "offline"}` : vpsOnline ? `online` : `carregando…`}
${stat("Host", UI.escapeHtml(wanHost), UI.escapeHtml(hostSub), "remote")} ${stat("CPU (load 1m)", loadVps, vps.load5 != null ? `5m: ${vps.load5.toFixed(2)}` : "—", "cpu", vps.load1 != null ? Math.min(100, Math.round(vps.load1 * 100)) : null)} ${hasStats && memPctVps != null ? stat("RAM VPS", UI.bytes(vps.memory_used_mb * 1048576), `de ${UI.bytes(vps.memory_total_mb * 1048576)} · ${memPctVps}%`, "mem", memPctVps) : stat("RAM VPS", "—", "aguardando SSH…", "mem")}
`; } // Tabela de serviços html += `
${UI.icon("services")}

Serviços

${d.services.items.map((s) => ``).join("")}
ServiçoTipoPortaEstado
${UI.escapeHtml(UI.svcLabel(s.name))}
${UI.escapeHtml(s.description)}
${UI.escapeHtml(s.type || s.backend || "")} ${s.port} ${UI.stateBadge(s.state, s.healthy)} ${s.state === "active" ? `${UI.icon("link", 14)} LAN` + (s.wan_url ? ` ${UI.icon("remote", 14)} WAN` : "") : ""}
`; // Disco livre if (diskData && diskData.items && diskData.items.length) { html += `
${UI.icon("disk")}

Espaço em disco

${diskData.items.map((x) => `
${UI.escapeHtml(x.path)}
${UI.bytes(x.free)} livres de ${UI.bytes(x.total)} · ${x.percent}%
`).join("")}
`; } content().innerHTML = html; // Auto-refresh leve enquanto a página estiver aberta (mantém métricas e o // bloco de benchmark vivos sem recarregar manualmente). armPoll(5000, () => { if (state.page === "overview") Pages.overview(); }); }; // ---------- Benchmarks ---------- // Modo de comparação: estado local da página (não sobrevive à navegação). let bmCompareMode = false; let bmSelected = new Set(); let bmComparePreview = null; // { jobIds, data } quando a comparação está sendo exibida let bmFilterType = ""; let bmFilterStatus = ""; let bmSortCol = "createdAt"; let bmSortDir = "desc"; Pages.benchmarks = async () => { if (bmComparePreview) return renderComparePreview(); const [data, typesData] = await Promise.all([ API.benchmarks(), API.benchmarkTypes().catch(() => null), ]); const items = data.items || []; const anyActive = items.some((j) => j.status === "running" || j.status === "queued"); const doneCount = items.filter((j) => j.status === "done").length; const allTypes = (typesData && typesData.items) || []; let html = `

Benchmarks (${items.length})

Execute benchmarks pela página Perfis. A fila roda um por vez.
${bmCompareMode ? `${bmSelected.size} selecionado(s) ` : ` `}
`; // Filtros (visíveis quando há itens) if (items.length > 0) { const statusOpts = [ { v: "done", l: "Concluído" }, { v: "error", l: "Erro" }, { v: "canceled", l: "Cancelado" }, { v: "queued", l: "Na fila" }, { v: "running", l: "Em execução" }, ]; html += `
Tipo: Status: ${(bmFilterType || bmFilterStatus) ? `` : ""}
`; } // Teste em execução: exibido como o card da Visão geral (não como linha da // tabela), evitando duplicação. Fila/concluídos/erro continuam na tabela. html += runningBenchBlock(data, { onBenchmarks: true }); const currentId = data.current && data.current.status === "running" ? data.current.id : null; const allRows = currentId ? items.filter((j) => j.id !== currentId) : items; // "Completo" é expandido em sub-linhas por tipo (exceto no modo comparação, // que depende de IDs únicos por checkbox). const expandedRows = bmCompareMode ? allRows : _expandCompleto(allRows); // Filtragem e ordenação const filteredRows = expandedRows.filter((j) => { if (bmFilterType && j.typeId !== bmFilterType) return false; if (bmFilterStatus && j.status !== bmFilterStatus) return false; return true; }); const sortedRows = _sortBenchRows(filteredRows, bmSortCol, bmSortDir); const srtTh = (label, col) => `${label} ` + (bmSortCol === col ? (bmSortDir === 'asc' ? '↑' : '↓') : '') + ``; if (!items.length) { html += `
Nenhum benchmark ainda. Vá em "Perfis" e clique em "Executar benchmark".
`; } else if (sortedRows.length) { html += `
${bmCompareMode ? "" : ""} ${srtTh("Perfil", "profile")}${srtTh("Tipo", "type")}${srtTh("Status", "status")} ${srtTh("Criado", "createdAt")}${srtTh("Iniciado", "startedAt")}${srtTh("Finalizado", "finishedAt")}${srtTh("Duração", "duration")} ${sortedRows.map((j) => benchRow(j)).join("")}
`; } else { html += `
Nenhum benchmark corresponde aos filtros selecionados.
`; } content().innerHTML = html; if (bmCompareMode) { document.getElementById("bm-cancel-compare").onclick = () => { bmCompareMode = false; bmSelected.clear(); Pages.benchmarks(); }; document.getElementById("bm-show-compare").onclick = () => onShowComparison(); content().querySelectorAll("[data-bm-select]").forEach((cb) => (cb.onchange = () => { const id = cb.dataset.bmSelect; if (cb.checked) bmSelected.add(id); else bmSelected.delete(id); Pages.benchmarks(); })); } else { document.getElementById("bm-reload").onclick = () => Pages.benchmarks(); document.getElementById("bm-compare").onclick = () => { bmCompareMode = true; Pages.benchmarks(); }; } // Filtros const ftType = document.getElementById("bm-filter-type"); if (ftType) ftType.onchange = () => { bmFilterType = ftType.value; Pages.benchmarks(); }; const ftStatus = document.getElementById("bm-filter-status"); if (ftStatus) ftStatus.onchange = () => { bmFilterStatus = ftStatus.value; Pages.benchmarks(); }; const clearBtn = document.getElementById("bm-clear-filters"); if (clearBtn) clearBtn.onclick = () => { bmFilterType = ""; bmFilterStatus = ""; Pages.benchmarks(); }; // Ordenação por coluna content().querySelectorAll("[data-bm-sort]").forEach((th) => (th.onclick = () => { const col = th.dataset.bmSort; if (bmSortCol === col) bmSortDir = bmSortDir === "asc" ? "desc" : "asc"; else { bmSortCol = col; bmSortDir = "desc"; } Pages.benchmarks(); })); content().querySelectorAll("[data-bm-report]").forEach((b) => (b.onclick = () => window.open(API.benchmarkReportUrl(b.dataset.bmReport), "_blank", "noopener"))); content().querySelectorAll("[data-bm-cancel]").forEach((b) => (b.onclick = () => onBenchCancel(b.dataset.bmCancel))); content().querySelectorAll("[data-bm-error]").forEach((b) => (b.onclick = () => onBenchError(b.dataset.bmError))); content().querySelectorAll("[data-bm-delete]").forEach((b) => (b.onclick = () => onBenchDelete(b.dataset.bmDelete))); // Atualiza sozinho enquanto houver job na fila/execução (pausa durante a seleção). if (anyActive && !bmCompareMode) armPoll(3000, () => { if (state.page === "benchmarks") Pages.benchmarks(); }); }; function benchRow(j) { let statusCell = UI.benchBadge(j.status); if (j.status === "running" && j.progress && j.progress.total) { const pct = Math.round(j.progress.done / j.progress.total * 100); statusCell += `
${j.progress.done}/${j.progress.total} · ${pct}%
`; } else if (j.status === "running") { statusCell += `
carregando modelo…
`; } // Duração: concluídos usam durationS; em execução, tempo decorrido vivo. let dur = j.durationS != null ? UI.duration(j.durationS) : "—"; if (j.status === "running" && j.startedAt) dur = UI.duration((Date.now() - Date.parse(j.startedAt)) / 1000); let actions = ""; if (j.status === "done" && j.hasReport) actions = ``; else if (j.status === "queued" || j.status === "running") actions = ``; else if (j.status === "error") actions = ``; if (j.status !== "queued" && j.status !== "running") actions += ` `; const selectCell = bmCompareMode ? `${j.status === "done" ? `` : ""}` : ""; return ` ${selectCell} ${UI.escapeHtml(j.profileName || "—")} ${j.linkedService ? `
${UI.escapeHtml(UI.svcLabel(j.linkedService))}
` : ""} ${UI.escapeHtml(j.typeLabel || "—")} ${statusCell}${j.canceledBy ? `
por ${UI.escapeHtml(j.canceledBy)}
` : ""} ${UI.dt(j.createdAt)} ${UI.dt(j.startedAt)} ${UI.dt(j.finishedAt)} ${dur} ${actions} `; } async function onShowComparison() { const ids = Array.from(bmSelected); if (ids.length < 2) { UI.toast("Selecione ao menos 2 benchmarks concluídos.", "bad"); return; } try { const data = await API.reportPreview(ids); bmComparePreview = { jobIds: ids, data }; Pages.benchmarks(); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } function renderComparePreview() { const { data } = bmComparePreview; const suggested = `Comparativo de Benchmarks - ${new Date().toISOString().slice(0, 16).replace("T", " ")}`; let html = `

Pré-visualização da comparação

`; html += ComparisonView.render(data); content().innerHTML = html; document.getElementById("cmp-back").onclick = () => { bmComparePreview = null; bmCompareMode = false; bmSelected.clear(); Pages.benchmarks(); }; document.getElementById("cmp-save").onclick = () => onSaveReport(); } async function onSaveReport() { const titleInput = document.getElementById("cmp-title"); const title = titleInput ? titleInput.value.trim() : ""; const btn = document.getElementById("cmp-save"); btn.disabled = true; btn.textContent = "Salvando…"; try { await API.reportCreate(bmComparePreview.jobIds, title || undefined); UI.toast("Relatório salvo.", "ok"); bmComparePreview = null; bmCompareMode = false; bmSelected.clear(); navigate("reports"); } catch (ex) { UI.toast("Erro ao salvar relatório: " + ex.message, "bad"); btn.disabled = false; btn.textContent = "Salvar relatório"; } } async function onBenchCancel(id) { const ok = await UI.confirm({ title: "Cancelar benchmark?", text: "Se estiver na fila, é removido. Se estiver em execução, o processo é encerrado com segurança e o serviço é restaurado.", confirmLabel: "Cancelar benchmark", danger: true, }); if (!ok) return; try { await API.benchmarkCancel(id); UI.toast("Cancelamento solicitado.", "ok"); Pages.benchmarks(); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } async function onBenchError(id) { try { const j = await API.benchmark(id); await UI.confirm({ title: "Erro do benchmark", text: j.error || "Sem detalhes registrados.", confirmLabel: "Fechar", }); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } // Sub-tipos que compõem o benchmark "Completo" — mesma ordem do catálogo. const _COMPLETO_SUBTYPES = [ { id: "codigo", label: "Código" }, { id: "agente", label: "Agente" }, { id: "instrucoes", label: "Seguir instruções" }, { id: "contexto-longo", label: "Contexto longo" }, ]; function _expandCompleto(rows) { const out = []; for (const j of rows) { if (j.typeId === "completo") { for (const sub of _COMPLETO_SUBTYPES) { out.push({ ...j, typeId: sub.id, typeLabel: sub.label }); } } else { out.push(j); } } return out; } function _sortBenchRows(rows, col, dir) { const key = { profile: (j) => (j.profileName || "").toLowerCase(), type: (j) => (j.typeLabel || "").toLowerCase(), status: (j) => j.status || "", createdAt: (j) => j.createdAt || "", startedAt: (j) => j.startedAt || "", finishedAt: (j) => j.finishedAt || "", duration: (j) => j.durationS ?? -1, }[col] || ((j) => j.createdAt || ""); return [...rows].sort((a, b) => { const va = key(a), vb = key(b); const cmp = va < vb ? -1 : va > vb ? 1 : 0; return dir === "asc" ? cmp : -cmp; }); } async function onBenchDelete(id) { const ok = await UI.confirm({ title: "Remover benchmark?", text: "O registro e os artefatos (relatório, logs) serão apagados permanentemente.", confirmLabel: "Remover", danger: true, }); if (!ok) return; try { await API.benchmarkDelete(id); UI.toast("Benchmark removido.", "ok"); Pages.benchmarks(); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } // ---------- Serviços ---------- function showWanUsageModal(url, svcLabel) { const curlExample = `curl ${url}models \\ -H "Authorization: Bearer sk-..."`; const sdkExample = `from openai import OpenAI\nclient = OpenAI(\n base_url="${url}",\n api_key="sk-...",\n)`; const overlay = document.createElement("div"); overlay.className = "modal-bg show"; overlay.innerHTML = ``; document.body.appendChild(overlay); document.getElementById("wan-copy-url").onclick = () => UI.copy(url).then((ok) => UI.toast(ok ? "URL copiada." : "Não foi possível copiar.", ok ? "ok" : "bad")); document.getElementById("wan-close").onclick = () => overlay.remove(); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); } // Abre o chat WAN. Como a URL WAN é outra origem (domínio público), o cookie // de sessão do dashboard não viaja junto → o backend 401. Pedimos um token de // uso único (com a sessão atual) e o anexamos à URL; o proxy resgata o token, // cria a sessão no domínio WAN e redireciona para a URL limpa. async function onWanChat(wanChatUrl) { // Abre a aba ANTES do await para não cair no bloqueador de pop-up. const win = window.open("", "_blank"); try { const { token } = await API.llmChatToken(); const sep = wanChatUrl.includes("?") ? "&" : "?"; const url = wanChatUrl + sep + "t=" + encodeURIComponent(token); if (win) win.location.href = url; else window.open(url, "_blank"); } catch (ex) { if (win) win.close(); UI.toast("Erro ao abrir o chat WAN: " + (ex && ex.message ? ex.message : ex), "bad"); } } // Gera o HTML dos botões de ação de um serviço (sem o botão Logs). function svcActionButtons(s) { return s.state === "active" ? `LAN` + (s.wan_chat_url ? ` ` : "") + (s.wan_url ? ` ` : "") + ` ` + ` ` : ``; } // Atualiza só badge + tag de health + botões de ação — sem re-renderizar a página. async function patchServicesStatus() { let d; try { d = await API.services(); } catch { return; } let hasTransient = false; for (const s of d.items) { const reachable = s.health && s.health.healthy; const isTransient = s.state === "activating" || (s.state === "active" && !reachable); if (isTransient) hasTransient = true; // Badge (estado + health) const badgeEl = content().querySelector(`[data-svc-badge="${s.name}"]`); if (badgeEl) badgeEl.outerHTML = `${UI.stateBadge(s.state, reachable)}`; // Tag de health (latência) const healthTagEl = content().querySelector(`[data-svc-health="${s.name}"]`); if (healthTagEl) healthTagEl.innerHTML = s.health && s.health.latency_ms != null ? `${s.health.latency_ms} ms` : ""; // Botões de ação (trocam quando o serviço liga/desliga) const actionsEl = content().querySelector(`[data-svc-actions="${s.name}"]`); if (actionsEl) { actionsEl.innerHTML = svcActionButtons(s) + ` `; actionsEl.querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", () => onServiceAction(b.dataset.svc, b.dataset.act))); actionsEl.querySelectorAll("[data-wan-url]").forEach((b) => b.addEventListener("click", () => showWanUsageModal(b.dataset.wanUrl, b.dataset.wanSvc))); actionsEl.querySelectorAll("[data-wan-chat]").forEach((b) => b.addEventListener("click", () => onWanChat(b.dataset.wanChat))); } } if (!hasTransient) stopPoll(); } Pages.services = async () => { const [d, profs, activeData, enumsData] = await Promise.all([ API.services(), API.profiles(), API.profilesActive(), API.profileEnums(), ]); const activeMap = activeData.active || {}; const taxMeta = (enumsData && enumsData.taxonomy) || {}; const TAX_KEYS = ["funcPurpose", "modality", "architecture", "cognitiveStyle"]; const profileTaxBadges = (p) => { if (!p) return ""; const badges = TAX_KEYS.flatMap((key) => (p[key] || []).map((id) => { const info = (taxMeta[key] || []).find((m) => m.id === id); return { id, label: info ? info.label : id }; }) ); if (!badges.length) return ""; const span = ({ id, label }) => `${UI.escapeHtml(label)}`; const first = badges.slice(0, 2).map(span).join(""); const rest = badges.slice(2).map(span).join(""); return `
${first}
${rest ? `
${rest}
` : ""}`; }; // Perfis habilitados agrupados pelo serviço vinculado. const profsByService = {}; (profs.items || []).forEach((p) => { if (p.linkedService && p.enabled) (profsByService[p.linkedService] = profsByService[p.linkedService] || []).push(p); }); let html = `
`; html += d.items.map((s) => { const reachable = s.health && s.health.healthy; const linked = profsByService[s.name] || []; const cur = activeMap[s.name] || ""; const profSelect = linked.length ? ` ` : ""; const activeProf = cur ? (profs.items || []).find((p) => p.id === cur) : null; const taxProf = activeProf || (linked.length === 1 ? linked[0] : null); const healthHtml = s.health && s.health.latency_ms != null ? `${s.health.latency_ms} ms` : ""; return `
${UI.escapeHtml(UI.svcLabel(s.name))} ${UI.stateBadge(s.state, reachable)} ${healthHtml}
${profileTaxBadges(taxProf)}
${profSelect} ${s.model_key ? `${UI.escapeHtml(s.model_key)}` : ""}
${svcActionButtons(s)}
`; }).join(""); html += `
`; content().innerHTML = html; content().querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", () => onServiceAction(b.dataset.svc, b.dataset.act))); content().querySelectorAll("[data-profile-svc]").forEach((sel) => sel.addEventListener("change", () => onProfileSwitch(sel))); content().querySelectorAll("[data-wan-url]").forEach((b) => b.addEventListener("click", () => showWanUsageModal(b.dataset.wanUrl, b.dataset.wanSvc))); content().querySelectorAll("[data-wan-chat]").forEach((b) => b.addEventListener("click", () => onWanChat(b.dataset.wanChat))); // Polling cirúrgico: atualiza só badge/health/botões enquanto houver serviço transitório. const hasTransient = d.items.some( (s) => s.state === "activating" || (s.state === "active" && !(s.health && s.health.healthy)) ); if (hasTransient) armPoll(3000, patchServicesStatus); else stopPoll(); }; // Troca o perfil ativo de um serviço: ativa o escolhido (grava o override e // reinicia) ou, com "padrão", desativa o atual (volta ao servers.yaml). async function onProfileSwitch(sel) { const svc = sel.dataset.profileSvc; const prev = sel.dataset.prev || ""; const next = sel.value; if (next === prev) return; const label = sel.options[sel.selectedIndex].textContent.replace(/^\s*perfil:\s*/, "").trim(); const ok = await UI.confirm({ title: `Trocar o modelo de ${svc}?`, text: next ? `O serviço será reiniciado com o perfil "${label}". Modelos grandes podem levar minutos para carregar (503 enquanto isso).` : `O serviço será reiniciado com o modelo padrão (servers.yaml).`, confirmLabel: "Trocar e reiniciar", danger: false, }); if (!ok) { sel.value = prev; return; } sel.disabled = true; try { if (next) await API.profileActivate(next); else await API.profileDeactivate(prev); UI.toast(`"${svc}": perfil aplicado — serviço reiniciando…`, "ok"); setTimeout(() => render(), 700); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); sel.disabled = false; sel.value = prev; } } async function onServiceAction(svc, act) { if (act === "logs") { return showLogs(svc); } if (act === "stop" || act === "restart") { const ok = await UI.confirm({ title: `${act === "stop" ? "Parar" : "Reiniciar"} ${svc}?`, text: act === "stop" ? `O serviço "${svc}" ficará indisponível até ser iniciado novamente.` : `O serviço "${svc}" será reiniciado e ficará indisponível por alguns segundos.`, confirmLabel: act === "stop" ? "Parar serviço" : "Reiniciar", danger: act === "stop", }); if (!ok) return; } try { const res = await API.serviceControl(svc, act); if (res.ok) UI.toast(`"${svc}": ${act} executado.`, "ok"); else UI.toast(`"${svc}": ${act} falhou (cód. ${res.returncode}).`, "bad"); setTimeout(() => render(), 700); } catch (ex) { UI.toast(`Erro: ${ex.message}`, "bad"); } } async function showLogs(svc) { const box = document.getElementById("svc-logs"); box.innerHTML = `
${UI.icon("docs")}

Logs · ${UI.escapeHtml(svc)}

Carregando…
`; const load = async () => { const n = document.getElementById("log-lines").value; const lb = document.getElementById("logbox"); lb.textContent = "Carregando…"; try { const r = await API.serviceLogs(svc, n); lb.textContent = r.output && r.output.trim() ? r.output : "(sem linhas de log)"; lb.scrollTop = lb.scrollHeight; } catch (ex) { lb.textContent = "Erro: " + ex.message; } }; document.getElementById("log-reload").addEventListener("click", load); document.getElementById("log-lines").addEventListener("change", load); box.scrollIntoView({ behavior: "smooth", block: "nearest" }); load(); } // ---------- RAM Monitor ---------- async function loadRamMonitor() { const body = document.getElementById("ram-monitor-body"); if (!body) return; try { const st = await API.ramMonitorStatus(); const active = st.active; const mins = st.interval_minutes || 720; const stateHtml = active ? `ativo` : `parado`; body.innerHTML = `
${stateHtml}
Intervalo: ${mins} min
${active ? `` : `` }
O monitor verifica erros ECC de memoria e mede banda. Quando parado, o servico systemd ram-health-monitor nao consome recursos.
`; } catch (e) { body.innerHTML = `
Erro ao carregar status.
`; } } // ---------- Acesso remoto ---------- Pages.remote = async () => { const d = await API.remote(); const wanHost = d.vps && d.vps.hostname; setHostChip( wanHost ? `${d.hostname || "host"} · ${d.lan_ip} · ${wanHost}` : `${d.hostname || "host"} · ${d.lan_ip}` ); const net = d.network; const vpsInfo = d.vps || {}; const vpsHc = vpsInfo.https_health || {}; // Badge de saúde genérico (para local/LAN/WAN) const hb = (h) => { if (!h) return ``; if (h.healthy) return `${h.latency_ms} ms`; if (h.reachable) return `${h.latency_ms != null ? h.latency_ms + " ms" : "resp. " + (h.status_code || "?")}`; return `offline`; }; const lanLoginUrl = `http://${d.lan_ip}:8090/`; const wanLoginUrl = wanHost ? `https://${wanHost}/` : null; let html = `
${UI.icon("remote")}

LAN

Hostname${UI.escapeHtml(net.hostname || "—")}
IP da LAN${UI.escapeHtml(d.lan_ip)}
CIDR${UI.escapeHtml(net.cidr || "—")}
Gateway${UI.escapeHtml(net.gateway || "—")}
`; // Bloco WAN (VPS relay) if (vpsInfo.enabled) { const vpsStatus = vpsHc.reachable ? `${vpsHc.latency_ms} ms` : `offline`; html += `
${UI.icon("remote")}

WAN

${vpsStatus}
Hostname${UI.escapeHtml(vpsInfo.hostname || "—")} ${wanLoginUrl ? `` : ""}
IP${UI.escapeHtml(vpsInfo.ip || "—")}
Porta${UI.escapeHtml(String(vpsInfo.public_port || 443))}
Método${UI.escapeHtml(vpsInfo.method || "—")}
Caddy HTTPS${vpsHc.reachable ? `online ${vpsHc.latency_ms} ms · ${vpsHc.status_code}` : `offline${vpsHc.status_code ? ` ${vpsHc.status_code}` : ""}`}
Acesso remoto ${net.remote_access.enabled ? `habilitado (${UI.escapeHtml(net.remote_access.method || "?")})` : `desabilitado`}
`; } else { html += `
${UI.icon("admin")}

Acesso administrativo

SSHporta ${UI.escapeHtml(String(net.ssh.port))} · ${UI.escapeHtml(net.ssh.auth || "—")}
SSH permitido de${UI.escapeHtml(net.ssh.allowed_from || "—")}
Acesso remoto${net.remote_access.enabled ? `habilitado (${UI.escapeHtml(net.remote_access.method || "?")})` : `desabilitado`}
`; } html += `
`; // Tabela de endpoints const hasWan = vpsInfo.enabled && !!wanHost; const urlCell = (url, healthObj) => ` ${UI.escapeHtml(url)}
${hb(healthObj)}
`; html += `
${UI.icon("services")}

Endpoints dos serviços

${hasWan ? "" : ""} ${d.services.map((s) => { const wUrl = s.urls && s.urls.wan; return ` ${urlCell(s.urls.local, s.local_health)} ${urlCell(s.urls.lan, s.lan_health)} ${hasWan ? (wUrl ? urlCell(wUrl, s.wan_health) : ``) : ""} `; }).join("")}
ServiçoURL localURL LANURL WAN
${UI.escapeHtml(UI.svcLabel(s.name))}

Os serviços fazem bind em 0.0.0.0; acesso restringido pelo firewall (UFW). WAN via VPS relay — exige API key (Authorization: Bearer sk-…).

`; content().innerHTML = html; }; // ---------- Perfis ---------- (implementado em profiles.js) Pages.profiles = () => ProfilesPage.render(content()); // ---------- Relatórios ---------- (implementado em reports.js) Pages.reports = () => ReportsPage.render(content()); // ---------- Documentação ---------- Pages.docs = async () => { const d = await API.docs(); const pdfs = (d.pdfs || []).map((p) => `📄 ${UI.escapeHtml(p)}`).join(" "); let html = `
${d.items.map((it) => ``).join("")} ${pdfs ? `
PDFs
${pdfs}
` : ""}
Selecione um documento à esquerda.
`; content().innerHTML = html; const nav = document.getElementById("doc-nav"); const view = document.getElementById("doc-view"); const open = async (name, btn) => { nav.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn)); view.innerHTML = `
Carregando ${UI.escapeHtml(name)}…
`; try { const doc = await API.doc(name); view.innerHTML = Markdown.render(doc.content); state.docName = name; } catch (ex) { view.innerHTML = `
Erro: ${UI.escapeHtml(ex.message)}
`; } }; nav.querySelectorAll("button").forEach((b) => b.addEventListener("click", () => open(b.dataset.doc, b))); // Links internos entre docs (data-doc) abrem no próprio visualizador. view.addEventListener("click", (e) => { const a = e.target.closest("a[data-doc]"); if (!a) return; e.preventDefault(); const name = a.getAttribute("data-doc"); const btn = nav.querySelector(`button[data-doc="${name}"]`); if (btn) open(name, btn); else open(name, null); // doc fora da lista (ainda assim tenta abrir) view.scrollIntoView({ behavior: "smooth", block: "start" }); }); // Abre o primeiro (ou o último visto) automaticamente. const first = nav.querySelector(`button[data-doc="${state.docName}"]`) || nav.querySelector("button"); if (first) open(first.dataset.doc, first); }; // ---------- Administração ---------- Pages.admin = async () => { const [ver, status] = await Promise.all([ API.adminVersion(), API.adminStatus().catch(() => null), ]); // Disco vem do snapshot consolidado (status.disk_usage) — sem /api/admin/disk redundante. const diskItems = (status && status.disk_usage) || []; let html = `
${UI.icon("bolt")}

Versão & repositório

Versão${UI.escapeHtml(ver.version)}
Branch${UI.escapeHtml(ver.branch)}
Commit${UI.escapeHtml(ver.commit)}
${ver.repo_status ? `
${UI.escapeHtml(ver.repo_status)}
` : ""}
${UI.icon("disk")}

Espaço em disco

${diskItems.map((x) => `
${UI.escapeHtml(x.path)}
${UI.bytes(x.free)} livres · ${x.percent}%
`).join("")}
`; // fim grid cols-2 // Bloco de status do servidor (snapshot consolidado) html += `
${UI.icon("cpu")}

Status do servidor

${renderStatus(status)}
`; html += `
${UI.icon("services")}

Checagem de saúde

Clique em "Verificar agora".
`; html += `
${UI.icon("heart")}

Monitor de RAM

Carregando...
`; html += `
${UI.icon("admin")}

Zona de manutenção

Reinicializa o servidor físico. Todos os serviços (inclusive este painel) ficarão indisponíveis durante o reboot.

`; content().innerHTML = html; document.getElementById("btn-update").addEventListener("click", onUpdate); document.getElementById("btn-health").addEventListener("click", onHealth); document.getElementById("btn-reboot").addEventListener("click", onReboot); document.getElementById("btn-status-refresh").addEventListener("click", onRefreshStatus); // RAM Monitor - eventos loadRamMonitor(); document.getElementById("ram-monitor-body").addEventListener("click", async (e) => { const btn = e.target.closest("[data-ram-act]"); if (!btn) return; const act = btn.dataset.ramAct; try { let res; if (act === "start") res = await API.ramMonitorStart(); else if (act === "stop") res = await API.ramMonitorStop(); else if (act === "refresh") { loadRamMonitor(); return; } else return; if (res.ok) UI.toast("Monitor: " + act + " executado.", "ok"); else UI.toast("Monitor: " + act + " falhou.", "bad"); setTimeout(loadRamMonitor, 700); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } }); document.getElementById("ram-monitor-body").addEventListener("change", async (e) => { const sel = e.target.closest("[data-ram-interval]"); if (!sel) return; const mins = parseInt(sel.value, 10); try { const res = await API.ramMonitorInterval(mins); if (res.ok) UI.toast("Intervalo alterado para " + mins + " min.", "ok"); else UI.toast("Falha ao alterar intervalo.", "bad"); setTimeout(loadRamMonitor, 700); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } }); bindServiceBootButtons(); }; function renderStatus(s) { if (!s) return `
Carregando status do servidor...
`; const h = s.host || {}; const gpuList = s.gpu || []; const gpu = gpuList[0] || null; let html = `
`; // CPU html += `
CPU
`; html += `
${UI.escapeHtml(h.cpu_model || "?")}
`; html += `
${h.cpu_sockets || 1} socket(s) · ${h.cpu_count || 0} núcleos
`; html += `
load: ${(h.loadavg || []).map(v => v.toFixed(1)).join(" / ")}
`; if (s.cpu_temp && s.cpu_temp.max != null) html += `
${UI.tempBadge(s.cpu_temp.max)}
`; else html += `
temp: —
`; // RAM html += `
RAM
`; const mem = h.memory || {}; html += `
${UI.bytes(mem.used || 0)} usada / ${UI.bytes(mem.total || 0)} total
`; html += `
`; html += `
`; // Fans const fans = s.cpu_fans || []; if (fans.length) { html += `
Fans
`; html += fans.map(f => `
${UI.escapeHtml(f.label)}: ${f.rpm} RPM
`).join(""); html += `
`; } // Disk temps const dt = s.disk_temps || []; if (dt.length) { html += `
Temp. discos
`; html += dt.map(d => `
${UI.escapeHtml(d.label)}: ${d.celsius} °C
`).join(""); html += `
`; } html += `
`; // fecha coluna CPU/RAM // GPU coluna html += `
`; html += `
GPU
`; if (gpu) { html += `
${UI.escapeHtml(gpu.name)}
`; html += `
${gpu.utilization != null ? gpu.utilization + "%" : "?"} util
`; html += `
${gpu.memory_used_mib != null ? gpu.memory_used_mib.toFixed(0) : "?"} / ${gpu.memory_total_mib != null ? gpu.memory_total_mib.toFixed(0) : "?"} MiB VRAM
`; if (gpu.temperature_c != null) html += `
${UI.tempBadge(gpu.temperature_c)}
`; if (gpu.power_w != null) html += `
${gpu.power_w} W
`; } else { html += `
GPU indisponível
`; } // Uptime html += `
Uptime
`; const up = h.uptime_seconds || 0; const d = Math.floor(up / 86400); const _h = Math.floor((up % 86400) / 3600); html += `
${d}d ${_h}h
`; html += `
`; // fecha coluna GPU // Serviços const svcs = (s.services || {}).items || []; html += `
Serviços
`; html += ``; svcs.forEach(svc => { const boot = svc.enabled === "enabled" ? "boot" : "manual"; html += ``; }); html += `
ServiçoEstadoBootHealth
${UI.escapeHtml(svc.name)}
:${svc.port} · ${UI.escapeHtml(svc.type || svc.backend || "")}
${UI.stateBadge(svc.state, svc.healthy)} ${boot} ${svc.healthy ? 'ok' : 'off'} ${svc.latency_ms != null ? `${svc.latency_ms}ms` : ""}
`; return html; } async function onRefreshStatus() { const body = document.getElementById("status-body"); body.innerHTML = `
Atualizando...
`; try { const s = await API.adminStatus(); body.innerHTML = renderStatus(s); bindServiceBootButtons(); } catch (ex) { body.innerHTML = `
Erro: ${UI.escapeHtml(ex.message)}
`; } } function bindServiceBootButtons() { document.querySelectorAll(".service-boot-btn").forEach(btn => { btn.onclick = async () => { const name = btn.dataset.svc; const action = btn.dataset.action; btn.disabled = true; btn.textContent = action === "enable" ? "Habilitando..." : "Desabilitando..."; try { const r = await API.serviceControl(name, action); UI.toast(r.ok ? `${action} ${name}: OK` : `Falha: ${r.stderr || r.stdout}`, r.ok ? "ok" : "bad"); await onRefreshStatus(); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); btn.disabled = false; btn.textContent = action === "enable" ? "Habilitar boot" : "Desabilitar boot"; } }; }); } async function onUpdate() { const ok = await UI.confirm({ title: "Atualizar repositório?", text: "Executa git pull --ff-only na raiz do projeto. Não força merge nem rebase; falha com segurança se houver divergência.", confirmLabel: "Atualizar", }); if (!ok) return; try { const r = await API.adminUpdate(); UI.toast(r.ok ? "Repositório atualizado." : "Atualização sem avanço / com erro.", r.ok ? "ok" : "bad"); UI.confirm({ title: r.ok ? "Atualização concluída" : "Resultado da atualização", text: (r.stdout || "") + (r.stderr ? "\n" + r.stderr : ""), confirmLabel: "Fechar", }); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } async function onHealth() { const body = document.getElementById("health-body"); body.innerHTML = `
Checando serviços…
`; try { const h = await API.adminHealth(); const badge = h.summary.status === "ok" ? `tudo saudável` : `${h.summary.healthy}/${h.summary.total} saudáveis`; body.innerHTML = `
${badge}
${h.services.map((s) => ``).join("")}
ServiçoEstadoHealthLatência
${UI.escapeHtml(s.name)} ${UI.stateBadge(s.state, s.health.healthy)} ${s.health.healthy ? `ok` : s.health.reachable ? `resp. ${s.health.status_code || "?"}` : `offline`} ${s.health.latency_ms != null ? s.health.latency_ms + " ms" : "—"}
`; } catch (ex) { body.innerHTML = `
Erro: ${UI.escapeHtml(ex.message)}
`; } } async function onReboot() { const ok = await UI.confirm({ title: "Reiniciar o servidor?", text: "Esta ação reinicia a máquina física. O painel e todos os serviços ficarão fora do ar por alguns minutos.", confirmLabel: "Reiniciar agora", danger: true, requireText: "REINICIAR", }); if (!ok) return; try { await API.adminReboot(); UI.toast("Comando de reinicialização enviado.", "ok"); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } // ---------- Usuários (admin only) ---------- function userRow(u) { const badges = [ ["Dashboard", u.can_dashboard], ["GPU", u.can_gpu], ["CPU", u.can_cpu], ["CPU+GPU", u.can_agent], ["Admin", u.is_admin], ].filter(([, v]) => v).map(([l]) => UI.permBadge(l, true)).join(" "); const ips = (u.allowed_ips || ["*"]).join(", "); const ipsTxt = ips.length > 28 ? ips.slice(0, 28) + "…" : ips; return ` ${UI.escapeHtml(u.username)} ${UI.escapeHtml(u.email)} ${badges || 'nenhuma'} ${UI.escapeHtml(ipsTxt)} ${UI.dt(u.last_seen_at)} ${u.active ? 'ativo' : 'bloqueado'}
`; } function openUserModal(title, user, onSave) { document.getElementById("usr-form-overlay") && document.getElementById("usr-form-overlay").remove(); const isNew = !user; const ips = user ? (user.allowed_ips || ["*"]).join("\n") : "*"; const chk = (f) => (user && user[f]) ? "checked" : ""; const numVal = (f) => String(user ? (user[f] ?? 0) : 0); const permRow = (id, label, icon) => ``; const overlay = document.createElement("div"); overlay.id = "usr-form-overlay"; overlay.className = "modal-bg show"; overlay.innerHTML = ``; document.body.appendChild(overlay); const close = () => overlay.remove(); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); document.getElementById("uf-cancel").onclick = close; document.getElementById("uf-save").onclick = async () => { const body = { username: document.getElementById("uf-username").value.trim(), email: document.getElementById("uf-email").value.trim(), allowed_ips: document.getElementById("uf-ips").value.trim().split("\n").map((s) => s.trim()).filter(Boolean), can_dashboard: document.getElementById("uf-can_dashboard").checked, can_gpu: document.getElementById("uf-can_gpu").checked, can_cpu: document.getElementById("uf-can_cpu").checked, can_agent: document.getElementById("uf-can_agent").checked, is_admin: document.getElementById("uf-is_admin").checked, rate_limit_rpm: parseInt(document.getElementById("uf-rpm").value) || 0, rate_limit_tpd: parseInt(document.getElementById("uf-tpd").value) || 0, }; if (!isNew) { const pw = document.getElementById("uf-password").value; if (pw) body.password = pw; } const btn = document.getElementById("uf-save"); btn.disabled = true; btn.textContent = "Salvando…"; try { await onSave(body); close(); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); btn.disabled = false; btn.textContent = isNew ? "Criar e enviar e-mail" : "Salvar"; } }; } function showChangePasswordModal() { document.getElementById("chpw-overlay") && document.getElementById("chpw-overlay").remove(); const overlay = document.createElement("div"); overlay.id = "chpw-overlay"; overlay.className = "modal-bg show"; overlay.innerHTML = ``; document.body.appendChild(overlay); const showErr = (msg) => { const el = document.getElementById("chpw-err"); el.textContent = msg; el.style.display = "block"; }; document.getElementById("chpw-save").onclick = async () => { const cur = document.getElementById("chpw-current").value; const nw = document.getElementById("chpw-new").value; const cf = document.getElementById("chpw-confirm").value; if (!cur || !nw) { showErr("Preencha todos os campos."); return; } if (nw.length < 8) { showErr("A nova senha precisa ter ao menos 8 caracteres."); return; } if (nw !== cf) { showErr("As senhas não coincidem."); return; } const btn = document.getElementById("chpw-save"); btn.disabled = true; btn.textContent = "Salvando…"; try { const res = await API.changePassword(cur, nw); overlay.remove(); if (res.api_key) showApiKey(res.api_key); } catch (ex) { showErr(ex.message || "Erro ao salvar senha."); btn.disabled = false; btn.textContent = "Salvar e continuar"; } }; } function showApiKey(key) { const overlay = document.createElement("div"); overlay.className = "modal-bg show"; overlay.innerHTML = ``; document.body.appendChild(overlay); document.getElementById("apikey-copy").onclick = () => UI.copy(key).then((ok) => UI.toast(ok ? "Copiado." : "Não foi possível copiar.", ok ? "ok" : "bad")); document.getElementById("apikey-close").onclick = () => overlay.remove(); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); } async function onUserEdit(id, users) { const user = users.find((u) => u.id === id); if (!user) return; openUserModal("Editar: " + user.username, user, async (body) => { await API.userUpdate(id, body); UI.toast("Usuário atualizado.", "ok"); Pages.users(); }); } async function onUserInvite(id, users) { const user = users.find((u) => u.id === id); const ok = await UI.confirm({ title: "Gerar convite para " + (user ? user.username : "usuário") + "?", text: "Será gerado um link de convite válido por 72 horas.", confirmLabel: "Gerar convite", }); if (!ok) return; try { const res = await API.userInvite(id, location.origin); const overlay = document.createElement("div"); overlay.className = "modal-bg show"; const emailNote = res.email_sent ? `

E-mail enviado para ${UI.escapeHtml(user ? user.email || "" : "")}.

` : `

Nao foi possivel enviar e-mail — encaminhe o link manualmente.

`; overlay.innerHTML = ``; document.body.appendChild(overlay); document.getElementById("inv-copy").onclick = () => UI.copy(res.invite_url).then((ok) => UI.toast(ok ? "Link copiado." : "Não foi possível copiar.", ok ? "ok" : "bad")); document.getElementById("inv-close").onclick = () => overlay.remove(); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } async function onUserRotate(id) { const ok = await UI.confirm({ title: "Rotacionar API key?", text: "A chave atual será invalidada imediatamente.", confirmLabel: "Rotacionar", danger: true, }); if (!ok) return; try { const res = await API.userRotateKey(id); showApiKey(res.api_key); Pages.users(); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } async function onUserToggle(id, isActive) { const label = isActive ? "Bloquear" : "Desbloquear"; const ok = await UI.confirm({ title: label + " usuário?", text: isActive ? "O usuário perderá acesso imediatamente." : "O usuário recuperará acesso.", confirmLabel: label, danger: isActive, }); if (!ok) return; try { await API.userUpdate(id, { active: !isActive }); UI.toast("Usuário " + (isActive ? "bloqueado" : "desbloqueado") + ".", "ok"); Pages.users(); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } async function onUserDelete(id, users) { const user = users.find((u) => u.id === id); const ok = await UI.confirm({ title: `Excluir ${user ? user.username : "usuário"}?`, text: "Esta ação é irreversível. O usuário e todos os seus dados serão removidos permanentemente.", confirmLabel: "Excluir", danger: true, }); if (!ok) return; try { await API.userDeactivate(id); await Pages.users(); UI.toast("Usuário excluído.", "ok"); } catch (ex) { UI.toast("Erro: " + ex.message, "bad"); } } Pages.users = async () => { const data = await API.users(); const users = data.items || []; let html = `

Usuários (${users.length})

${users.length ? `${users.map(userRow).join("")}
UsuárioEmailPermissõesIPs Último acessoStatus
` : '
Nenhum usuário cadastrado.
'}
`; content().innerHTML = html; document.getElementById("usr-new").onclick = () => { openUserModal("Novo usuário", null, async (body) => { const created = await API.userCreate(body); const emailMsg = created.email_sent ? `E-mail enviado para ${created.email || "o usuário"}.` : "Usuário criado, mas o e-mail não foi enviado — verifique smtp.conf."; UI.toast(emailMsg, created.email_sent ? "ok" : "bad"); Pages.users(); }); }; content().querySelectorAll("[data-usr-edit]").forEach((b) => (b.onclick = () => onUserEdit(b.dataset.usrEdit, users))); content().querySelectorAll("[data-usr-invite]").forEach((b) => (b.onclick = () => onUserInvite(b.dataset.usrInvite, users))); content().querySelectorAll("[data-usr-rotate]").forEach((b) => (b.onclick = () => onUserRotate(b.dataset.usrRotate))); content().querySelectorAll("[data-usr-toggle]").forEach((b) => (b.onclick = () => onUserToggle(b.dataset.usrToggle, b.dataset.usrActive === "1"))); content().querySelectorAll("[data-usr-delete]").forEach((b) => (b.onclick = () => onUserDelete(b.dataset.usrDelete, users))); }; // ---------- Logs (admin only) ---------- let logsOffset = 0; let logsFilters = { user_id: "", service: "", date_from: "", date_to: "", blocked_only: false }; Pages.logs = async () => { logsOffset = 0; const [summary, usersData] = await Promise.all([ API.accessLogSummary(), API.users().catch(() => ({ items: [] })), ]); const users = usersData.items || []; const card = (label, value, cls) => `
${label}
${value}
${cls ? `
${cls}
` : ""}
`; let html = `
${card("Requests hoje", summary.total_requests, summary.date)} ${card("Tokens hoje", (summary.total_tokens || 0).toLocaleString(), "prompt + completion")} ${card("Bloqueadas hoje", summary.total_blocked, summary.total_blocked > 0 ? 'atenção' : "")}
`; const svcOpts = ["", "gpu", "cpu", "agent"].map((s) => ``).join(""); const userOpts = [{ id: "", username: "Todos os usuários" }, ...users].map((u) => ``).join(""); html += `
`; content().innerHTML = html; const applyFilters = () => { logsFilters.user_id = document.getElementById("lf-user").value; logsFilters.service = document.getElementById("lf-service").value; logsFilters.date_from = document.getElementById("lf-from").value; logsFilters.date_to = document.getElementById("lf-to").value; logsFilters.blocked_only = document.getElementById("lf-blocked").checked; logsOffset = 0; loadLogs(); }; document.getElementById("lf-apply").onclick = applyFilters; document.getElementById("lf-clear").onclick = () => { logsFilters = { user_id: "", service: "", date_from: "", date_to: "", blocked_only: false }; logsOffset = 0; Pages.logs(); }; loadLogs(); }; async function loadLogs() { const wrap = document.getElementById("logs-table-wrap"); if (!wrap) return; wrap.innerHTML = '
Carregando…
'; const params = { limit: 100, offset: logsOffset }; if (logsFilters.user_id) params.user_id = logsFilters.user_id; if (logsFilters.service) params.service = logsFilters.service; if (logsFilters.date_from) params.date_from = logsFilters.date_from; if (logsFilters.date_to) params.date_to = logsFilters.date_to; if (logsFilters.blocked_only) params.blocked_only = "true"; try { const data = await API.accessLog(params); const items = data.items || []; if (!items.length && logsOffset === 0) { wrap.innerHTML = '
Nenhum registro encontrado.
'; return; } const rows = items.map((r) => ` ${UI.dt(r.timestamp)} ${UI.escapeHtml(r.username || r.user_id)} ${UI.permBadge(r.service, true)} ${UI.escapeHtml(r.method)} ${UI.escapeHtml(r.path)} ${UI.escapeHtml(r.ip)} ${r.tokens_in != null || r.tokens_out != null ? (r.tokens_in || 0) + "+" + (r.tokens_out || 0) : "—"} ${r.latency_ms != null ? r.latency_ms + " ms" : "—"} ${r.status_code || "—"} ${r.blocked ? 'sim' : ""} `).join(""); const moreBtn = items.length === 100 ? `
` : ""; const tableHtml = `
${rows}
TimestampUsuárioServiçoMétodo PathIPTokensLatênciaStatusBloqueada
${moreBtn}`; if (logsOffset === 0) { wrap.innerHTML = tableHtml; } else { const existing = wrap.querySelector("tbody"); if (existing) existing.insertAdjacentHTML("beforeend", rows); const oldMore = wrap.querySelector("#logs-more"); if (oldMore) oldMore.parentElement.remove(); if (moreBtn) wrap.insertAdjacentHTML("beforeend", moreBtn); } const moreEl = document.getElementById("logs-more"); if (moreEl) moreEl.onclick = () => { logsOffset += 100; loadLogs(); }; } catch (ex) { wrap.innerHTML = `
Erro: ${UI.escapeHtml(ex.message)}
`; } } // ------------------------------------------------------------------ go boot(); })();