/* 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) =>
`${UI.icon(n.id)}${n.label} `
).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 ? `Cancelar ` : "")
: `Abrir Benchmarks `;
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
Gerenciar
Serviço Tipo Porta Estado
${d.services.items.map((s) => `
${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 ` : "")
: ""}
`).join("")}
`;
// 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)
Cancelar seleção
Exibir comparação `
: `
Comparar
Atualizar `}
`;
// 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:
Todos
${allTypes.filter((t) => t.id !== "completo").map((t) => `${UI.escapeHtml(t.label)} `).join("")}
Status:
Todos
${statusOpts.map((s) => `${s.l} `).join("")}
${(bmFilterType || bmFilterStatus) ? `Limpar ` : ""}
`;
}
// 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 = `${UI.icon("eye", 16)} `;
else if (j.status === "queued" || j.status === "running")
actions = `Cancelar `;
else if (j.status === "error")
actions = `${UI.icon("eye", 16)} `;
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 = `
${UI.icon("back", 14)} Voltar
`;
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 = `
${UI.icon("remote", 16)} Acesso WAN — ${UI.escapeHtml(svcLabel)}
Base URL
${UI.escapeHtml(url)}
${UI.icon("copy", 13)}
Exemplo curl
${UI.escapeHtml(curlExample)}
Exemplo Python (OpenAI SDK)
${UI.escapeHtml(sdkExample)}
Substitua sk-... pela API key do usuário. O endpoint é compatível com a API OpenAI.
`;
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 ? ` WAN ` : "")
+ (s.wan_url ? ` API ` : "")
+ ` Reiniciar `
+ ` Parar `
: `Iniciar `;
}
// 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)
+ ` Logs `;
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 ? `
padrão (servers.yaml)
${linked.map((p) => {
const missing = p.model && !p.model.downloaded;
return `
${UI.escapeHtml(p.name)}${missing ? " (modelo não baixado)" : ""} `;
}).join("")}
` : "";
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 `
${profSelect}
${s.model_key ? `${UI.escapeHtml(s.model_key)} ` : ""}
${svcActionButtons(s)}
Logs
`;
}).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)}
100 200 500
Recarregar
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
? `
Parar `
: `
Iniciar `
}
Atualizar
Intervalo:
5 min
10 min
30 min
1 hora
6 horas
12 horas
24 horas
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
SSH porta ${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
Serviço URL local URL LAN
${hasWan ? "URL WAN " : ""}
${d.services.map((s) => {
const wUrl = s.urls && s.urls.wan;
return `
${UI.escapeHtml(UI.svcLabel(s.name))}
${urlCell(s.urls.local, s.local_health)}
${urlCell(s.urls.lan, s.lan_health)}
${hasWan ? (wUrl ? urlCell(wUrl, s.wan_health) : `— `) : ""}
`;
}).join("")}
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) => `
${UI.escapeHtml(it.title)} `).join("")}
${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)} ` : ""}
Atualizar (git pull --ff-only)
${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
Atualizar
${renderStatus(status)}
`;
html += `${UI.icon("services")}
Checagem de saúde
Verificar agora
Clique em "Verificar agora".
`;
html += `
${UI.icon("heart")}
Monitor de RAM
`;
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.
Reiniciar servidor
`;
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 += `
Serviço Estado Boot Health `;
svcs.forEach(svc => {
const boot = svc.enabled === "enabled" ? "boot" : "manual";
html += `
${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 ` : ""}
${svc.enabled === "enabled" ? "Desabilitar boot" : "Habilitar boot"}
`;
});
html += `
`;
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}
Serviço Estado Health Latência
${h.services.map((s) => `
${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" : "—"}
`).join("")}
`;
} 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 '}
${UI.icon("edit", 13)} Editar
Convidar
Rotacionar chave
${u.active ? "Bloquear" : "Desbloquear"}
${UI.icon("trash", 13)} Excluir
`;
}
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) =>
`
${icon} ${label}
`;
const overlay = document.createElement("div");
overlay.id = "usr-form-overlay";
overlay.className = "modal-bg show";
overlay.innerHTML = `
${UI.escapeHtml(title)}
${isNew ? '
Uma senha temporária será enviada por e-mail
' : ""}
Permissões
${permRow("can_dashboard", "Dashboard", "🖥️")}
${permRow("can_gpu", "GPU", "⚡")}
${permRow("can_cpu", "CPU", "🔧")}
${permRow("can_agent", "Agente", "🤖")}
${permRow("is_admin", "Admin", "🔑")}
`;
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 = `
API key
Copie agora — não será exibida novamente.
${UI.escapeHtml(key)}
${UI.icon("copy", 14)}
`;
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 = `
Link de convite
Valido por 72 horas. Encaminhe ao convidado.
${UI.escapeHtml(res.invite_url)}
${UI.icon("copy", 14)}
${emailNote}
`;
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})
${UI.icon("plus", 14)} Novo usuário
${users.length
? `
Usuário Email Permissões IPs
Último acesso Status
${users.map(userRow).join("")}
`
: '
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) =>
`${s || "Todos os serviços"} `).join("");
const userOpts = [{ id: "", username: "Todos os usuários" }, ...users].map((u) =>
`${UI.escapeHtml(u.username)} `).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
? `Carregar mais
`
: "";
const tableHtml = `
Timestamp Usuário Serviço Método
Path IP Tokens Latência Status Bloqueada
${rows}
${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();
})();