"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); } })(); })();