mirror of
https://github.com/mrvasil/ctf-ad-mirror-tool-rust.git
synced 2026-10-23 14:31:02 +00:00
340 lines
27 KiB
JavaScript
340 lines
27 KiB
JavaScript
"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); } })();
|
||
})();
|