diff --git a/admin-web/server.py b/admin-web/server.py index 4891b40..3c93879 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -1235,25 +1235,31 @@ def routed_behind_443() -> list[dict[str, Any]]: return routes -def port_443_status() -> dict[str, Any]: - listeners, errors = collect_port_listeners(443) +def port_status(port: int | None = None) -> dict[str, Any]: + public_port = int(port or read_telemt_port() or 443) + listeners, errors = collect_port_listeners(public_port) shared = load_shared443_config() - if shared.get("enabled"): + if public_port == 443 and shared.get("enabled"): for item in listeners: if item.get("role") == "site" and "nginx" in str(item.get("process", "")).lower(): item["role"] = "edge" item["details"] = "nginx stream ssl_preread" return { "checked_at": int(time.time()), + "public_port": public_port, "configured_port": read_telemt_port(), "listeners": listeners, - "routes": routed_behind_443(), + "routes": routed_behind_443() if public_port == 443 else [], "shared_443": shared, "ok": not errors, "error": "; ".join(errors[:2]), } +def port_443_status() -> dict[str, Any]: + return port_status(443) + + def wait_tcp_port(port: int, timeout: int = 90) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -1856,6 +1862,7 @@ def overview_payload() -> dict[str, Any]: "site_status": site_status(config), "users_count": len(users), "services": services, + "port_map": port_status(int(config.get("port") or read_telemt_port() or 443)), "port_443": port_443_status(), "stats_current": current, "stats_history": history, diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 02ec80b..65d4794 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -111,6 +111,7 @@ const i18n = { routingPort: "Public port", routingMaskHost: "Mask site", routingSave: "Save routing", + routingSaveShort: "Save", routingSaved: "Routing saved", routingOk: "port free", routingBusy: "port busy", @@ -205,8 +206,8 @@ const i18n = { keyEnabled: "Key enabled", keyDisabled: "Key disabled", ipLimitSaved: "IP limit saved", - visualTitle: "Port 443 map", - visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.", + visualTitle: "Port {port} map", + visualText: "Shows the selected public listener. Port 443 may be occupied by Xray / 3x-ui; busy ports are blocked on save.", port443Checked: "checked", port443NoListeners: "No 443 listeners found", port443Listeners: "listeners", @@ -214,8 +215,8 @@ const i18n = { port443Error: "Port check failed", port443Public: "public", port443Configured: "telemt: {port}", - port443PublicSection: "Public 443", - port443BehindSection: "Behind 443", + port443PublicSection: "Public port {port}", + port443BehindSection: "Behind port {port}", port443NoRoutes: "No routed services detected", port443Via: "via {value}", roleMtproxy: "MTProxy", @@ -365,6 +366,7 @@ const i18n = { routingPort: "Публичный порт", routingMaskHost: "Сайт маскировки", routingSave: "Сохранить маршрут", + routingSaveShort: "Сохранить", routingSaved: "Маршрутизация сохранена", routingOk: "порт свободен", routingBusy: "порт занят", @@ -459,8 +461,8 @@ const i18n = { keyEnabled: "Ключ включён", keyDisabled: "Ключ отключён", ipLimitSaved: "Лимит IP сохранён", - visualTitle: "Карта порта 443", - visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.", + visualTitle: "Карта порта {port}", + visualText: "Показывает выбранный публичный порт. 443 может быть занят Xray / 3x-ui; занятые порты блокируются при сохранении.", port443Checked: "проверено", port443NoListeners: "Слушателей 443 не найдено", port443Listeners: "слушателей", @@ -468,8 +470,8 @@ const i18n = { port443Error: "Проверка порта не удалась", port443Public: "публичный", port443Configured: "telemt: {port}", - port443PublicSection: "Публичный 443", - port443BehindSection: "За портом 443", + port443PublicSection: "Публичный порт {port}", + port443BehindSection: "За портом {port}", port443NoRoutes: "Маршрутизируемых сервисов не найдено", port443Via: "через {value}", roleMtproxy: "MTProxy", @@ -858,14 +860,17 @@ function roleLabel(role) { return label === key ? t("roleOther") : label; } -function renderPort443(payload = {}) { +function renderPortMap(payload = {}) { const listeners = Array.isArray(payload.listeners) ? payload.listeners : []; const routes = Array.isArray(payload.routes) ? payload.routes : []; const summary = $("#port443Summary"); const list = $("#port443List"); - const configuredPort = Number(payload.configured_port) || 443; - $("#port443Number").textContent = "443"; - $("#port443Configured").textContent = configuredPort === 443 ? t("port443Public") : t("port443Configured").replace("{port}", configuredPort); + const publicPort = Number(payload.public_port || payload.port || payload.configured_port) || 443; + const configuredPort = Number(payload.configured_port) || publicPort; + $("#visualTitle").textContent = t("visualTitle").replace("{port}", publicPort); + $("#visualText").textContent = t("visualText"); + $("#port443Number").textContent = String(publicPort); + $("#port443Configured").textContent = configuredPort === publicPort ? t("port443Public") : t("port443Configured").replace("{port}", configuredPort); if (payload.error) { summary.textContent = t("port443Error"); summary.className = "port-status error"; @@ -898,9 +903,9 @@ function renderPort443(payload = {}) { `; }).join("") : `
${escapeHtml(t("port443NoRoutes"))}
`; list.innerHTML = ` -
${escapeHtml(t("port443PublicSection"))}
+
${escapeHtml(t("port443PublicSection").replace("{port}", publicPort))}
${listenerHtml} -
${escapeHtml(t("port443BehindSection"))}
+
${escapeHtml(t("port443BehindSection").replace("{port}", publicPort))}
${routeHtml} `; } @@ -916,7 +921,7 @@ function renderOverview() { $("#settingsBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; $("#metricMode").textContent = cfg.mode || "--"; renderSiteStatus(); - renderPort443(data.port_443 || {}); + renderPortMap(data.port_map || data.port_443 || {}); $("#metricUsers").textContent = data.users_count ?? 0; $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`; @@ -1437,19 +1442,47 @@ function routingSummary(payload) { return `${listenerText}. ${mask}. ${t("routingPerUserNote")}`; } +function routingControlPairs() { + return [ + { + port: $("#visualRoutingPort"), + mask: $("#visualRoutingMaskHost"), + note: $("#visualRoutingNote"), + }, + { + port: $("#routingPort"), + mask: $("#routingMaskHost"), + note: $("#routingRuntimeNote"), + }, + ].filter((item) => item.port && item.mask); +} + +function setRoutingControlValues(routing = {}) { + routingControlPairs().forEach((item) => { + item.port.value = routing.port || 443; + item.mask.value = routing.mask_host || "google.com"; + }); +} + +function setRoutingNotes(routing = {}) { + const conflicts = routing.conflicts || []; + const message = routingSummary(routing); + routingControlPairs().forEach((item) => { + if (!item.note) return; + item.note.textContent = message; + item.note.classList.toggle("error", Boolean(conflicts.length)); + }); +} + function renderRoutingSettings(payload = null) { const routing = payload || state.routing || state.overview?.routing || {}; - const portEl = $("#routingPort"); - const maskEl = $("#routingMaskHost"); - if (!portEl || !maskEl) return; if (!payload) { - portEl.value = routing.port || 443; - maskEl.value = routing.mask_host || "google.com"; + setRoutingControlValues(routing); } const conflicts = routing.conflicts || []; $("#routingStatus").textContent = conflicts.length ? t("routingBusy") : t("routingOk"); $("#routingStatus").className = `status-pill ${conflicts.length ? "health-error" : "health-ok"}`; - $("#routingRuntimeNote").textContent = routingSummary(routing); + setRoutingNotes(routing); } async function refreshAll() { @@ -1640,16 +1673,28 @@ async function setUserMaxUniqueIps(name, value) { } } -async function checkRoutingPort() { - const port = Number.parseInt($("#routingPort").value, 10); +async function checkRoutingPort(inputEl = $("#routingPort")) { + const port = Number.parseInt(inputEl?.value, 10); if (!Number.isFinite(port) || port < 1 || port > 65535) return; try { const data = await api(`/api/routing?port=${encodeURIComponent(port)}`); renderRoutingSettings(data); + renderPortMap({ + public_port: data.port, + configured_port: state.routing?.port || state.overview?.routing?.port || data.port, + listeners: data.listeners || [], + routes: [], + ok: data.ok, + error: data.error, + }); } catch (err) { $("#routingStatus").textContent = t("routingBusy"); $("#routingStatus").className = "status-pill health-error"; - $("#routingRuntimeNote").textContent = err.message; + routingControlPairs().forEach((item) => { + if (!item.note) return; + item.note.textContent = err.message; + item.note.classList.add("error"); + }); } } @@ -1657,18 +1702,22 @@ async function saveRoutingSettings(eventObj) { eventObj.preventDefault(); const form = eventObj.currentTarget; const controls = Array.from(form.querySelectorAll("input, button")); + const portInput = form.querySelector('[name="port"]'); + const maskInput = form.querySelector('[name="mask_host"]'); controls.forEach((control) => { control.disabled = true; }); try { const payload = { - port: Number.parseInt($("#routingPort").value, 10), - mask_host: $("#routingMaskHost").value.trim(), + port: Number.parseInt(portInput?.value, 10), + mask_host: String(maskInput?.value || "").trim(), }; const data = await api("/api/routing", { method: "POST", body: JSON.stringify(payload), }); state.routing = data; + state.overview = await api("/api/overview"); renderRoutingSettings(); + renderOverview(); addEvent(t("routingSaved"), `${data.port} / ${data.mask_host}`); toast(t("routingSaved")); setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400); @@ -1947,8 +1996,10 @@ $("#loadLogsBtn").addEventListener("click", loadLogs); $("#repairStatsBtn").addEventListener("click", repairStats); $("#collectStatsBtn").addEventListener("click", collectStats); $("#authSettingsForm").addEventListener("submit", updateAuthSettings); +$("#visualRoutingForm").addEventListener("submit", saveRoutingSettings); +$("#visualRoutingPort").addEventListener("change", (eventObj) => checkRoutingPort(eventObj.target)); $("#routingSettingsForm").addEventListener("submit", saveRoutingSettings); -$("#routingPort").addEventListener("change", checkRoutingPort); +$("#routingPort").addEventListener("change", (eventObj) => checkRoutingPort(eventObj.target)); $("#warpSettingsForm").addEventListener("submit", saveWarpSettings); $("#warpScope").addEventListener("change", renderWarpSettings); $("#warpUserSelect").addEventListener("change", renderWarpSettings); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 16a68d3..551fc88 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -11,7 +11,7 @@ document.documentElement.dataset.theme = theme; }()); - +
@@ -69,6 +69,18 @@

PCAtelegram_web

Port 443

Website, MTProxy and local admin status in one operational view.

+
+ + + +
+

@@ -470,6 +482,6 @@
- + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 33fbe55..48971d8 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -380,6 +380,45 @@ h2 { color: var(--muted); } +.visual-routing-form { + display: grid; + grid-template-columns: minmax(92px, 130px) minmax(160px, 1fr) auto; + gap: 10px; + align-items: end; + max-width: 620px; + margin-top: 16px; +} + +.visual-routing-form label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.visual-routing-form input { + min-height: 42px; +} + +.visual-routing-form button { + min-height: 42px; + white-space: nowrap; +} + +.visual-routing-note { + min-height: 18px; + margin-top: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.visual-routing-note.error { + color: var(--red); +} + .signal-map { position: relative; min-height: 128px; @@ -1553,6 +1592,10 @@ td small { grid-template-columns: 1fr; } + .visual-routing-form { + grid-template-columns: 1fr; + } + .topbar { align-items: flex-start; padding: 16px;