"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 isIp(value) { if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return value.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part); if (!value.includes(":") || !/^[\da-f:.]+$/i.test(value)) return false; try { return new URL(`http://[${value}]/`).hostname.startsWith("["); } catch { return false; } } 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 raw = await response.text(); let data; try { data = JSON.parse(raw); } catch { data = { error: response.headers.get("content-type")?.startsWith("text/plain") ? raw.slice(0, 400) : null }; } 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", "flag_protection", "custom", "custom_ip"].map((key) => [key, service[key] ?? ({ custom: false, custom_ip: null, flag_protection: false }[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-name").setCustomValidity(""); $("service-listen").value = service.listen; $("service-flags").checked = Boolean(service.flag_protection); $("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.title = "IPv4 / IPv6 · Enter — сохранить, Escape — отменить ввод"; const ipError = el("p", "service-error"); ipError.id = `ip-error-${id}`; ipError.setAttribute("role", "alert"); customIp.setAttribute("aria-describedby", ipError.id); const draft = { dirty: false }; customIp.addEventListener("input", () => { draft.dirty = true; ipError.textContent = ""; customIp.removeAttribute("aria-invalid"); }); async function saveIp() { if (!draft.dirty || state.busy || state.stale) return; const value = customIp.value.trim(); if (value && !isIp(value)) { ipError.textContent = "Введите IPv4 или IPv6 без порта. Escape — отменить ввод."; customIp.setAttribute("aria-invalid", "true"); return; } if (await updateService(id, { custom_ip: value || null }, ipError.id)) { draft.dirty = false; customIp.removeAttribute("aria-invalid"); customIp.value = serviceById(id)?.custom_ip || ""; render(); } else if (ipError.textContent) customIp.setAttribute("aria-invalid", "true"); } customIp.addEventListener("change", saveIp); customIp.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); saveIp(); } if (event.key === "Escape") { draft.dirty = false; ipError.textContent = ""; customIp.removeAttribute("aria-invalid"); customIp.value = serviceById(id)?.custom_ip || ""; customIp.blur(); } }); 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, ipError, stats, error, panel); $("service-board").append(root); return { root, strip, title, port, selected, auto, toggle, customRow, customIp, draft, 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; $("board-status").textContent = !board.enabled ? "Scoreboard: выключен" : `Scoreboard (${board.kind}): ${scoreFresh ? "подключён" : "нет свежих данных"}${board.round == null ? "" : ` · раунд ${board.round}`}`; $("board-error").textContent = board.last_error || ""; $("add-service").disabled = locked; $("manage-teams").disabled = locked; $("logout").disabled = state.busy; for (const form of [$("customProxyForm"), $("settings-form"), $("team-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked; $("service-flags").disabled = locked || !current.flag_protection?.ready; const vault = current.flag_protection; $("flags-status").textContent = vault ? `Кеш подмены флагов: ${vault.ready ? "готов" : "недоступен"}; записей ${vault.entries}/${vault.capacity}; подменено ${vault.encoded}, восстановлено ${vault.decoded}, попаданий ${vault.cache_hits}, ошибок ${vault.errors}.` : "Подмена флагов не настроена. Для включения задайте [flags] в TOML и ключ в окружении, затем перезапустите процесс."; $("flags-hint").textContent = vault?.ready ? "По умолчанию выключено. Применяется к новым соединениям. Формат и срок кеша задаются в TOML. Только открытый несжатый трафик; это не проверка прав клиента." : "Сначала настройте [flags] и ключ в окружении (см. справку ⓘ)."; 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.root.classList.toggle("flags-enabled", Boolean(service.flag_protection)); card.title.textContent = service.name; card.title.title = `Настройки: ${service.name}${service.flag_protection ? " · подмена флагов включена" : ""}`; 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 (!card.draft.dirty && 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 = "Команда не выбрана"; card.selected.title = primary ? `${primary.name} · ${primary.address}${service.replicas.length ? ` · дубли: ${service.replicas.length}` : ""}` : "Команда не выбрана"; 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)); row.primary.setAttribute("aria-label", `Основной маршрут ${team.name}`); row.primary.title = `Основной маршрут ${team.name}`; 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", () => { $("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); if (!$("customName").value.trim()) { $("custom-error").textContent = "Введите название"; return; } if ($("customIp").value.trim() && !isIp($("customIp").value.trim())) { $("custom-error").textContent = "Введите IPv4 или IPv6 без порта"; return; } 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(); $("customProxyForm").reset(); } }); $("settings-form").addEventListener("submit", async (event) => { event.preventDefault(); if (!$("service-name").value.trim()) { $("settings-error").textContent = "Введите название"; return; } 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, flag_protection: $("service-flags").checked }, "settings-error")) $("settingsModal").close(); }); // Whitespace-only names pass HTML required validation; reject before submission. for (const id of ["customName", "service-name", "team-name"]) { const input = $(id); input.addEventListener("input", () => input.setCustomValidity(input.value.trim() ? "" : "Введите название")); } function teamChoices(selected = "") { const select = $("team-choice"); select.replaceChildren(new Option("Новая команда", "")); for (const team of state.snapshot.teams) select.add(new Option(`${team.name} · ${team.address}`, team.id)); select.value = selected; } function editTeam() { const team = teamById($("team-choice").value); $("team-name").value = team?.name || ""; $("team-name").setCustomValidity(""); $("team-address").value = team?.address || ""; $("team-address").readOnly = Boolean(team); $("team-error").textContent = ""; $("team-result").textContent = ""; } $("help").addEventListener("click", () => $("helpModal").showModal()); $("manage-teams").addEventListener("click", () => { teamChoices($("team-choice").value); $("teamsModal").showModal(); }); $("team-choice").addEventListener("change", editTeam); $("team-form").addEventListener("submit", async (event) => { event.preventDefault(); $("team-result").textContent = ""; if (!$("team-name").value.trim()) { $("team-error").textContent = "Введите название команды"; return; } if (!isIp($("team-address").value.trim())) { $("team-error").textContent = "Введите IPv4 или IPv6 без порта"; return; } const old = teamById($("team-choice").value); const id = old?.id || `team-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const team = { id, name: $("team-name").value.trim(), address: $("team-address").value.trim(), hidden: old?.hidden || false }; if (await mutate(`/api/teams/${encodeURIComponent(id)}`, "PUT", team, "team-error")) { teamChoices(id); editTeam(); $("team-result").textContent = "Команда сохранена"; } }); 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; } })(); })();