/
mcmare
/
RustAPI
Обзор
Документация
Войти
/
mcmare
/
RustAPI
Код
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
engine/webui-assets/app.js
531 строка
17 KB
mcmare
Add input/output schema for script endpoints, redesign WebUI with Tailwind
19 июл 2026, 09:28
19 июл 2026, 09:28
f3df0d8
Код
Авторство
О чём код?
"use strict"; // ---------- helpers ---------- async function api(method, path, body) { const opts = { method, credentials: "same-origin", headers: {} }; if (body !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); } const resp = await fetch(path, opts); if (resp.status === 204) return null; const isJson = (resp.headers.get("content-type") || "").includes("application/json"); const data = isJson ? await resp.json().catch(() => null) : await resp.text(); if (!resp.ok) { const message = isJson && data && data.error ? data.error.message : String(data || resp.statusText); const err = new Error(message); err.status = resp.status; throw err; } return data; } function el(id) { return document.getElementById(id); } function show(id) { el(id).classList.remove("hidden"); } function hide(id) { el(id).classList.add("hidden"); } function setError(id, message) { const container = el(id); container.textContent = ""; if (!message) return; const box = document.createElement("div"); box.className = "error-box"; box.textContent = message; container.appendChild(box); } // Builds a <td> via textContent rather than string concatenation, so row data never // passes through innerHTML — no escaping to get right, no HTML-injection class of bug. function td(text, cls) { const cell = document.createElement("td"); if (cls) cell.className = cls; cell.textContent = text == null ? "" : text; return cell; } function toast(message, kind) { const t = document.createElement("div"); t.className = `toast ${kind || ""}`; t.textContent = message; document.body.appendChild(t); setTimeout(() => t.remove(), 3500); } // ---------- theme ---------- function applyTheme(theme) { document.documentElement.dataset.theme = theme; localStorage.setItem("rustapi-admin-theme", theme); } (function initTheme() { const saved = localStorage.getItem("rustapi-admin-theme"); const theme = saved || (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); applyTheme(theme); })(); el("theme-toggle").addEventListener("click", () => { const current = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; applyTheme(current); }); // ---------- boot / views ---------- async function boot() { let status; try { status = await api("GET", "/ui/api/status"); } catch (e) { toast("Не удалось связаться с сервером: " + e.message, "error"); return; } hide("view-setup"); hide("view-login"); hide("view-dashboard"); hide("logout-btn"); if (status.needs_setup) { show("view-setup"); } else if (!status.authenticated) { show("view-login"); } else { show("view-dashboard"); show("logout-btn"); loadDashboard(); } } el("setup-form").addEventListener("submit", async (e) => { e.preventDefault(); setError("setup-error", ""); try { await api("POST", "/ui/api/setup", { username: el("setup-username").value, password: el("setup-password").value, }); boot(); } catch (err) { setError("setup-error", err.message); } }); el("login-form").addEventListener("submit", async (e) => { e.preventDefault(); setError("login-error", ""); try { await api("POST", "/ui/api/login", { username: el("login-username").value, password: el("login-password").value, }); boot(); } catch (err) { setError("login-error", err.message); } }); el("logout-btn").addEventListener("click", async () => { await api("POST", "/ui/api/logout"); boot(); }); // ---------- tabs ---------- document.querySelectorAll(".tab-btn").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active")); document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active")); btn.classList.add("active"); el("panel-" + btn.dataset.tab).classList.add("active"); }); }); function loadDashboard() { if (!document.querySelector(".tab-btn.active")) { document.querySelector(".tab-btn").classList.add("active"); el("panel-connections").classList.add("active"); } loadConnections(); loadTokens(); loadEndpoints(); loadScripts(); } // ---------- connections ---------- async function loadConnections() { const list = await api("GET", "/ui/api/connections"); const tbody = el("connections-table"); tbody.innerHTML = ""; for (const c of list) { const tr = document.createElement("tr"); tr.appendChild(td(c.name, "mono")); tr.appendChild(td(c.driver)); tr.appendChild(td(c.url, "mono")); const actionsCell = td(""); const delBtn = document.createElement("button"); delBtn.className = "danger"; delBtn.textContent = "Удалить"; delBtn.addEventListener("click", async () => { try { await api("DELETE", `/ui/api/connections/${encodeURIComponent(c.name)}`); toast("Подключение удалено. Требуется перезапуск сервера.", "success"); loadConnections(); } catch (err) { toast(err.message, "error"); } }); actionsCell.appendChild(delBtn); tr.appendChild(actionsCell); tbody.appendChild(tr); } populateConnectionSelect(list); } function populateConnectionSelect(list) { const select = el("crud-connection"); const current = select.value; select.innerHTML = ""; for (const c of list) { const opt = document.createElement("option"); opt.value = c.name; opt.textContent = c.name; select.appendChild(opt); } if (current) select.value = current; } el("connection-form").addEventListener("submit", async (e) => { e.preventDefault(); setError("connection-error", ""); try { await api("POST", "/ui/api/connections", { name: el("conn-name").value, driver: el("conn-driver").value, url: el("conn-url").value, }); el("connection-form").reset(); toast("Подключение сохранено. Требуется перезапуск сервера.", "success"); loadConnections(); } catch (err) { setError("connection-error", err.message); } }); // ---------- tokens ---------- async function loadTokens() { const list = await api("GET", "/ui/api/tokens"); const tbody = el("tokens-table"); tbody.innerHTML = ""; list.forEach((token, index) => { const tr = document.createElement("tr"); tr.appendChild(td(token, "mono")); const actionsCell = td(""); const delBtn = document.createElement("button"); delBtn.className = "danger"; delBtn.textContent = "Удалить"; delBtn.addEventListener("click", async () => { try { await api("DELETE", `/ui/api/tokens/${index}`); toast("Токен удалён. Требуется перезапуск сервера.", "success"); loadTokens(); } catch (err) { toast(err.message, "error"); } }); actionsCell.appendChild(delBtn); tr.appendChild(actionsCell); tbody.appendChild(tr); }); } el("token-form").addEventListener("submit", async (e) => { e.preventDefault(); setError("token-error", ""); try { await api("POST", "/ui/api/tokens", { token: el("token-value").value }); el("token-form").reset(); toast("Токен добавлен. Требуется перезапуск сервера.", "success"); loadTokens(); } catch (err) { setError("token-error", err.message); } }); // ---------- endpoints ---------- const FIELD_TYPES = ["string", "number", "bool", "uuid", "datetime", "object", "array<string>", "array<number>", "array<bool>", "array<uuid>", "array<datetime>", "array<object>"]; function addFieldRow(containerId, name, type) { const container = el(containerId); const row = document.createElement("div"); row.className = "field-list-row"; const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.placeholder = "имя поля"; nameInput.value = name || ""; const typeSelect = document.createElement("select"); for (const t of FIELD_TYPES) { const opt = document.createElement("option"); opt.value = t; opt.textContent = t; typeSelect.appendChild(opt); } typeSelect.value = type || "string"; const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "ghost"; removeBtn.textContent = "✕"; removeBtn.addEventListener("click", () => row.remove()); row.appendChild(nameInput); row.appendChild(typeSelect); row.appendChild(removeBtn); container.appendChild(row); } function collectFields(containerId) { const out = {}; el(containerId).querySelectorAll(".field-list-row").forEach((row) => { const [nameInput, typeSelect] = row.querySelectorAll("input, select"); if (nameInput.value.trim()) out[nameInput.value.trim()] = typeSelect.value; }); return out; } el("crud-input-add").addEventListener("click", () => addFieldRow("crud-input-fields")); el("crud-output-add").addEventListener("click", () => addFieldRow("crud-output-fields")); el("script-input-add").addEventListener("click", () => addFieldRow("script-input-fields")); el("script-output-add").addEventListener("click", () => addFieldRow("script-output-fields")); let editingCrudSlug = null; let editingScriptSlug = null; function openCrudForm(existing) { editingCrudSlug = existing ? existing.slug : null; el("crud-form-title").textContent = existing ? `Редактирование: ${existing.method} ${existing.path}` : "Новый CRUD-эндпоинт"; el("crud-method").value = existing ? existing.method : "GET"; el("crud-path").value = existing ? existing.path : ""; el("crud-connection").value = existing ? existing.connection : ""; el("crud-table").value = existing ? existing.table : ""; el("crud-operation").value = existing ? existing.operation : "create"; el("crud-primary-key").value = existing ? existing.primary_key : "id"; el("crud-input-fields").innerHTML = ""; el("crud-output-fields").innerHTML = ""; if (existing) { Object.entries(existing.input || {}).forEach(([k, v]) => addFieldRow("crud-input-fields", k, v)); Object.entries(existing.output || {}).forEach(([k, v]) => addFieldRow("crud-output-fields", k, v)); } setError("crud-error", ""); hide("script-form-card"); show("crud-form-card"); } function openScriptForm(existing) { editingScriptSlug = existing ? existing.slug : null; el("script-form-title").textContent = existing ? `Редактирование: ${existing.method} ${existing.path}` : "Новый скриптовый эндпоинт"; el("script-method").value = existing ? existing.method : "POST"; el("script-path").value = existing ? existing.path : ""; el("script-filename").value = existing ? existing.script : ""; el("script-filename").disabled = !!existing; el("script-source").value = ""; el("script-input-fields").innerHTML = ""; el("script-output-fields").innerHTML = ""; if (existing) { Object.entries(existing.input || {}).forEach(([k, v]) => addFieldRow("script-input-fields", k, v)); Object.entries(existing.output || {}).forEach(([k, v]) => addFieldRow("script-output-fields", k, v)); } setError("script-endpoint-error", ""); hide("crud-form-card"); show("script-form-card"); } el("new-crud-btn").addEventListener("click", () => openCrudForm(null)); el("new-script-btn").addEventListener("click", () => openScriptForm(null)); el("crud-cancel").addEventListener("click", () => hide("crud-form-card")); el("script-endpoint-cancel").addEventListener("click", () => hide("script-form-card")); el("crud-form").addEventListener("submit", async (e) => { e.preventDefault(); setError("crud-error", ""); const body = { kind: "crud", method: el("crud-method").value, path: el("crud-path").value, connection: el("crud-connection").value, table: el("crud-table").value, operation: el("crud-operation").value, primary_key: el("crud-primary-key").value || "id", input: collectFields("crud-input-fields"), output: collectFields("crud-output-fields"), }; try { if (editingCrudSlug) { await api("PUT", `/ui/api/endpoints/${editingCrudSlug}`, body); } else { await api("POST", "/ui/api/endpoints", body); } hide("crud-form-card"); toast("Эндпоинт сохранён.", "success"); loadEndpoints(); } catch (err) { setError("crud-error", err.message); } }); el("script-endpoint-form").addEventListener("submit", async (e) => { e.preventDefault(); setError("script-endpoint-error", ""); const body = { kind: "script", method: el("script-method").value, path: el("script-path").value, script: el("script-filename").value, script_source: el("script-source").value || undefined, input: collectFields("script-input-fields"), output: collectFields("script-output-fields"), }; try { if (editingScriptSlug) { await api("PUT", `/ui/api/endpoints/${editingScriptSlug}`, body); } else { await api("POST", "/ui/api/endpoints", body); } hide("script-form-card"); toast("Эндпоинт сохранён.", "success"); loadEndpoints(); loadScripts(); } catch (err) { setError("script-endpoint-error", err.message); } }); function methodBadge(method) { const cell = document.createElement("td"); cell.className = "px-2 py-2"; const span = document.createElement("span"); span.className = `method-badge ${method.toLowerCase()}`; span.textContent = method; cell.appendChild(span); return cell; } async function loadEndpoints() { const list = await api("GET", "/ui/api/endpoints"); const tbody = el("endpoints-table"); tbody.innerHTML = ""; for (const ep of list) { const tr = document.createElement("tr"); tr.appendChild(methodBadge(ep.method)); tr.appendChild(td(ep.path, "mono")); const kindCell = td(""); kindCell.appendChild(document.createTextNode(ep.kind + " ")); const badge = document.createElement("span"); badge.className = ep.slug ? "badge editable" : "badge readonly"; badge.textContent = ep.slug ? "редактируемый" : "файл конфига"; kindCell.appendChild(badge); tr.appendChild(kindCell); const editCell = td(""); const deleteCell = td(""); if (ep.slug) { const editBtn = document.createElement("button"); editBtn.textContent = "Изменить"; editBtn.addEventListener("click", () => (ep.kind === "crud" ? openCrudForm(ep) : openScriptForm(ep))); editCell.appendChild(editBtn); const delBtn = document.createElement("button"); delBtn.className = "danger"; delBtn.textContent = "Удалить"; delBtn.addEventListener("click", async () => { if (!confirm(`Удалить эндпоинт ${ep.method} ${ep.path}?`)) return; try { await api("DELETE", `/ui/api/endpoints/${ep.slug}`); toast("Эндпоинт удалён.", "success"); loadEndpoints(); } catch (err) { toast(err.message, "error"); } }); deleteCell.appendChild(delBtn); } tr.appendChild(editCell); tr.appendChild(deleteCell); tbody.appendChild(tr); } } // ---------- scripts ---------- let editingScriptFilename = null; async function loadScripts() { const list = await api("GET", "/ui/api/scripts"); const tbody = el("scripts-table"); tbody.innerHTML = ""; for (const s of list) { const tr = document.createElement("tr"); tr.appendChild(td(s.filename, "mono")); const cell = td(""); tr.appendChild(cell); const editBtn = document.createElement("button"); editBtn.textContent = "Открыть"; editBtn.addEventListener("click", () => openScriptEditor(s.filename)); cell.appendChild(editBtn); const delBtn = document.createElement("button"); delBtn.className = "danger"; delBtn.textContent = "Удалить"; delBtn.addEventListener("click", () => deleteScript(s.filename, false)); cell.appendChild(delBtn); tbody.appendChild(tr); } } async function openScriptEditor(filename) { const source = await api("GET", `/ui/api/scripts/${encodeURIComponent(filename)}`); editingScriptFilename = filename; el("script-edit-title").textContent = filename; el("script-edit-source").value = source; setError("script-edit-error", ""); show("script-edit-card"); } el("script-edit-cancel").addEventListener("click", () => hide("script-edit-card")); el("script-save-btn").addEventListener("click", async () => { setError("script-edit-error", ""); try { await api("PUT", `/ui/api/scripts/${encodeURIComponent(editingScriptFilename)}`, { source: el("script-edit-source").value, }); toast("Скрипт сохранён.", "success"); hide("script-edit-card"); } catch (err) { setError("script-edit-error", err.message); } }); async function deleteScript(filename, force) { if (!force && !confirm(`Удалить скрипт ${filename}?`)) return; try { await api("DELETE", `/ui/api/scripts/${encodeURIComponent(filename)}${force ? "?force=true" : ""}`); toast("Скрипт удалён.", "success"); loadScripts(); } catch (err) { if (err.status === 409 && confirm(err.message + "\n\nУдалить всё равно?")) { deleteScript(filename, true); } else { toast(err.message, "error"); } } } // ---------- go ---------- boot();