Bxio 1be8a45dde
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
Expand flow editor with TeamSpeak events, permissions and execution controls
2026-10-10 20:38:31 +01:00

758 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mountEditor } from "./flow-editor.js";
const app = document.querySelector("#app");
const esc = (v) =>
String(v ?? "").replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
c
],
);
const paths = {
dashboard: "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z",
bot: "M9 3h6 M12 3v3 M5 8h14v12H5z M8 12v3 M16 12v3 M2 11v6 M22 11v6",
flow: "M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7 M10 6h7v9",
play: "M8 5l12 7-12 7z",
clock: "M12 8v5l3 2 M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18",
users:
"M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3 M9 3a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M18 4a4 4 0 0 1 0 7 M22 21v-3a4 4 0 0 0-3-4",
link: "M10 14l4-4 M8 16l-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0 M16 8l1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0",
settings:
"M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2",
variable: "M8 3H5v18h3 M16 3h3v18h-3 M10 9l4 6 M14 9l-4 6",
plus: "M12 5v14 M5 12h14",
save: "M4 3h14l3 3v15H3V3z M7 3v6h10V3 M7 21v-8h10v8",
refresh: "M20 7a9 9 0 1 0 1 6 M20 2v5h-5",
check: "M5 12l4 4L19 6",
close: "M6 6l12 12 M18 6L6 18",
shield: "M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6",
music:
"M9 18V5l11-2v13 M9 18a3 3 0 1 1-6 0 3 3 0 1 1 6 0 M20 16a3 3 0 1 1-6 0 3 3 0 1 1 6 0",
trash: "M3 6h18 M8 6V3h8v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7",
data: "M3 5c0-4 18-4 18 0s-18 4-18 0z M3 5v14c0 4 18 4 18 0V5 M3 12c0 4 18 4 18 0",
condition: "M12 2l9 10-9 10-9-10z",
text: "M4 5h16 M12 5v15 M8 20h8",
sort: "M4 5h16 M4 12h12 M4 19h8",
channel: "M8 3l-2 18 M18 3l-2 18 M3 8h18 M2 16h18",
expand: "M8 3H3v5 M16 3h5v5 M3 16v5h5 M21 16v5h-5",
};
const icon = (n) =>
`<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[n] || paths.bot}"/></svg>`;
const nodeIcon = (t) =>
({
timer: "clock",
manual: "play",
serverinfo: "data",
clients: "users",
condition: "condition",
sort: "sort",
limit: "dashboard",
template: "text",
channelEdit: "channel",
message: "text",
delay: "clock",
log: "text",
})[t];
let user,
data,
page = "dashboard",
botId,
pending,
dirty = false,
editorView,
executionFilter = { bot: "", status: "", mode: "" },
executionId,
polling = false;
const owner = () => user?.role === "Dono",
dis = () =>
owner() ? "" : 'disabled title="O perfil Leitor permite consultar"';
const selectedBot = () => data.bots.find((b) => b.id === botId);
async function api(path, options = {}) {
const r = await fetch(path, {
...options,
headers: { "Content-Type": "application/json" },
});
const body = await r.json();
if (!r.ok) throw new Error(body.error || "O pedido falhou.");
return body;
}
function toast(text, error = false) {
document.querySelector("#toast")?.remove();
const el = document.createElement("div");
el.id = "toast";
el.className = `toast ${error ? "error" : ""}`;
el.setAttribute("role", "status");
el.textContent = text;
document.body.append(el);
setTimeout(() => el.remove(), 6000);
}
async function refresh() {
data = await api("/api/dashboard");
if (!data.bots.some((b) => b.id === botId)) botId = data.bots[0]?.id;
}
const nav = [
["dashboard", "dashboard", "Dashboard"],
["bots", "bot", "Bots"],
["editor", "flow", "Editor de flows"],
["executions", "clock", "Execuções"],
["variables", "variable", "Variáveis"],
["connections", "link", "Ligações"],
["settings", "settings", "Definições"],
];
const empty = (i, title, text) =>
`<div class="empty"><span class="icon-tile purple">${icon(i)}</span><h3>${title}</h3><p>${text}</p></div>`;
const button = (id, text, i = "plus", style = "primary") =>
`<button id="${id}" class="button ${style}" ${dis()}>${icon(i)}${text}</button>`;
const heading = (title, subtitle, actions = "") =>
`<div class="page-heading"><div><h1>${title}</h1><p>${subtitle}</p></div><div class="heading-actions">${actions}</div></div>`;
const ago = (d) =>
!d
? "Sem execuções"
: Math.floor((Date.now() - Date.parse(d)) / 60000) < 1
? "Agora"
: `Há ${Math.floor((Date.now() - Date.parse(d)) / 60000)} min`;
async function boot() {
const me = await api("/api/me");
user = me.user;
if (!user) {
app.innerHTML = `<section class="login"><div class="login-card"><img class="logo" src="/logo.png" alt="XanaX"><span class="overline">TS6 BOTFLOW</span><h1>O teu centro de bots.</h1><p>Cria flows, gere bots e acompanha as tuas automações num só lugar.</p><div id="notice"></div><a class="button primary" href="/auth/discord">Entrar com Discord ${icon("play")}</a><p class="muted">Acesso reservado às contas autorizadas.</p></div></section>`;
const messages = {
denied: "Esta conta não está autorizada.",
authentication: "O login expirou ou foi cancelado.",
configuration: "Configura a aplicação Discord no Docker.",
};
const msg =
messages[new URLSearchParams(location.search).get("error")] ||
(!me.configured && messages.configuration);
if (msg) {
const el = document.querySelector("#notice");
el.className = "notice";
el.textContent = msg;
}
return;
}
await refresh();
app.innerHTML = `<div class="shell"><aside class="sidebar"><a href="#dashboard" class="brand"><img class="logo" src="/logo.png" alt="XanaX"></a><div class="overline">AUTOMAÇÃO</div><nav>${nav.map(([id, i, label]) => `<button class="nav" data-page="${id}">${icon(i)}<span>${label}</span></button>`).join("")}</nav><div class="sidebar-bottom"><span class="status-dot"></span><span>TS6 Botflow<small>Ambiente local</small></span><small>0.3</small></div></aside><div class="workspace"><header class="topbar"><div class="breadcrumb">Workspace <span>/</span><strong id="page-label"></strong></div><div class="topbar-right"><select id="server-select" aria-label="Servidor do bot selecionado" disabled>${data.connections.length ? data.connections.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join("") : "<option>Sem servidor ligado</option>"}</select><span class="divider"></span><div class="avatar">${esc(user.name.slice(0, 1).toUpperCase())}</div><div class="user-info"><strong>${esc(user.name)}</strong><small>${esc(user.role)}</small></div><button class="icon-button" id="logout" title="Terminar sessão">${icon("close")}</button></div></header><main id="content"></main><footer><span>XanaX · TS6 Botflow</span><span>O teu centro de automações</span></footer></div></div><dialog id="dialog"></dialog>`;
document
.querySelectorAll("[data-page]")
.forEach((b) => (b.onclick = () => navigate(b.dataset.page)));
document.querySelector(".brand").onclick = (e) => {
e.preventDefault();
navigate("dashboard");
};
document.querySelector("#logout").onclick = async () => {
await api("/auth/logout", { method: "POST" });
location.assign("/");
};
document.querySelector("#server-select").onchange = () =>
navigate("connections");
navigate(location.hash.slice(1));
}
function navigate(name) {
if (dirty && !confirm("Sair sem guardar as alterações do flow?")) return;
dirty = false;
pending = null;
page = nav.some(([id]) => id === name) ? name : "dashboard";
location.hash = page;
document
.querySelectorAll(".nav")
.forEach((b) => b.classList.toggle("active", b.dataset.page === page));
document.querySelector("#page-label").textContent = nav.find(
([id]) => id === page,
)[2];
document
.querySelector(".workspace")
.classList.toggle("editor-workspace", page === "editor");
({
dashboard: dashboardPage,
bots: botsPage,
editor: editorPage,
executions: executionsPage,
variables: variablesPage,
connections: connectionsPage,
settings: settingsPage,
})[page]();
wireExecutions();
updateServerLabel();
}
function botCard(bot) {
const runs = data.executions.filter((e) => e.botId === bot.id);
return `<article class="card bot-card"><div class="bot-card-top"><span class="icon-tile ${esc(bot.color)}">${icon(bot.color === "red" ? "shield" : bot.color === "orange" ? "music" : bot.color === "cyan" ? "users" : "bot")}</span><div class="bot-card-title"><h2>${esc(bot.name)}</h2><span class="badge ${bot.enabled ? "green" : "gray"}">${bot.enabled ? "Ativo" : "Rascunho"}</span></div><button class="switch ${bot.enabled ? "on" : ""}" data-toggle="${bot.id}" aria-label="Ativar ou desativar ${esc(bot.name)}" aria-pressed="${bot.enabled}" ${dis()}></button></div><p class="bot-description">${esc(bot.description || "Personaliza o flow deste bot.")}</p><div class="bot-meta"><span>${icon("flow")}1 flow</span><span>${icon("play")}${runs.length} execuções</span><span>${icon("clock")}${ago(runs[0]?.startedAt)}</span></div><div class="bot-actions"><button class="button secondary" data-open="${bot.id}">${icon("flow")}Abrir flow</button><button class="icon-button bordered" data-config="${bot.id}" aria-label="Configurar bot">${icon("settings")}</button></div></article>`;
}
function wireBots() {
document.querySelectorAll("[data-open]").forEach(
(b) =>
(b.onclick = () => {
botId = b.dataset.open;
navigate("editor");
}),
);
document
.querySelectorAll("[data-config]")
.forEach((b) => (b.onclick = () => configureBot(b.dataset.config)));
const create = document.querySelector("#create-bot");
if (create) create.onclick = () => newBot();
document.querySelectorAll("[data-toggle]").forEach(
(b) =>
(b.onclick = async () => {
try {
const bot = data.bots.find((bot) => bot.id === b.dataset.toggle);
await api(`/api/bots/${bot.id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !bot.enabled }),
});
await refresh();
navigate(page);
toast("Estado atualizado.");
} catch (e) {
toast(e.message, true);
}
}),
);
}
function dashboardPage() {
const today = new Date().toLocaleDateString("pt-PT", {
timeZone: "Europe/Lisbon",
});
document.querySelector("#content").innerHTML =
heading(
"Dashboard",
"Uma visão geral dos teus bots e automações.",
button("create-bot", "Criar bot"),
) +
`<div class="stats">${[
[
"Bots ativos",
data.bots.filter((b) => b.enabled).length,
"bot",
"green",
],
[
"Execuções hoje",
data.executions.filter(
(e) =>
new Date(e.startedAt).toLocaleDateString("pt-PT", {
timeZone: "Europe/Lisbon",
}) === today,
).length,
"play",
"cyan",
],
["Flows guardados", data.bots.length, "flow", "purple"],
["Servidores configurados", data.connections.length, "link", "orange"],
]
.map(
([label, n, i, c]) =>
`<div class="card stat"><span class="icon-tile ${c}">${icon(i)}</span><div><span>${label}</span><strong>${n}</strong></div></div>`,
)
.join(
"",
)}</div><div class="section-heading"><h2>Os teus bots</h2><button class="text-button" id="all-bots">Ver todos →</button></div><div class="bot-grid">${data.bots.map(botCard).join("")}</div><div class="dashboard-bottom"><section class="card"><div class="section-heading"><h2>Atividade recente</h2><button class="text-button" id="all-runs">Ver execuções →</button></div>${data.executions.slice(0, 4).map(runRow).join("") || empty("clock", "Ainda sem execuções", "Simula um flow para acompanhar aqui cada passo.")}</section><section class="card quick-start"><span class="icon-tile purple">${icon("flow")}</span><h2>O teu primeiro flow</h2><p>O rascunho Estatísticas prepara a descrição do canal 21. Escolhe a ligação e simula antes de ativar.</p><button class="button secondary" id="first-flow">${icon("flow")}Abrir editor</button></section></div>`;
wireBots();
document.querySelector("#all-bots").onclick = () => navigate("bots");
document.querySelector("#all-runs").onclick = () => navigate("executions");
document.querySelector("#first-flow").onclick = () => navigate("editor");
}
function botsPage() {
document.querySelector("#content").innerHTML =
heading(
"Bots",
"Gere os teus bots do TeamSpeak num só lugar.",
button("import-bot", "Importar", "flow", "secondary") +
button("create-bot", "Criar bot"),
) +
`<input id="bot-file" type="file" accept="application/json,.json" hidden><div class="filterbar"><input id="search-bots" placeholder="Pesquisar bots…" aria-label="Pesquisar bots"><span class="muted">${data.bots.length} bots · ${data.bots.filter((b) => b.enabled).length} ativos</span></div><div class="bot-grid" id="bot-list">${data.bots.map(botCard).join("")}</div><div class="section-heading templates-heading"><h2>Templates</h2><span class="muted">Flows prontos para configurar</span></div><div class="template-grid">${data.templates.map((t) => `<button class="template-card" data-template="${t.id}" ${dis()}><span class="icon-tile ${t.color}">${icon("flow")}</span><strong>${esc(t.name)}</strong><p>${esc(t.description)}</p><span>Criar bot →</span></button>`).join("")}</div>`;
wireBots();
document.querySelector("#search-bots").oninput = (e) => {
document.querySelector("#bot-list").innerHTML = data.bots
.filter((b) =>
b.name.toLowerCase().includes(e.target.value.toLowerCase()),
)
.map(botCard)
.join("");
wireBots();
};
document
.querySelectorAll("[data-template]")
.forEach((b) => (b.onclick = () => newBot(b.dataset.template)));
document.querySelector("#import-bot").onclick = () =>
document.querySelector("#bot-file").click();
document.querySelector("#bot-file").onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
if (file.size > 500000)
throw new Error("O ficheiro deve ter menos de 500 KB.");
const exported = JSON.parse(await file.text());
if (exported.format !== "ts6-botflow/v1" || !exported.bot)
throw new Error("Usa um ficheiro exportado pelo Botflow.");
const bot = await api("/api/bots", {
method: "POST",
body: JSON.stringify({ import: exported.bot }),
});
botId = bot.id;
await refresh();
navigate("editor");
toast(
"Bot importado como rascunho. Configura a ligação e os campos secretos.",
);
} catch (error) {
toast(error.message, true);
} finally {
e.target.value = "";
}
};
}
function modal(title, body, submit) {
const dialog = document.querySelector("#dialog");
dialog.innerHTML = `<form id="modal-form"><div class="modal-heading"><h2>${title}</h2><button class="icon-button" type="button" id="close-dialog" aria-label="Fechar">${icon("close")}</button></div>${body}<div class="modal-actions"><button type="button" class="button secondary" id="cancel">Cancelar</button><button class="button primary" ${dis()}>${icon("save")}Guardar</button></div></form>`;
dialog.showModal();
document.querySelector("#close-dialog").onclick = document.querySelector(
"#cancel",
).onclick = () => dialog.close();
document.querySelector("#modal-form").onsubmit = async (e) => {
e.preventDefault();
try {
await submit(new FormData(e.target));
dialog.close();
await refresh();
dirty = false;
navigate(page);
toast("Guardado.");
} catch (error) {
toast(error.message, true);
}
};
}
function newBot(templateId = "custom") {
const template =
data.templates.find((t) => t.id === templateId) ||
data.templates.find((t) => t.id === "custom");
modal(
"Criar bot",
`<label>Nome<input name="name" value="${esc(template.name)}" required maxlength="80"></label><label>Template<select name="templateId">${data.templates.map((t) => `<option value="${t.id}" ${t.id === template.id ? "selected" : ""}>${esc(t.name)}</option>`).join("")}</select></label><label>Descrição<textarea name="description">${esc(template.description)}</textarea></label><label>Cor<select name="color">${["purple", "green", "red", "cyan", "orange"].map((c) => `<option ${template.color === c ? "selected" : ""}>${c}</option>`).join("")}</select></label><p class="form-help">O bot começa desativado. Configura os blocos e escolhe o servidor no editor.</p>`,
async (form) => {
const bot = await api("/api/bots", {
method: "POST",
body: JSON.stringify(Object.fromEntries(form)),
});
botId = bot.id;
page = "editor";
},
);
}
function configureBot(id) {
botId = id;
const bot = selectedBot();
modal(
"Configuração do bot",
`<label>Nome<input name="name" value="${esc(bot.name)}" required maxlength="80"></label><label>Descrição<textarea name="description">${esc(bot.description)}</textarea></label><label>Servidor<select name="connectionId"><option value="">Escolher ligação</option>${data.connections.map((c) => `<option value="${c.id}" ${bot.connectionId === c.id ? "selected" : ""}>${esc(c.name)}</option>`).join("")}</select></label><label>Limite por execução (10–600 segundos)<input name="timeoutSeconds" type="number" min="10" max="600" value="${bot.timeoutSeconds || 120}"></label><div class="bot-management"><button type="button" class="button secondary" id="duplicate-bot" ${dis()}>${icon("plus")}Duplicar</button><button type="button" class="button secondary" id="export-bot" ${dis()}>${icon("save")}Exportar</button><button type="button" class="icon-button danger" id="delete-bot" title="Apagar bot" aria-label="Apagar bot" ${dis()}>${icon("trash")}</button></div><p class="form-help">A exportação omite a ligação e os campos secretos dos blocos.</p>`,
(f) =>
api(`/api/bots/${bot.id}`, {
method: "PUT",
body: JSON.stringify(Object.fromEntries(f)),
}),
);
document.querySelector("#duplicate-bot").onclick = async () => {
try {
const copy = await api(`/api/bots/${bot.id}/duplicate`, {
method: "POST",
});
botId = copy.id;
document.querySelector("#dialog").close();
await refresh();
navigate("bots");
toast("Cópia criada, desativada.");
} catch (error) {
toast(error.message, true);
}
};
document.querySelector("#export-bot").onclick = async () => {
try {
const body = await api(`/api/bots/${bot.id}/export`, { method: "POST" }),
url = URL.createObjectURL(
new Blob([JSON.stringify(body, null, 2)], {
type: "application/json",
}),
),
a = document.createElement("a");
a.href = url;
a.download = `${bot.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("Bot exportado.");
} catch (error) {
toast(error.message, true);
}
};
document.querySelector("#delete-bot").onclick = async () => {
if (!confirm(`Apagar o bot «${bot.name}»?`)) return;
try {
await api(`/api/bots/${bot.id}`, { method: "DELETE" });
document.querySelector("#dialog").close();
await refresh();
navigate("bots");
toast("Bot apagado.");
} catch (error) {
toast(error.message, true);
}
};
}
function editorPage() {
const bot = selectedBot();
if (!bot) {
document.querySelector("#content").innerHTML =
heading(
"Editor de flows",
"Cria um bot para começar.",
button("create-bot", "Criar bot"),
) + empty("flow", "Ainda sem flows", "Cria o teu primeiro bot.");
wireBots();
return;
}
editorView = mountEditor(bot, {
api,
data,
owner,
dis,
icon,
esc,
heading,
toast,
refresh,
showRun,
onDirty: (value) => {
dirty = value;
updateServerLabel();
},
onBot: (id) => {
if (dirty && !confirm("Sair sem guardar o flow?")) {
document.querySelector("#bot-select").value = botId;
return;
}
botId = id;
dirty = false;
editorPage();
updateServerLabel();
},
});
}
const statuses = {
success: ["green", "check", "Concluída"],
error: ["red", "close", "Falhou"],
warning: ["orange", "shield", "Erro tratado"],
running: ["cyan", "clock", "Em execução"],
cancelled: ["gray", "close", "Cancelada"],
};
function runRow(e) {
const [color, i, label] = statuses[e.status] || statuses.error;
return `<button class="execution-row ${executionId === e.id ? "selected" : ""}" data-execution="${e.id}"><span class="execution-status ${color}">${icon(i)}</span><span><strong>${esc(e.botName)}</strong><small>${e.simulation ? "Simulação" : "Execução real"} · ${label}</small></span><time>${ago(e.startedAt)}</time></button>`;
}
function runDetail(e) {
const [color, , label] = statuses[e.status] || statuses.error;
return `<div class="section-heading"><h2>${esc(e.botName)}</h2><span class="badge ${color}">${label}</span></div><span class="muted">${new Date(e.startedAt).toLocaleString("pt-PT")} · ${e.simulation ? "Simulação com dados de exemplo" : "Servidor TeamSpeak"}</span><div class="execution-summary"><div><span>Duração</span><strong>${((e.durationMs || 0) / 1000).toFixed(2)}s</strong></div><div><span>Iniciado por</span><strong>${esc(e.actor)}</strong></div><div><span>Passos</span><strong>${e.steps.length}</strong></div></div>${e.status === "running" ? `<button class="button secondary danger" data-cancel-execution="${e.botId}" ${dis()}>Cancelar execução</button>` : ""}<h3>Eventos e etapas</h3><div class="timeline">${e.steps
.map((s) => {
const [c, i] = statuses[s.status] || statuses.error;
return `<div class="timeline-step"><span class="execution-status ${c}">${icon(i)}</span><div><strong>${esc(s.label)}</strong><small class="muted">${esc(s.type)}${s.attempts > 1 ? ` · ${s.attempts} tentativas` : ""}</small><p>${esc(s.message || "A executar…")}</p></div><time>${s.durationMs || 0} ms</time></div>`;
})
.join(
"",
)}</div>${e.error ? `<div class="notice error">${esc(e.error)}</div>` : ""}${e.output ? `<h3>Texto produzido</h3><pre>${esc(e.output)}</pre>` : ""}`;
}
function showRun(e) {
const dialog = document.querySelector("#dialog");
dialog.innerHTML = `<div class="modal-heading"><h2>${e.simulation ? "Resultado da simulação" : "Resultado da execução"}</h2><button class="icon-button" id="close-run">${icon("close")}</button></div>${runDetail(e)}`;
dialog.showModal();
wireCancel();
document.querySelector("#close-run").onclick = () => dialog.close();
}
function wireExecutions() {
document.querySelectorAll("[data-execution]").forEach(
(b) =>
(b.onclick = () => {
executionId = b.dataset.execution;
const e = data.executions.find((e) => e.id === executionId),
el = document.querySelector("#execution-detail");
if (el) {
el.innerHTML = runDetail(e);
document
.querySelectorAll(".execution-row")
.forEach((row) => row.classList.toggle("selected", row === b));
wireCancel();
} else showRun(e);
}),
);
wireCancel();
}
function wireCancel() {
document.querySelectorAll("[data-cancel-execution]").forEach(
(b) =>
(b.onclick = async () => {
try {
await api(`/api/bots/${b.dataset.cancelExecution}/cancel`, {
method: "POST",
});
toast("Cancelamento solicitado.");
} catch (error) {
toast(error.message, true);
}
}),
);
}
function executionsPage() {
const filtered = data.executions.filter(
(e) =>
(!executionFilter.bot || e.botId === executionFilter.bot) &&
(!executionFilter.status || e.status === executionFilter.status) &&
(!executionFilter.mode || String(e.simulation) === executionFilter.mode),
);
if (!filtered.some((e) => e.id === executionId))
executionId = filtered[0]?.id;
document.querySelector("#content").innerHTML =
heading(
"Execuções",
"Histórico por passo, com atualização automática a cada 3 segundos.",
`<button class="button secondary" id="refresh-runs">${icon("refresh")}Atualizar</button>`,
) +
`<div class="execution-filters"><select id="execution-bot" aria-label="Filtrar por bot"><option value="">Todos os bots</option>${data.bots.map((b) => `<option value="${b.id}" ${executionFilter.bot === b.id ? "selected" : ""}>${esc(b.name)}</option>`).join("")}</select><select id="execution-status" aria-label="Filtrar por estado"><option value="">Todos os estados</option>${Object.entries(
statuses,
)
.map(
([key, [, , label]]) =>
`<option value="${key}" ${executionFilter.status === key ? "selected" : ""}>${label}</option>`,
)
.join(
"",
)}</select><select id="execution-mode" aria-label="Filtrar por modo"><option value="">Todos os modos</option><option value="false" ${executionFilter.mode === "false" ? "selected" : ""}>Execução real</option><option value="true" ${executionFilter.mode === "true" ? "selected" : ""}>Simulação</option></select></div><div class="executions-layout"><section class="card execution-list"><div class="section-heading"><h2>Execuções recentes</h2><span class="badge gray">${filtered.length}</span></div>${filtered.map(runRow).join("") || empty("clock", "Sem resultados", "Escolhe outro filtro ou simula um flow.")}</section><section class="card" id="execution-detail">${filtered.length ? runDetail(filtered.find((e) => e.id === executionId)) : empty("flow", "Cada passo, num só lugar", "Os resultados ficam guardados aqui.")}</section></div>`;
for (const [name, key] of [
["bot", "bot"],
["status", "status"],
["mode", "mode"],
])
document.querySelector(`#execution-${name}`).onchange = (e) => {
executionFilter[key] = e.target.value;
executionsPage();
wireExecutions();
};
document.querySelector("#refresh-runs").onclick = async () => {
try {
await refresh();
executionsPage();
wireExecutions();
} catch (error) {
toast(error.message, true);
}
};
}
const variableRow = (k, v) => {
const kind =
(v !== null && typeof v === "object") || v === null
? "json"
: typeof v === "number"
? "number"
: typeof v === "boolean"
? "boolean"
: "text";
return `<div class="variable-row"><input data-key value="${esc(k)}" placeholder="nome" ${owner() ? "" : "readonly"}><select data-value-type aria-label="Tipo do valor" ${dis()}>${["text", "number", "boolean", "json"].map((t) => `<option value="${t}" ${t === kind ? "selected" : ""}>${{ text: "Texto", number: "Número", boolean: "Booleano", json: "JSON" }[t]}</option>`).join("")}</select><input data-value value="${esc(kind === "json" ? JSON.stringify(v) : v)}" placeholder="valor" ${owner() ? "" : "readonly"}><button type="button" class="icon-button danger" data-remove-var ${dis()}>${icon("trash")}</button></div>`;
};
function variablesPage() {
const bot = selectedBot();
document.querySelector("#content").innerHTML =
heading(
"Variáveis",
"Valores persistentes para reutilizar nos teus flows.",
`<select id="variables-bot" aria-label="Bot">${data.bots.map((b) => `<option value="${b.id}" ${b.id === botId ? "selected" : ""}>${esc(b.name)}</option>`).join("")}</select>`,
) +
`<section class="card"><div class="section-heading"><h2>Variáveis do bot</h2>${button("add-variable", "Adicionar variável", "plus", "secondary")}</div><p class="form-help">Usa <code>{{variables.nome}}</code> no texto de um bloco.</p><form id="variables-form"><div class="table-heading"><span>Nome</span><span>Tipo</span><span>Valor</span><span></span></div><div id="variable-rows">${Object.entries(
bot?.variables || {},
)
.map(([k, v]) => variableRow(k, v))
.join(
"",
)}</div><div class="form-actions"><button class="button primary" ${dis()}>${icon("save")}Guardar variáveis</button></div></form></section>`;
const wire = () =>
document
.querySelectorAll("[data-remove-var]")
.forEach((b) => (b.onclick = () => b.closest(".variable-row").remove()));
wire();
document.querySelector("#variables-bot").onchange = (e) => {
botId = e.target.value;
variablesPage();
};
document.querySelector("#add-variable").onclick = () => {
document
.querySelector("#variable-rows")
.insertAdjacentHTML("beforeend", variableRow("", ""));
wire();
};
document.querySelector("#variables-form").onsubmit = async (e) => {
e.preventDefault();
if (!bot) return;
try {
const variables = Object.create(null);
document.querySelectorAll(".variable-row").forEach((row) => {
const key = row.querySelector("[data-key]").value.trim();
if (
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key) ||
Object.hasOwn(variables, key)
)
throw new Error("Usa nomes únicos com letras, números e underscore.");
const value = row.querySelector("[data-value]").value,
type = row.querySelector("[data-value-type]").value;
if (type === "number") {
if (value.trim() === "" || !Number.isFinite(Number(value)))
throw new Error("Indica um número válido.");
variables[key] = Number(value);
} else if (type === "json") variables[key] = JSON.parse(value);
else if (type === "boolean") {
if (!["true", "false"].includes(value))
throw new Error("Usa true ou false para booleanos.");
variables[key] = value === "true";
} else variables[key] = value;
});
await api(`/api/bots/${bot.id}`, {
method: "PUT",
body: JSON.stringify({ variables }),
});
await refresh();
toast("Variáveis guardadas.");
} catch (error) {
toast(error.message, true);
}
};
}
function connectionsPage() {
document.querySelector("#content").innerHTML =
heading(
"Ligações",
"WebQuery para ações e SSH Query para eventos e comandos.",
button("new-connection", "Adicionar servidor", "link"),
) +
`<div class="connection-grid">${
data.connections
.map((c) => {
const states = c.eventState || [],
connected = states.filter((s) => s.status === "connected").length;
return `<article class="card"><div class="bot-card-top"><span class="icon-tile cyan">${icon("link")}</span><div><h2>${esc(c.name)}</h2><span class="muted">WebQuery · Servidor #${c.serverId}</span></div></div><p class="connection-url">${esc(c.url)}</p><div class="details"><div><span>Eventos SSH</span><strong>${c.eventsEnabled ? (states.length ? `${connected} / ${states.length} escutas ligadas` : "À espera de um bot ativo") : "Desativados"}</strong></div>${c.eventsEnabled ? `<div><span>Destino SSH</span><strong>${esc(c.sshHost)}:${c.sshPort}</strong></div>` : ""}</div>${states
.filter((s) => s.error)
.map((s) => `<p class="notice error">${esc(s.error)}</p>`)
.join(
"",
)}<div class="connection-actions"><button class="button secondary" data-edit-connection="${c.id}">${icon("settings")}Configurar</button><button class="button secondary" data-test-connection="${c.id}" ${dis()}>${icon("refresh")}Testar WebQuery</button></div></article>`;
})
.join("") ||
`<section class="card">${empty("link", "Liga o teu TeamSpeak", "Adiciona o endereço WebQuery e a API key.")}</section>`
}</div><div class="notice connection-tip">Os eventos usam a porta SSH Query do TeamSpeak (habitualmente 10022). O nome <code>teamspeak6</code> funciona quando os containers partilham a rede Docker.</div>`;
document.querySelector("#new-connection").onclick = () => connectionModal();
document
.querySelectorAll("[data-edit-connection]")
.forEach(
(b) =>
(b.onclick = () =>
connectionModal(
data.connections.find((c) => c.id === b.dataset.editConnection),
)),
);
document.querySelectorAll("[data-test-connection]").forEach(
(b) =>
(b.onclick = async () => {
b.disabled = true;
try {
const result = await api(
`/api/connections/${b.dataset.testConnection}/test`,
{ method: "POST" },
);
toast(
`Ligação confirmada: ${result.server.virtualserver_name || "TeamSpeak"}`,
);
} catch (e) {
toast(e.message, true);
} finally {
b.disabled = !owner();
}
}),
);
}
function connectionModal(c = {}) {
modal(
c.id ? "Configurar servidor" : "Adicionar servidor",
`<label>Nome<input name="name" value="${esc(c.name || "Servidor principal")}" required></label><label>Endereço WebQuery<input name="url" type="url" value="${esc(c.url || "http://192.168.88.99:10080")}" required></label><label>ID do servidor virtual<input name="serverId" type="number" min="1" value="${c.serverId || 1}" required></label><label>API key<input name="apiKey" type="password" autocomplete="new-password" placeholder="${c.hasApiKey ? "Guardada · deixa vazio para manter" : "Coloca a API key"}" ${c.hasApiKey ? "" : "required"}></label><span class="overline">EVENTOS E COMANDOS</span><label>Escuta SSH Query<select name="eventsEnabled"><option value="false">Desativada</option><option value="true" ${c.eventsEnabled ? "selected" : ""}>Ativada</option></select></label><div class="form-grid"><label>Host SSH<input name="sshHost" value="${esc(c.sshHost || "")}" placeholder="Mesmo host do WebQuery"></label><label>Porta SSH Query<input name="sshPort" type="number" min="1" max="65535" value="${c.sshPort || 10022}"></label></div><label>Utilizador SSH Query<input name="sshUsername" value="${esc(c.sshUsername || "")}" autocomplete="off" placeholder="serveradmin ou utilizador Query dedicado"></label><label>Password SSH Query<input name="sshPassword" type="password" autocomplete="new-password" placeholder="${c.hasSshPassword ? "Guardada · deixa vazio para manter" : "Password Query"}"></label><details><summary>Identidade do servidor SSH</summary><label>Impressão digital SHA-256 (hex)<input name="sshFingerprint" value="${esc(c.sshFingerprint || "")}" placeholder="Guardada na primeira ligação" pattern="[a-fA-F0-9]{64}"></label><p class="form-help">A primeira ligação guarda a impressão digital. Se o servidor mudar de chave, confirma a nova chave e atualiza este campo.</p></details><p class="form-help">As credenciais são guardadas cifradas. A escuta inicia quando ativares um bot com eventos ou comandos.</p>${c.id ? `<button type="button" class="text-button danger" id="delete-connection" ${dis()}>Apagar ligação</button>` : ""}`,
(f) =>
api("/api/connections", {
method: "POST",
body: JSON.stringify({ ...Object.fromEntries(f), id: c.id }),
}),
);
if (c.id)
document.querySelector("#delete-connection").onclick = async () => {
if (!confirm("Apagar esta ligação?")) return;
try {
await api(`/api/connections/${c.id}`, { method: "DELETE" });
document.querySelector("#dialog").close();
await refresh();
navigate("connections");
toast("Ligação apagada.");
} catch (error) {
toast(error.message, true);
}
};
}
function settingsPage() {
document.querySelector("#content").innerHTML =
heading("Definições", "A tua conta e o ambiente do painel.") +
`<div class="settings-grid"><section class="card"><h2>O teu acesso</h2><div class="profile-card"><div class="avatar large">${esc(user.name.slice(0, 1).toUpperCase())}</div><div><h3>${esc(user.name)}</h3><span class="badge purple">${esc(user.role)}</span></div></div><div class="details"><div><span>ID Discord</span><strong>${esc(user.id)}</strong></div><div><span>Autenticação</span><strong>Discord</strong></div><div><span>Permissões</span><strong>${owner() ? "Configurar, guardar e executar" : "Consultar bots, flows e execuções"}</strong></div></div></section><section class="card"><h2>Ambiente</h2><div class="details"><div><span>Blocos disponíveis</span><strong>${Object.keys(data.nodeTypes).length}</strong></div><div><span>Motor</span><strong>WebQuery + eventos SSH</strong></div><div><span>Painel</span><strong>TS6 Botflow 0.3</strong></div><div><span>Dados</span><strong>Volume Docker persistente</strong></div><div><span>Novas contas</span><strong>Apenas IDs autorizados</strong></div></div><p class="form-help">A tua conta Discord e as ligações TeamSpeak têm permissões separadas.</p></section></div>`;
}
boot().catch((e) => {
app.innerHTML = `<div class="login"><div class="login-card"><h1>Não foi possível carregar o painel.</h1><p>${esc(e.message)}</p><a class="button primary" href="/">Tentar novamente</a></div></div>`;
});
window.addEventListener("beforeunload", (e) => {
if (dirty) {
e.preventDefault();
e.returnValue = "";
}
});
function updateServerLabel() {
const select = document.querySelector("#server-select");
if (!select) return;
const connectionId =
page === "editor"
? editorView?.draft.connectionId
: selectedBot()?.connectionId;
select.innerHTML =
'<option value="">Sem servidor escolhido</option>' +
data.connections
.map(
(c) =>
`<option value="${c.id}" ${c.id === connectionId ? "selected" : ""}>${esc(c.name)}</option>`,
)
.join("");
}
window.addEventListener("keydown", (e) => {
if (page === "editor" && !document.querySelector("dialog[open]"))
editorView?.keyboard(e);
});
setInterval(async () => {
if (
!user ||
polling ||
document.hidden ||
document.querySelector("dialog[open]") ||
!["executions", "connections"].includes(page)
)
return;
polling = true;
try {
await refresh();
if (page === "executions") {
executionsPage();
wireExecutions();
}
if (page === "connections") connectionsPage();
updateServerLabel();
} catch {
} finally {
polling = false;
}
}, 3000);