All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
758 lines
37 KiB
JavaScript
758 lines
37 KiB
JavaScript
import { mountEditor } from "./flow-editor.js";
|
||
const app = document.querySelector("#app");
|
||
const esc = (v) =>
|
||
String(v ?? "").replace(
|
||
/[&<>"']/g,
|
||
(c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
||
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);
|