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