ctf-ad-mirror-tool-rust/web/app.js

340 lines
27 KiB
JavaScript
Raw 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.

"use strict";
(() => {
const $ = (id) => document.getElementById(id);
const state = { csrf: "", snapshot: null, stream: null, busy: false, cards: new Map(), baselines: new Map(), rates: new Map(), stale: true, sessionTimer: null, staleTimer: null, sessionCheck: false, retryDelay: 2000, editId: null, deleteId: null, generation: 0 };
const configKeys = ["id", "name", "listen", "upstream_port", "primary", "replicas", "enabled", "auto_switch", "replica_policy"];
const statusNames = { 101: "UP", 102: "CORRUPT", 103: "MUMBLE", 104: "DOWN", 110: "CHECK FAIL" };
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function message(id, text) { $(id).textContent = text || ""; $(id).hidden = !text; }
function config(service) { return Object.fromEntries(configKeys.map((key) => [key, key === "replicas" ? [...service[key]] : service[key]])); }
function serviceById(id) { return state.snapshot?.services.find((service) => service.id === id); }
function field(form, name) { return form.elements.namedItem(name); }
function time(ms) { return ms ? new Date(ms).toLocaleTimeString("ru-RU") : "—"; }
function bytes(value) {
const count = Math.max(0, Number(value) || 0);
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
const unit = count ? Math.min(Math.floor(Math.log(count) / Math.log(1024)), units.length - 1) : 0;
return `${(count / 1024 ** unit).toLocaleString("ru-RU", { maximumFractionDigits: unit ? 1 : 0 })} ${units[unit]}`;
}
function setBusy(busy) {
state.busy = busy;
document.querySelectorAll("[data-write]").forEach((node) => { node.disabled = busy; });
document.querySelectorAll("dialog form input, dialog form select, dialog [data-close]").forEach((node) => { node.disabled = busy || (node.name === "id" && Boolean(state.editId) && node.closest("#service-form") !== null); });
if (state.snapshot) render(state.snapshot, false, !busy);
}
async function api(path, options = {}) {
const headers = { ...options.headers };
if (options.body !== undefined) headers["Content-Type"] = "application/json";
if (options.method && options.method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
let response;
try { response = await fetch(path, { ...options, headers, credentials: "same-origin", cache: "no-store" }); }
catch (error) { throw new Error(`Ошибка сети: ${error.message}`); }
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(body.error || `Ошибка HTTP ${response.status}`);
error.status = response.status;
if (response.status === 401 && path !== "/api/login" && path !== "/api/session") showLogin("Сессия завершена. Войдите снова.");
throw error;
}
return body;
}
function closeStream() {
if (state.stream) state.stream.close();
state.stream = null;
clearTimeout(state.sessionTimer); clearTimeout(state.staleTimer);
state.sessionTimer = null;
}
function showLogin(reason = "Введите пароль для входа.") {
state.generation += 1;
closeStream(); state.csrf = ""; state.stale = true;
document.querySelectorAll("dialog[open]").forEach((dialog) => dialog.close());
$("dashboard").hidden = true; $("login-view").hidden = false;
$("login-status").textContent = reason;
$("password").value = "";
}
function markStale(detail) {
state.stale = true;
$("stream-state").textContent = detail || "Нет свежих данных";
$("stream-state").className = "warn";
if (state.snapshot) render(state.snapshot, false);
}
function scheduleSessionCheck() {
if (state.sessionTimer || state.sessionCheck || !state.csrf) return;
state.sessionTimer = setTimeout(async () => {
state.sessionTimer = null; state.sessionCheck = true;
const generation = state.generation;
try {
const session = await api("/api/session");
if (generation !== state.generation) return;
state.csrf = session.csrf;
state.retryDelay = Math.min(state.retryDelay * 2, 30000);
} catch (error) {
if (generation !== state.generation) return;
if (error.status === 401) showLogin("Сессия завершена. Войдите снова.");
else { markStale("Нет связи с сервером"); message("global-error", error.message); state.retryDelay = Math.min(state.retryDelay * 2, 30000); }
} finally {
state.sessionCheck = false;
if (state.stale && state.csrf) scheduleSessionCheck();
}
}, state.retryDelay);
}
function watchFreshness() {
clearTimeout(state.staleTimer);
state.staleTimer = setTimeout(() => { markStale("Снимок устарел · > 8 с"); scheduleSessionCheck(); }, 8000);
}
function startStream() {
closeStream();
const stream = new EventSource("/api/events");
state.stream = stream;
stream.addEventListener("snapshot", (event) => {
if (stream !== state.stream) return;
try {
const snapshot = JSON.parse(event.data);
state.stale = false; state.retryDelay = 2000;
clearTimeout(state.sessionTimer); state.sessionTimer = null;
$("stream-state").textContent = "В реальном времени"; $("stream-state").className = "good";
render(snapshot); watchFreshness();
} catch { markStale("Некорректный снимок"); scheduleSessionCheck(); }
});
stream.onerror = () => { if (stream === state.stream) { markStale("Связь прервана · переподключение"); scheduleSessionCheck(); } };
watchFreshness();
}
async function refresh() {
const generation = state.generation;
const snapshot = await api("/api/snapshot");
if (generation === state.generation && state.csrf) render(snapshot);
}
async function enter(session) {
state.generation += 1; state.csrf = session.csrf; state.stale = true;
state.snapshot = null; state.baselines.clear(); state.rates.clear();
state.cards.forEach((card) => card.root.remove()); state.cards.clear();
message("login-error", ""); message("global-error", ""); $("password").value = "";
$("login-view").hidden = true; $("dashboard").hidden = false;
markStale("Подключение…");
try { await refresh(); if (state.csrf) startStream(); }
catch (error) { message("global-error", error.message); if (state.csrf) startStream(); }
}
async function mutate(path, method, body, errorId = "global-error") {
if (state.busy) return false;
setBusy(true); message(errorId, ""); $("action-status").textContent = "Сохраняем…";
try {
await api(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
await refresh();
$("action-status").textContent = "Сохранено";
return true;
} catch (error) { message(errorId, error.message); $("action-status").textContent = "Изменение не подтверждено. Проверьте состояние и повторите."; return false; }
finally { setBusy(false); }
}
async function updateService(id, change) {
const service = serviceById(id);
if (!service || state.busy) return;
const next = { ...config(service), ...change };
await mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", next);
}
function control(type, label, onChange) {
const input = el("input"); input.type = type; input.setAttribute("aria-label", label); input.dataset.write = "";
input.addEventListener("change", onChange); return input;
}
function button(text, className, onClick) {
const node = el("button", className, text); node.type = "button"; node.dataset.write = "";
node.addEventListener("click", onClick); return node;
}
function createCard(id) {
const root = el("article", "service-card");
const header = el("header", "service-header"); const heading = el("div", "service-heading");
const title = el("h2", "service-name"); const running = el("span", "service-state"); heading.append(title, running);
const endpoint = el("p", "endpoint"); const route = el("p", "route-summary");
const actions = el("div", "card-actions");
const toggle = button("Запустить", "small accent", () => { const service = serviceById(id); if (service) updateService(id, { enabled: !service.enabled }); });
const autoLabel = el("label", "auto-label");
const auto = control("checkbox", "Автопереключение", () => updateService(id, { auto_switch: auto.checked })); autoLabel.append(auto, "Авто");
const policyLabel = el("label", "policy-label"); const policy = el("select"); policy.dataset.write = ""; policy.setAttribute("aria-label", "Политика реплик");
for (const [value, text] of [["isolate", "Isolate · отключать"], ["strict", "Strict · ждать"]]) { const option = el("option", "", text); option.value = value; policy.append(option); }
policy.addEventListener("change", () => updateService(id, { replica_policy: policy.value })); policyLabel.append(policy);
const edit = button("Настроить", "small", () => openService(id));
actions.append(toggle, autoLabel, policyLabel, edit); header.append(heading, endpoint, route, actions);
const stats = el("dl", "stats-grid"); const values = {};
for (const [key, text] of [["active", "Активные"], ["accepted", "Принято"], ["completed", "Завершено"], ["errors", "Ошибки"], ["up", "↑ Основной / с"], ["down", "↓ Ответ / с"], ["replica_disconnects", "Откл. реплик"], ["replica_errors", "Ошибки реплик"]]) {
const item = el("div", "stat"); values[key] = el("dd", "", "—"); item.append(el("dt", "", text), values[key]); stats.append(item);
}
const totals = el("div", "bytes-line"); const table = el("table", "team-table");
const caption = el("caption", "sr-only", "Маршруты команд");
const head = el("thead"); const headRow = el("tr");
for (const [text, className] of [["Осн.", "control-col"], ["Реп.", "control-col"], ["Команда", ""], ["TCP", "health-col"], ["ForcAD", "score-col"]]) { const th = el("th", className, text); th.scope = "col"; headRow.append(th); }
head.append(headRow); const tbody = el("tbody"); table.append(caption, head, tbody);
const error = el("p", "service-error error-text"); error.hidden = true;
const footer = el("footer", "card-footer"); const policyNote = el("span");
const remove = button("Удалить", "text-button", () => openDelete(id)); footer.append(policyNote, remove);
root.append(header, stats, totals, table, error, footer); $("service-board").append(root);
return { root, title, running, endpoint, route, toggle, auto, policy, edit, values, totals, tbody, error, remove, policyNote, rows: new Map() };
}
function createRow(card, serviceId, team) {
const root = el("tr"); const mainCell = el("td", "control-cell"); const replicaCell = el("td", "control-cell");
const primary = control("radio", "", () => { const service = serviceById(serviceId); if (service) updateService(serviceId, { primary: team.id, replicas: service.replicas.filter((id) => id !== team.id) }); });
primary.name = `primary-${serviceId}`;
const replica = control("checkbox", "", () => { const service = serviceById(serviceId); if (service) updateService(serviceId, { replicas: replica.checked ? [...new Set([...service.replicas, team.id])] : service.replicas.filter((id) => id !== team.id) }); });
mainCell.append(primary); replicaCell.append(replica);
const meta = el("td"); const name = el("span", "team-name"); const address = el("span", "team-address"); meta.append(name, address);
const healthCell = el("td"); const health = el("span", "health-badge"); healthCell.append(health);
const scoreCell = el("td"); const score = el("span", "score-status"); const flags = el("span", "score-flags"); scoreCell.append(score, flags);
root.append(mainCell, replicaCell, meta, healthCell, scoreCell); card.tbody.append(root);
return { root, primary, replica, name, address, health, score, flags };
}
function trackRates(snapshot) {
for (const service of snapshot.services) {
const stats = service.engine?.stats || {}; const previous = state.baselines.get(service.id);
const dt = previous ? snapshot.now_ms - previous.at : 0;
if (previous && dt > 0) {
const reset = stats.bytes_up < previous.up || stats.bytes_down < previous.down;
state.rates.set(service.id, reset ? null : { up: (stats.bytes_up - previous.up) * 1000 / dt, down: (stats.bytes_down - previous.down) * 1000 / dt });
} else if (!previous || dt < 0) state.rates.set(service.id, null);
if (!previous || dt !== 0) state.baselines.set(service.id, { at: snapshot.now_ms, up: stats.bytes_up || 0, down: stats.bytes_down || 0 });
}
}
function render(snapshot, fresh = true, reconcile = false) {
if (fresh) {
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;
trackRates(snapshot); state.snapshot = snapshot;
}
const scoreboard = snapshot.scoreboard || {};
const scoreStale = state.stale || scoreboard.stale || !scoreboard.connected;
$("snapshot-time").textContent = `${time(snapshot.now_ms)}${state.stale ? " · устарел" : ""}`;
$("service-count").textContent = `${snapshot.services.filter((service) => service.engine?.running).length} / ${snapshot.services.length} работают`;
$("scoreboard-state").textContent = !scoreboard.enabled ? "Выключен" : scoreStale ? "Устаревшие данные" : `Раунд ${scoreboard.round ?? "—"}`;
$("scoreboard-state").className = !scoreboard.enabled ? "muted" : scoreStale ? "warn" : "good";
$("scoreboard-detail").textContent = scoreboard.enabled ? `${scoreboard.connected ? "Подключён" : "Нет подключения"} · обновлён ${time(scoreboard.updated_at_ms)}${scoreboard.last_error ? ` · ${scoreboard.last_error}` : ""}` : "ForcAD выключен. TCP-доступность проверяется отдельно от состояния чекера.";
$("empty-state").hidden = snapshot.services.length > 0;
const ids = new Set(snapshot.services.map((service) => service.id));
for (const [id, card] of state.cards) if (!ids.has(id)) { card.root.remove(); state.cards.delete(id); state.baselines.delete(id); state.rates.delete(id); }
for (const service of snapshot.services) {
let card = state.cards.get(service.id);
if (!card) { card = createCard(service.id); state.cards.set(service.id, card); }
const engine = service.engine || {}; const stats = engine.stats || {}; const rate = state.rates.get(service.id);
card.root.classList.toggle("running", Boolean(engine.running)); card.title.textContent = service.name;
card.running.textContent = engine.running ? "● РАБОТАЕТ" : service.enabled ? "● ОШИБКА" : "○ ОСТАНОВЛЕН";
card.running.className = `service-state ${engine.running ? "good" : service.enabled ? "bad" : "muted"}`;
card.endpoint.textContent = `${service.listen} → :${service.upstream_port}`;
const primary = snapshot.teams.find((team) => team.id === service.primary);
card.route.textContent = `${primary ? primary.name : "Основной маршрут не выбран"} · реплик: ${service.replicas.length}`;
card.toggle.textContent = service.enabled ? "Остановить" : "Запустить";
card.toggle.disabled = state.busy || (!service.enabled && !service.primary);
card.auto.disabled = state.busy; card.policy.disabled = state.busy; card.edit.disabled = state.busy;
card.auto.setAttribute("aria-label", `Автопереключение: ${service.name}`); card.policy.setAttribute("aria-label", `Политика реплик: ${service.name}`);
if (reconcile || document.activeElement !== card.auto) card.auto.checked = service.auto_switch;
if (reconcile || document.activeElement !== card.policy) card.policy.value = service.replica_policy;
card.remove.disabled = state.busy || service.enabled || engine.running;
card.remove.title = service.enabled || engine.running ? "Сначала остановите сервис" : "Удалить настройки сервиса";
card.policyNote.textContent = service.replica_policy === "strict" ? "Strict: общий поток ждёт реплики" : "Isolate: отстающая реплика отключается";
for (const key of ["active", "accepted", "completed", "replica_disconnects", "replica_errors"]) card.values[key].textContent = (stats[key] || 0).toLocaleString("ru-RU");
card.values.errors.textContent = ((stats.connect_errors || 0) + (stats.io_errors || 0) + (stats.rejected || 0)).toLocaleString("ru-RU");
card.values.up.textContent = !state.stale && rate ? `${bytes(rate.up)}/с` : "—";
card.values.down.textContent = !state.stale && rate ? `${bytes(rate.down)}/с` : "—";
card.totals.textContent = `Всего: ↑ ${bytes(stats.bytes_up)} · ↓ ${bytes(stats.bytes_down)} · реплики ${bytes(stats.replica_bytes)}`;
card.error.textContent = engine.last_error || ""; card.error.hidden = !engine.last_error;
const teamIds = new Set(snapshot.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 snapshot.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.primary.setAttribute("aria-label", `Основной маршрут ${team.name}: ${service.name}`);
row.replica.setAttribute("aria-label", `Реплика ${team.name}: ${service.name}`);
if (reconcile || document.activeElement !== row.primary) row.primary.checked = service.primary === team.id;
if (reconcile || document.activeElement !== row.replica) row.replica.checked = service.replicas.includes(team.id);
row.primary.disabled = state.busy; row.replica.disabled = state.busy || service.primary === team.id;
row.root.classList.toggle("is-primary", service.primary === team.id); row.root.classList.toggle("is-replica", service.replicas.includes(team.id));
const health = service.health?.[team.id];
const unknown = !health?.checked_at_ms;
const healthStale = state.stale || (!unknown && snapshot.now_ms - health.checked_at_ms > 15000);
row.health.textContent = unknown ? "?" : healthStale ? "Устар." : health.reachable ? "UP" : "DOWN";
row.health.className = `health-badge ${unknown ? "muted" : healthStale ? "warn" : health.reachable ? "good" : "bad"}`;
row.health.title = unknown ? "Ещё не проверено" : `TCP: ${health.reachable ? "доступен" : "недоступен"}; стабильно здоров: ${health.healthy ? "да" : "нет"}; ${health.latency_ms ?? "—"} мс; ${time(health.checked_at_ms)}`;
const entry = service.scoreboard?.[team.id];
row.score.textContent = entry ? `${statusNames[entry.status] || entry.status}${scoreStale ? " *" : ""}` : "—";
row.score.className = `score-status ${scoreStale ? "stale-data" : entry?.status === 101 ? "good" : entry ? "bad" : "muted"}`;
row.score.title = scoreStale ? "Состояние чекера устарело" : "Состояние чекера ForcAD";
row.flags.textContent = entry ? `↑${entry.stolen ?? 0} ↓${entry.lost ?? 0}` : "↑— ↓—";
row.flags.title = "Украдено / потеряно";
}
}
if (fresh) {
const events = [...(snapshot.events || [])].slice(-30).reverse();
$("event-log").replaceChildren(...events.map((event) => { const li = el("li"); const stamp = el("time", "", time(event.at_ms)); li.append(stamp, document.createTextNode(`${event.service ? `${event.service}: ` : ""}${event.message}`)); return li; }));
}
}
function openService(id = null) {
const service = id ? serviceById(id) : null;
if (state.busy || (id && !service)) return;
state.editId = id;
const form = $("service-form"); form.reset(); message("service-form-error", "");
$("service-dialog-title").textContent = service ? "Настроить сервис" : "Добавить сервис";
for (const key of ["id", "name", "listen", "upstream_port", "replica_policy"]) field(form, key).value = service?.[key] ?? ({ listen: "0.0.0.0:18080", upstream_port: 8080, replica_policy: "isolate" }[key] || "");
field(form, "id").disabled = Boolean(id);
field(form, "enabled").checked = service?.enabled || false; field(form, "auto_switch").checked = service?.auto_switch || false;
const primary = field(form, "primary"); primary.replaceChildren();
const none = el("option", "", "Не выбран (сервис остановлен)"); none.value = ""; primary.append(none);
$("dialog-replicas").replaceChildren();
for (const team of state.snapshot.teams) {
const option = el("option", "", `${team.name} · ${team.address}`); option.value = team.id; primary.append(option);
const label = el("label"); const input = el("input"); input.type = "checkbox"; input.name = "replicas"; input.value = team.id; input.checked = service?.replicas.includes(team.id) || false;
label.append(input, document.createTextNode(team.name)); $("dialog-replicas").append(label);
}
primary.value = service?.primary || "";
syncDialogReplicas(); $("service-dialog").showModal();
}
function syncDialogReplicas() {
const primary = field($("service-form"), "primary").value;
$("dialog-replicas").querySelectorAll("input").forEach((input) => { input.disabled = input.value === primary; if (input.disabled) input.checked = false; });
}
function openDelete(id) {
const service = serviceById(id); if (!service || service.enabled || state.busy) return;
state.deleteId = id; message("delete-error", "");
$("delete-detail").textContent = `«${service.name}» (${service.listen}), основной маршрут ${service.primary || "не выбран"}, реплик: ${service.replicas.length}.`;
$("delete-dialog").showModal();
}
$("login-form").addEventListener("submit", async (event) => {
event.preventDefault(); $("login-button").disabled = true; message("login-error", "");
try { await enter(await api("/api/login", { method: "POST", body: JSON.stringify({ password: $("password").value }) })); }
catch (error) { message("login-error", error.message); }
finally { $("login-button").disabled = false; }
});
$("logout").addEventListener("click", async () => { if (state.busy) return; setBusy(true); try { await api("/api/logout", { method: "POST" }); showLogin(); } catch (error) { message("global-error", error.message); } finally { setBusy(false); } });
$("refresh").addEventListener("click", async () => { $("refresh").disabled = true; try { await refresh(); message("global-error", ""); } catch (error) { message("global-error", error.message); } finally { $("refresh").disabled = false; } });
$("add-service").addEventListener("click", () => openService());
$("add-team").addEventListener("click", () => { $("team-form").reset(); message("team-form-error", ""); $("team-dialog").showModal(); });
document.querySelectorAll("[data-close]").forEach((node) => node.addEventListener("click", () => { if (!state.busy) $(node.dataset.close).close(); }));
document.querySelectorAll("dialog").forEach((dialog) => dialog.addEventListener("cancel", (event) => { if (state.busy) event.preventDefault(); }));
field($("service-form"), "primary").addEventListener("change", syncDialogReplicas);
$("service-form").addEventListener("submit", async (event) => {
event.preventDefault(); const form = event.currentTarget;
const id = field(form, "id").value.trim();
if (!state.editId && serviceById(id)) { message("service-form-error", "Сервис с таким ID уже существует. Откройте его настройки."); return; }
const body = { id, name: field(form, "name").value.trim(), listen: field(form, "listen").value.trim(), upstream_port: Number(field(form, "upstream_port").value), primary: field(form, "primary").value || null, replicas: [...form.querySelectorAll("input[name=replicas]:checked")].map((input) => input.value), enabled: field(form, "enabled").checked, auto_switch: field(form, "auto_switch").checked, replica_policy: field(form, "replica_policy").value };
if (body.enabled && !body.primary) { message("service-form-error", "Выберите основной маршрут перед запуском."); return; }
if (await mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", body, "service-form-error")) $("service-dialog").close();
syncDialogReplicas();
});
$("team-form").addEventListener("submit", async (event) => {
event.preventDefault(); const form = event.currentTarget;
const body = Object.fromEntries(["id", "name", "address"].map((key) => [key, field(form, key).value.trim()]));
const existing = state.snapshot.teams.find((team) => team.id === body.id);
if (existing && existing.address !== body.address) { message("team-form-error", "У этого ID уже другой адрес. Выберите новый ID."); return; }
if (await mutate(`/api/teams/${encodeURIComponent(body.id)}`, "PUT", body, "team-form-error")) $("team-dialog").close();
});
$("delete-form").addEventListener("submit", async (event) => {
event.preventDefault(); const service = serviceById(state.deleteId);
if (!service || service.enabled) { message("delete-error", "Сервис отсутствует или уже запущен. Закройте окно и проверьте состояние."); return; }
if (await mutate(`/api/services/${encodeURIComponent(state.deleteId)}`, "DELETE", undefined, "delete-error")) $("delete-dialog").close();
});
window.addEventListener("pagehide", closeStream);
window.addEventListener("pageshow", (event) => { if (event.persisted && state.csrf) { markStale("Переподключение…"); startStream(); } });
(async () => { try { await enter(await api("/api/session")); } catch (error) { showLogin(error.status === 401 ? "Введите пароль для входа." : "Не удалось проверить сессию. Можно повторить вход."); if (error.status !== 401) message("login-error", error.message); } })();
})();