mirror of
https://github.com/mrvasil/ctf-ad-mirror-tool-rust.git
synced 2026-10-23 14:31:02 +00:00
252 lines
21 KiB
JavaScript
252 lines
21 KiB
JavaScript
"use strict";
|
||
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
const state = { snapshot: null, csrf: null, source: null, busy: false, stale: true, cards: new Map(), editId: null, watchdog: null };
|
||
const statusClass = { 101: "up", 102: "corrupt", 103: "mumble", 104: "down", 110: "fail" };
|
||
const statusOrder = { 101: 0, 102: 1, 103: 2, 104: 3, 110: 4 };
|
||
const statusName = { 101: "UP", 102: "CORRUPT", 103: "MUMBLE", 104: "DOWN", 110: "CHECK_FAILED" };
|
||
const el = (tag, cls = "", text = "") => { const node = document.createElement(tag); node.className = cls; node.textContent = text; return node; };
|
||
const serviceById = (id) => state.snapshot?.services.find((service) => service.id === id);
|
||
const teamById = (id) => state.snapshot?.teams.find((team) => team.id === id);
|
||
const portOf = (service) => Number(service.listen.slice(service.listen.lastIndexOf(":") + 1));
|
||
function button(cls, title, icon, action) {
|
||
const node = el("button", cls); node.type = "button"; node.title = title; node.setAttribute("aria-label", title);
|
||
if (icon) { const i = el("i", `fas fa-${icon}`); i.setAttribute("aria-hidden", "true"); node.append(i); }
|
||
node.addEventListener("click", (event) => { event.stopPropagation(); action(event); }); return node;
|
||
}
|
||
function alert(message) { $("alert-message").textContent = message; $("alert").hidden = !message; }
|
||
function closeStream() { state.source?.close(); state.source = null; clearTimeout(state.watchdog); }
|
||
function watchUpdates() {
|
||
clearTimeout(state.watchdog);
|
||
state.watchdog = setTimeout(() => { if (state.csrf) { state.stale = true; render(); alert("Нет свежих обновлений от сервера. Обновите подключение."); } }, 5000);
|
||
}
|
||
function showLogin() {
|
||
closeStream(); state.csrf = null; state.snapshot = null; state.stale = true;
|
||
for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
|
||
$("header").hidden = true; $("dashboard").hidden = true; $("login").hidden = false; document.title = "Вход";
|
||
}
|
||
async function api(path, options = {}) {
|
||
const headers = { ...options.headers };
|
||
if (options.body !== undefined) headers["Content-Type"] = "application/json";
|
||
if (state.csrf && options.method && options.method !== "GET") headers["X-CSRF-Token"] = state.csrf;
|
||
let response;
|
||
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); }
|
||
catch { throw new Error("Нет связи с сервером. Проверьте подключение и обновите страницу."); }
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
if (response.status === 401 && path !== "/api/login") showLogin();
|
||
const error = new Error(data.error || `Ошибка сервера (${response.status})`); error.status = response.status; throw error;
|
||
}
|
||
return data;
|
||
}
|
||
function config(service) {
|
||
return Object.fromEntries(["id", "name", "listen", "upstream_port", "primary", "replicas", "enabled", "auto_switch", "replica_policy", "custom", "custom_ip"].map((key) => [key, service[key] ?? ({ custom: false, custom_ip: null }[key])]));
|
||
}
|
||
async function refresh() { render(await api("/api/snapshot")); watchUpdates(); }
|
||
async function mutate(path, method, body, errorId) {
|
||
if (state.busy || state.stale) return false;
|
||
state.busy = true; render(); if (errorId) $(errorId).textContent = "";
|
||
try {
|
||
await api(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||
await refresh(); alert(""); return true;
|
||
} catch (error) {
|
||
if (errorId) $(errorId).textContent = error.message; else alert(error.message);
|
||
try { if (state.csrf) await refresh(); } catch { /* Preserve the actual mutation error. */ }
|
||
return false;
|
||
} finally { state.busy = false; render(); }
|
||
}
|
||
function updateService(id, patch, errorId) {
|
||
const current = serviceById(id); if (!current) return Promise.resolve(false);
|
||
return mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", { ...config(current), ...patch }, errorId);
|
||
}
|
||
function startStream() {
|
||
closeStream(); const source = new EventSource("/api/events"); state.source = source;
|
||
source.addEventListener("snapshot", (event) => {
|
||
if (state.source !== source) return;
|
||
try { const wasStale = state.stale; render(JSON.parse(event.data)); watchUpdates(); if (wasStale) alert(""); }
|
||
catch { state.stale = true; render(); alert("Получены некорректные данные. Обновите страницу."); }
|
||
});
|
||
source.onerror = async () => {
|
||
if (state.source !== source) return;
|
||
state.stale = true; render(); alert("Нет обновлений от сервера. Переподключение…");
|
||
try { await api("/api/session"); } catch (error) { if (error.status !== 401) return; }
|
||
};
|
||
watchUpdates();
|
||
}
|
||
async function enter(session) {
|
||
state.csrf = session.csrf; $("password").value = ""; $("login-error").textContent = "";
|
||
await refresh(); $("login").hidden = true; $("header").hidden = false; $("dashboard").hidden = false;
|
||
document.title = "Proxy Manager"; startStream();
|
||
}
|
||
function openService(id) {
|
||
const service = serviceById(id); if (!service) return;
|
||
const hostname = window.location.hostname.replace(/^\[|\]$/g, "");
|
||
const host = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||
window.open(`http://${host}:${portOf(service)}`, "_blank", "noopener,noreferrer");
|
||
}
|
||
function selectPrimary(id, teamId) {
|
||
const service = serviceById(id); if (!service || service.primary === teamId) return;
|
||
const replicas = service.replicas.filter((value) => value !== teamId);
|
||
if (service.primary && !replicas.includes(service.primary)) replicas.push(service.primary);
|
||
updateService(id, { primary: teamId, replicas, custom_ip: null });
|
||
}
|
||
function toggleReplica(id, teamId) {
|
||
const service = serviceById(id); if (!service || service.primary === teamId) return;
|
||
const replicas = service.replicas.includes(teamId) ? service.replicas.filter((value) => value !== teamId) : [...service.replicas, teamId];
|
||
updateService(id, { replicas });
|
||
}
|
||
function openSettings(id) {
|
||
const service = serviceById(id); if (!service || state.busy || state.stale) return;
|
||
state.editId = id; $("settings-error").textContent = "";
|
||
$("service-name").value = service.name; $("service-listen").value = service.listen;
|
||
$("service-port").value = service.upstream_port; $("service-policy").value = service.replica_policy;
|
||
$("settingsModal").showModal();
|
||
}
|
||
async function removeCustom(id) {
|
||
const service = serviceById(id); if (!service?.custom || state.busy || state.stale) return;
|
||
if (!window.confirm(`Удалить прокси «${service.name}»?`)) return;
|
||
if (service.enabled && !await updateService(id, { enabled: false })) return;
|
||
await mutate(`/api/services/${encodeURIComponent(id)}`, "DELETE");
|
||
}
|
||
function createCard(service) {
|
||
const id = service.id; const root = el("div", "task-column"); root.dataset.serviceId = id;
|
||
const strip = el("div", "strip sb-off");
|
||
const header = el("div", "task-header"); header.addEventListener("click", () => openService(id));
|
||
const wrap = el("div", "task-title-wrap"); const titleRow = el("div", "task-title-row");
|
||
const title = button("task-title task-title-button", "Настройки сервиса", null, () => openSettings(id));
|
||
const port = el("div", "task-port"); titleRow.append(title, port);
|
||
const selected = el("div", "selected-team", "Команда не выбрана"); wrap.append(titleRow, selected);
|
||
const controls = el("div", "task-controls");
|
||
const auto = button("auto-switch-button", "Авто-переключение", "robot", () => { const s = serviceById(id); if (s) updateService(id, { auto_switch: !s.auto_switch }); });
|
||
const open = button("open-button", "Открыть сервис", "up-right-from-square", () => openService(id));
|
||
const toggle = button("toggle-button", "Запустить/Остановить", null, () => {
|
||
const s = serviceById(id); if (!s) return;
|
||
if (!s.enabled && !s.primary) { alert("Выберите команду или введите IP"); return; }
|
||
updateService(id, { enabled: !s.enabled });
|
||
});
|
||
controls.append(auto, open, toggle); header.append(wrap, controls);
|
||
const customRow = el("div", "custom-ip-row"); const customIp = el("input", "input-modern");
|
||
customIp.placeholder = "IP"; customIp.setAttribute("aria-label", `IP: ${service.name}`); customIp.autocomplete = "off";
|
||
customIp.addEventListener("change", () => updateService(id, { custom_ip: customIp.value.trim() || null, replicas: [] }));
|
||
const remove = button("btn-delete", "Удалить", "trash", () => removeCustom(id)); customRow.append(customIp, remove);
|
||
const stats = el("div", "task-stats"); const values = {};
|
||
for (const [key, label, cls] of [["accepted", "Всего:", ""], ["active", "Активные:", "stat-active"], ["completed", "Закрыто:", ""], ["errors", "Ошибки:", "stat-error"]]) {
|
||
const item = el("div", "stat-item"); const value = el("span", `stat-value ${cls}`, "0"); values[key] = value;
|
||
item.append(el("span", "stat-label", label), value); stats.append(item);
|
||
}
|
||
const error = el("p", "service-error"); error.setAttribute("role", "status");
|
||
const panel = el("div", "command-panel"); const list = el("div", "command-list"); panel.append(el("div", "panel-title", "Команды"), list);
|
||
root.append(strip, header, customRow, stats, error, panel); $("service-board").append(root);
|
||
return { root, strip, title, port, selected, auto, toggle, customRow, customIp, remove, stats, values, error, list, rows: new Map() };
|
||
}
|
||
function createRow(card, id, team) {
|
||
const root = el("div", "command-item"); root.dataset.teamId = team.id; root.dataset.value = team.address;
|
||
root.setAttribute("role", "button"); root.tabIndex = 0;
|
||
root.addEventListener("click", () => toggleReplica(id, team.id));
|
||
root.addEventListener("keydown", (event) => { if (event.target === root && ["Enter", " "].includes(event.key)) { event.preventDefault(); toggleReplica(id, team.id); } });
|
||
const left = el("div", "command-left");
|
||
const primary = button("primary-area", `Основной маршрут ${team.name}`, null, () => selectPrimary(id, team.id));
|
||
const circle = el("span", "primary-toggle"); circle.append(el("span", "primary-dot")); primary.append(circle); primary.setAttribute("aria-pressed", "false");
|
||
const health = el("span", "option-status unknown"); left.append(primary, health);
|
||
const meta = el("div", "command-meta"); const name = el("span", "command-name"); const address = el("span", "command-ip"); const flags = el("span", "flag-chip", "-"); meta.append(name, address, flags);
|
||
root.append(left, meta); card.list.append(root); return { root, primary, health, name, address, flags };
|
||
}
|
||
function render(snapshot) {
|
||
if (snapshot) {
|
||
if (!Array.isArray(snapshot.services) || !Array.isArray(snapshot.teams) || !Number.isFinite(snapshot.now_ms)) throw new Error("Некорректный снимок");
|
||
if (state.snapshot && snapshot.now_ms < state.snapshot.now_ms) return;
|
||
state.snapshot = snapshot; state.stale = false;
|
||
}
|
||
if (!state.snapshot) return;
|
||
const current = state.snapshot; const board = current.scoreboard || {}; const scoreFresh = !state.stale && board.connected && !board.stale;
|
||
const locked = state.busy || state.stale;
|
||
$("add-service").disabled = locked; $("logout").disabled = state.busy;
|
||
for (const form of [$("customProxyForm"), $("settings-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked;
|
||
for (const node of document.querySelectorAll("[data-close]")) node.disabled = state.busy;
|
||
const ids = new Set(current.services.map((service) => service.id));
|
||
for (const [id, card] of state.cards) if (!ids.has(id)) { card.root.remove(); state.cards.delete(id); }
|
||
for (const service of current.services) {
|
||
let card = state.cards.get(service.id);
|
||
if (!card) { card = createCard(service); state.cards.set(service.id, card); }
|
||
card.title.textContent = service.name; card.port.textContent = `:${portOf(service)}`;
|
||
card.title.setAttribute("aria-label", `Настройки сервиса ${service.name}`); card.title.disabled = locked;
|
||
card.auto.classList.toggle("active", service.auto_switch); card.auto.setAttribute("aria-pressed", String(service.auto_switch));
|
||
card.auto.title = service.auto_switch ? "Авто-переключение включено" : "Авто-переключение"; card.auto.disabled = locked;
|
||
card.toggle.classList.toggle("active", Boolean(service.engine?.running)); card.toggle.disabled = locked;
|
||
card.toggle.setAttribute("aria-label", `${service.enabled ? "Остановить" : "Запустить"} ${service.name}`); card.toggle.setAttribute("aria-pressed", String(Boolean(service.engine?.running)));
|
||
card.customRow.hidden = !service.custom; card.customIp.disabled = locked; card.remove.disabled = locked;
|
||
if (document.activeElement !== card.customIp) card.customIp.value = service.custom_ip || "";
|
||
const primary = teamById(service.primary);
|
||
card.selected.replaceChildren();
|
||
if (primary) {
|
||
card.selected.append(el("span", "selected-team-name", primary.name));
|
||
if (primary.name !== primary.address) card.selected.append(el("span", "selected-team-ip", primary.address));
|
||
if (service.replicas.length) card.selected.append(el("span", "selected-team-tail", ` · дубли: ${service.replicas.length}`));
|
||
} else card.selected.textContent = "Команда не выбрана";
|
||
const stats = service.engine?.stats || {};
|
||
card.stats.style.visibility = service.engine?.running || stats.active ? "visible" : "hidden";
|
||
for (const key of ["accepted", "active", "completed"]) card.values[key].textContent = stats[key] || 0;
|
||
const errors = (stats.connect_errors || 0) + (stats.io_errors || 0) + (stats.rejected || 0) + (stats.accept_errors || 0);
|
||
card.values.errors.textContent = errors; card.values.errors.classList.toggle("text-danger", errors > 0);
|
||
card.stats.title = `Байты: ↑ ${stats.bytes_up || 0}, ↓ ${stats.bytes_down || 0}; реплики: ${stats.replica_bytes || 0}; отключено реплик: ${stats.replica_disconnects || 0}; ошибок реплик: ${stats.replica_errors || 0}`;
|
||
card.error.textContent = service.engine?.last_error ? `Последняя ошибка: ${service.engine.last_error}` : "";
|
||
const localTeam = current.teams.find((team) => team.address === current.main_team_ip);
|
||
const boardTeam = board.teams?.find((team) => team.address === current.main_team_ip || (localTeam && team.name === localTeam.name));
|
||
const boardTask = board.tasks?.find((task) => task.name === service.name || task.name === service.id);
|
||
const entry = scoreFresh && current.main_team_ip ? board.entries?.[boardTask?.id]?.[boardTeam?.id] : null;
|
||
card.strip.className = `strip sb-${statusClass[entry?.status] || "off"}`;
|
||
card.strip.title = !board.enabled ? "Scoreboard выключен" : !current.main_team_ip ? "Для статуса своей команды задайте main_team_ip в конфигурации" : scoreFresh ? `Своя команда: ${statusName[entry?.status] || "нет данных"}` : "Данные scoreboard устарели или соединение отсутствует";
|
||
const teams = current.teams.filter((team) => !team.hidden || service.primary === team.id || service.replicas.includes(team.id));
|
||
const teamIds = new Set(teams.map((team) => team.id));
|
||
for (const [id, row] of card.rows) if (!teamIds.has(id)) { row.root.remove(); card.rows.delete(id); }
|
||
for (const team of teams) {
|
||
let row = card.rows.get(team.id); if (!row) { row = createRow(card, service.id, team); card.rows.set(team.id, row); }
|
||
row.name.textContent = team.name; row.address.textContent = team.address; row.address.hidden = team.name === team.address;
|
||
row.root.classList.toggle("primary-active", service.primary === team.id); row.root.classList.toggle("replica-active", service.replicas.includes(team.id));
|
||
row.root.setAttribute("aria-label", `Реплика ${team.name}: ${service.name}`); row.root.setAttribute("aria-disabled", String(locked || service.primary === team.id));
|
||
row.root.setAttribute("aria-pressed", String(service.replicas.includes(team.id))); row.primary.disabled = locked;
|
||
row.primary.setAttribute("aria-pressed", String(service.primary === team.id));
|
||
const h = service.health?.[team.id]; const recent = !state.stale && h?.checked_at_ms && current.now_ms - h.checked_at_ms <= (current.health_max_age_ms || 12000);
|
||
row.health.className = `option-status ${recent ? h.reachable ? "available" : "unavailable" : "unknown"}`;
|
||
row.health.title = recent ? `TCP: ${h.reachable ? "доступен" : "недоступен"}, ${h.latency_ms} мс` : "TCP: нет свежих данных";
|
||
const score = service.scoreboard?.[team.id]; const cls = scoreFresh ? statusClass[score?.status] || "off" : "off";
|
||
row.name.className = `command-name sb-${cls}`; row.flags.classList.toggle("stale-data", !scoreFresh);
|
||
row.flags.textContent = score ? `-${score.lost ?? 0}` : "-";
|
||
row.flags.title = score ? `${statusName[score.status] || score.status}; потеряно: ${score.lost || 0}; украдено: ${score.stolen || 0}${scoreFresh ? "" : "; устаревшие данные"}` : "Нет данных scoreboard";
|
||
}
|
||
if (scoreFresh && !card.list.contains(document.activeElement)) {
|
||
const ordered = [...teams].sort((a, b) => {
|
||
const sa = service.scoreboard?.[a.id]; const sb = service.scoreboard?.[b.id];
|
||
return (statusOrder[sa?.status] ?? 5) - (statusOrder[sb?.status] ?? 5) || (sa?.lost ?? Number.MAX_SAFE_INTEGER) - (sb?.lost ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name);
|
||
});
|
||
ordered.forEach((team, index) => { const node = card.rows.get(team.id).root; if (card.list.children[index] !== node) card.list.insertBefore(node, card.list.children[index] || null); });
|
||
}
|
||
}
|
||
}
|
||
$("dismiss-alert").addEventListener("click", () => alert(""));
|
||
$("login-form").addEventListener("submit", async (event) => {
|
||
event.preventDefault(); $("login-button").disabled = true; $("login-error").textContent = "";
|
||
try { await enter(await api("/api/login", { method: "POST", body: JSON.stringify({ password: $("password").value }) })); }
|
||
catch (error) { $("login-error").textContent = error.message; }
|
||
finally { $("login-button").disabled = false; }
|
||
});
|
||
$("logout").addEventListener("click", async () => { if (state.busy) return; try { await api("/api/logout", { method: "POST" }); showLogin(); alert(""); } catch (error) { alert(error.message); } });
|
||
$("refresh").addEventListener("click", async () => { try { await refresh(); alert(""); if (!state.source) startStream(); } catch (error) { alert(error.message); } });
|
||
$("add-service").addEventListener("click", () => { $("customProxyForm").reset(); $("custom-error").textContent = ""; $("addCustomModal").showModal(); });
|
||
for (const node of document.querySelectorAll("[data-close]")) node.addEventListener("click", () => { if (!state.busy) $(node.dataset.close).close(); });
|
||
for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("cancel", (event) => { if (state.busy) event.preventDefault(); });
|
||
$("customProxyForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault(); const port = Number($("customPort").value);
|
||
const id = `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||
const body = { id, name: $("customName").value.trim(), listen: `0.0.0.0:${port}`, upstream_port: port, primary: null, replicas: [], enabled: false, auto_switch: false, replica_policy: "isolate", custom: true, custom_ip: $("customIp").value.trim() || null };
|
||
if (await mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", body, "custom-error")) $("addCustomModal").close();
|
||
});
|
||
$("settings-form").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (await updateService(state.editId, { name: $("service-name").value.trim(), listen: $("service-listen").value.trim(), upstream_port: Number($("service-port").value), replica_policy: $("service-policy").value }, "settings-error")) $("settingsModal").close();
|
||
});
|
||
window.addEventListener("pagehide", closeStream);
|
||
window.addEventListener("pageshow", (event) => { if (event.persisted && state.csrf) startStream(); });
|
||
(async () => { try { await enter(await api("/api/session")); } catch (error) { showLogin(); if (error.status !== 401) $("login-error").textContent = error.message; } })();
|
||
})();
|