diff --git a/DOCS_AI.md b/DOCS_AI.md index b31f5a7..4681178 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -448,14 +448,14 @@ switch_language ru|en - токена нет: после SSH tunnel открывается `http://127.0.0.1:1984/`; - write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически. - язык панели читается из `config.json.language`, затем из `/opt/gotelegram/.language`, fallback `en`; `POST /api/settings/language` сохраняет RU/EN в общий конфиг, marker file и bot `.env`; `gotelegram-bot/i18n.py` использует тот же источник как default до per-user override; -- UI построен вкладками (`dashboard`, `traffic`, `keys`, `backups`, `logs`, `settings`), есть иконки меню, графический overview-блок, light/dark theme в `localStorage` и promo-modal раз в 24 часа через `localStorage`; -- `/api/overview` отдаёт `stats_status`, `admin_bind` и `site_status`; `/api/site/check` проверяет `https://config.domain/` и считает OK только HTTP 200; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`. +- UI построен вкладками (`dashboard`, `traffic`, `keys`, `backups`, `logs`, `settings`), есть иконки меню, полезный overview-блок по реальным TCP/UDP-слушателям 443, light/dark theme в `localStorage` и promo-modal раз в 24 часа через `localStorage`; +- `/api/overview` отдаёт `stats_status`, `admin_bind`, `site_status` и `port_443`; `/api/site/check` проверяет `https://config.domain/` и считает OK только HTTP 200; `/api/stats?range=15m|1h|24h|month` отдаёт выбранное окно и `summary_rows`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`. -Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users//enabled`, генерация proxy links, traffic history из `/opt/gotelegram/stats_history.csv`, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`). +Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users//enabled`, генерация proxy links, traffic history из `/opt/gotelegram/stats_history.csv` с периодами 15m/1h/24h/month, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`). -Отключённые ключи хранятся в `/opt/gotelegram/disabled_users.json`: active keys остаются в `/etc/telemt/config.toml` под `[access.users]`, disabled keys удаляются из active block и могут быть возвращены обратно без потери secret. `main` защищён от удаления и отключения. +Отключённые ключи хранятся в `/opt/gotelegram/disabled_users.json`: active keys остаются в `/etc/telemt/config.toml` под `[access.users]`, disabled keys удаляются из active block и могут быть возвращены обратно без потери secret. `main` защищён от удаления и отключения. Операции с ключами берут file lock `/run/gotelegram/admin-users.lock`, TOML пишется через temp+replace, а telemt restart для add/delete/enable/disable запускается асинхронно, чтобы switch в UI не зависал на `wait_tcp_port`. -`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Repair stats в Traffic. Backup v1.4 сохраняет `admin_web/server.py`, `admin_web/static/` и `disabled_users.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. +`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Restart collector в Traffic. Backup v1.4 сохраняет `admin_web/server.py`, `admin_web/static/` и `disabled_users.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. ### 13.2 Upgrade migration (v2.5.0) @@ -631,7 +631,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s: ## 17. Changelog -- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/runtime info); добавлена локальная web-админка goTelegram Pro `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена, вкладочной UI-навигацией, иконками, графическим overview, promo-modal раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, site check на HTTP 200, structured journal logs, light/dark theme, адаптивом и stats repair endpoint; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.4` сохраняет bot `.env`, bot lang files, disabled user keys, web-admin server/static, custom templates, templates catalog, stats history и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и генерируется `/opt/gotelegram/shared-443-3xui.md` с объяснением shared-443 ограничений. +- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/runtime info); добавлена локальная web-админка goTelegram Pro `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена, вкладочной UI-навигацией, иконками, блоком реальных TCP/UDP-слушателей 443, promo-modal раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, site check на HTTP 200, structured journal logs, light/dark theme, адаптивом, быстрыми switch-переключателями ключей, traffic history 15m/1h/24h/month с переключением график/строки и stats collector restart endpoint; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.4` сохраняет bot `.env`, bot lang files, disabled user keys, web-admin server/static, custom templates, templates catalog, stats history и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и генерируется `/opt/gotelegram/shared-443-3xui.md` с объяснением shared-443 ограничений. - **2.4.6 (2026-04-10)** — universal `apt_lock_wait` helper: ожидание dpkg/apt lock при unattended-upgrades, исправляет установку nginx/certbot/python на свежих VPS. - **2.4.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`. - **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`. diff --git a/DOCS_HUMAN.md b/DOCS_HUMAN.md index 181619d..bef84c2 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -145,7 +145,7 @@ CLI и бот переведены на русский и английский. - есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile; - Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`. -В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, управление ключами `[access.users]` с добавлением, удалением и отключением через switch, генерация ссылок, SVG-график traffic history, кнопка восстановления сборщика статистики, список бекапов и просмотр логов с количеством строк и статусом `journalctl`. +В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, полезный блок «кто слушает порт 443» по данным `ss`, управление ключами `[access.users]` с добавлением, удалением и быстрым отключением через switch, генерация ссылок, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, кнопка разового обновления статистики, кнопка перезапуска сборщика, список бекапов и просмотр логов с количеством строк и статусом `journalctl`. Отключённые ключи убираются из активного telemt-конфига и сохраняются в `/opt/gotelegram/disabled_users.json`, поэтому их можно включить обратно без потери secret. Основной ключ `main` защищён от удаления и отключения. @@ -240,7 +240,7 @@ A: Сам MTProxy — да, это публичная технология из ## Changelog (коротко) -- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка и runtime/API-информация; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом и repair-кнопкой для статистики; backup/restore сохраняет bot `.env`, языки бота, отключённые ключи, web-admin server/static, custom templates, stats history и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 с предупреждением и заметкой по shared-443. +- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка и информация из API telemt; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом, быстрыми switch-переключателями ключей, блоком реальных TCP/UDP-слушателей 443, подсказками к техническим терминам и traffic history по периодам 15 минут / 1 час / 24 часа / месяц; backup/restore сохраняет bot `.env`, языки бота, отключённые ключи, web-admin server/static, custom templates, stats history и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 с предупреждением и заметкой по shared-443. - **2.4.6** — ожидание apt/dpkg lock на свежих Ubuntu/Debian, чтобы установка nginx/certbot/Python не падала во время unattended-upgrades. - **2.4.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости. - **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess. diff --git a/admin-web/server.py b/admin-web/server.py index 5ccac3c..ab34b91 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -9,6 +9,7 @@ through an SSH tunnel; it must never be exposed directly on the public network. from __future__ import annotations import csv +import fcntl import hashlib import json import mimetypes @@ -38,6 +39,7 @@ BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups")) INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram")) BOT_DIR = Path(os.getenv("GOTELEGRAM_BOT_DIR", "/opt/gotelegram-bot")) DISABLED_USERS_FILE = Path(os.getenv("GOTELEGRAM_DISABLED_USERS", "/opt/gotelegram/disabled_users.json")) +USER_LOCK_FILE = Path(os.getenv("GOTELEGRAM_USER_LOCK", "/run/gotelegram/admin-users.lock")) HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) @@ -45,6 +47,12 @@ VERSION = "2.5.0" USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") LANG_RE = re.compile(r"^(en|ru)$") SENSITIVE_CONFIG_KEYS = {"secret"} +TRAFFIC_WINDOWS = { + "15m": 15 * 60, + "1h": 60 * 60, + "24h": 24 * 60 * 60, + "month": 30 * 24 * 60 * 60, +} def utc_now() -> str: @@ -65,6 +73,23 @@ def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]: return 125, "", str(exc) +class FileLock: + def __init__(self, path: Path): + self.path = path + self.handle: Any = None + + def __enter__(self) -> "FileLock": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.handle = self.path.open("w", encoding="utf-8") + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX) + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + if self.handle: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + + def load_json(path: Path, fallback: Any = None) -> Any: try: with path.open("r", encoding="utf-8") as fh: @@ -226,8 +251,10 @@ def write_telemt_users(users: dict[str, str]) -> None: out.append("[access.users]") out.extend(rendered) - TELEMT_CONFIG.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") - os.chmod(TELEMT_CONFIG, 0o600) + tmp = TELEMT_CONFIG.with_name(TELEMT_CONFIG.name + ".tmp") + tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(TELEMT_CONFIG) def restart_service(name: str) -> bool: @@ -239,6 +266,19 @@ def restart_service(name: str) -> bool: return True +def request_service_restart(name: str) -> bool: + try: + subprocess.Popen( + ["systemctl", "restart", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except Exception: + return False + + def service_status(name: str) -> str: code, stdout, _ = run(["systemctl", "is-active", name], timeout=3) value = stdout.strip() @@ -271,6 +311,84 @@ def read_telemt_port() -> int: return 443 +def _is_port_addr(value: str, port: int) -> bool: + token = value.strip() + if token.startswith("[") and "]:" in token: + return token.rsplit(":", 1)[-1] == str(port) + return token.rsplit(":", 1)[-1] == str(port) if ":" in token else False + + +def _process_role(process: str) -> str: + lowered = process.lower() + if "telemt" in lowered or "mtproto" in lowered: + return "mtproxy" + if "nginx" in lowered or "apache" in lowered or "caddy" in lowered: + return "site" + if "xray" in lowered or "x-ui" in lowered or "3x-ui" in lowered or "xui" in lowered: + return "xray" + if "amnezia" in lowered or "awg" in lowered or "wireguard" in lowered or re.search(r"\bwg\b", lowered): + return "amneziawg" + return "other" + + +def parse_ss_listeners(output: str, proto: str, port: int = 443) -> list[dict[str, Any]]: + listeners: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + for line in output.splitlines(): + parts = line.split() + address = next((part for part in parts if _is_port_addr(part, port)), "") + if not address: + continue + matches = re.findall(r'\("([^"]+)",pid=(\d+)', line) + if matches: + process_names = [] + pids = [] + for proc, pid in matches: + if proc not in process_names: + process_names.append(proc) + if pid not in pids: + pids.append(pid) + process = ", ".join(process_names) + pid_text = ", ".join(pids) + else: + process = "unknown" + pid_text = "" + key = (proto, address, process) + if key in seen: + continue + seen.add(key) + listeners.append({ + "proto": proto.upper(), + "address": address, + "process": process, + "pid": pid_text, + "role": _process_role(process), + }) + return listeners + + +def port_443_status() -> dict[str, Any]: + listeners: list[dict[str, Any]] = [] + errors: list[str] = [] + for proto, args in { + "tcp": ["ss", "-H", "-ltnp"], + "udp": ["ss", "-H", "-lunp"], + }.items(): + code, stdout, stderr = run(args, timeout=2) + if code == 0: + listeners.extend(parse_ss_listeners(stdout, proto, 443)) + elif stderr.strip(): + errors.append(stderr.strip()) + listeners.sort(key=lambda item: (item["proto"], item["address"], item["process"])) + return { + "checked_at": int(time.time()), + "configured_port": read_telemt_port(), + "listeners": listeners, + "ok": not errors, + "error": "; ".join(errors[:2]), + } + + def wait_tcp_port(port: int, timeout: int = 90) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -345,7 +463,7 @@ def site_status(config: dict[str, Any] | None = None) -> dict[str, Any]: } -def load_stats_history(limit: int = 240) -> list[dict[str, int]]: +def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]: if not HISTORY_FILE.exists(): return [] rows: list[dict[str, int]] = [] @@ -362,7 +480,8 @@ def load_stats_history(limit: int = 240) -> list[dict[str, int]]: continue except OSError: return [] - rows = rows[-limit:] + if limit: + rows = rows[-limit:] previous = None enriched: list[dict[str, int]] = [] for row in rows: @@ -378,6 +497,56 @@ def load_stats_history(limit: int = 240) -> list[dict[str, int]]: return enriched +def history_limit_for_range(range_key: str) -> int: + return { + "15m": 180, + "1h": 240, + "24h": 1800, + "month": 50000, + }.get(range_key, 240) + + +def normalize_range(range_key: str) -> str: + return range_key if range_key in TRAFFIC_WINDOWS else "1h" + + +def filter_history_by_range(rows: list[dict[str, int]], range_key: str) -> list[dict[str, int]]: + if not rows: + return [] + seconds = TRAFFIC_WINDOWS[normalize_range(range_key)] + latest = max(row.get("epoch", 0) for row in rows) + cutoff = latest - seconds + return [row for row in rows if row.get("epoch", 0) >= cutoff] + + +def traffic_interval_summaries(rows: list[dict[str, int]]) -> list[dict[str, Any]]: + if not rows: + return [ + {"range": key, "points": 0, "from": 0, "to": 0, "proxy_delta": 0, "site_delta": 0, "proxy_total": 0, "site_total": 0} + for key in TRAFFIC_WINDOWS + ] + latest = max(row.get("epoch", 0) for row in rows) + summaries = [] + for key, seconds in TRAFFIC_WINDOWS.items(): + window = [row for row in rows if row.get("epoch", 0) >= latest - seconds] + if not window: + summaries.append({"range": key, "points": 0, "from": 0, "to": latest, "proxy_delta": 0, "site_delta": 0, "proxy_total": 0, "site_total": 0}) + continue + first = window[0] + last = window[-1] + summaries.append({ + "range": key, + "points": len(window), + "from": first.get("epoch", 0), + "to": last.get("epoch", 0), + "proxy_delta": max(0, int(last.get("proxy_bytes", 0)) - int(first.get("proxy_bytes", 0))), + "site_delta": max(0, int(last.get("site_bytes", 0)) - int(first.get("site_bytes", 0))), + "proxy_total": int(last.get("proxy_bytes", 0)), + "site_total": int(last.get("site_bytes", 0)), + }) + return summaries + + def count_history_rows() -> int: if not HISTORY_FILE.exists(): return 0 @@ -537,6 +706,7 @@ def overview_payload() -> dict[str, Any]: "site_status": site_status(config), "users_count": len(users), "services": services, + "port_443": port_443_status(), "stats_current": current, "stats_history": history, "stats_status": stats_status(current, history), @@ -599,9 +769,21 @@ class AdminHandler(BaseHTTPRequestHandler): elif path == "/api/backups": self.send_json({"ok": True, "data": list_backups()}) elif path == "/api/stats": + qs = urllib.parse.parse_qs(parsed.query) + range_key = normalize_range(qs.get("range", ["1h"])[0]) current = load_json(CURRENT_STATS, {}) or {} - history = load_stats_history() - self.send_json({"ok": True, "data": {"current": current, "history": history, "status": stats_status(current, history)}}) + all_history = load_stats_history(limit=history_limit_for_range("month")) + history = filter_history_by_range(all_history[-history_limit_for_range(range_key):], range_key) + self.send_json({ + "ok": True, + "data": { + "range": range_key, + "current": current, + "history": history, + "summary_rows": traffic_interval_summaries(all_history), + "status": stats_status(current, history), + }, + }) elif path == "/api/site/check": self.send_json({"ok": True, "data": site_status()}) elif path == "/api/logs": @@ -631,51 +813,53 @@ class AdminHandler(BaseHTTPRequestHandler): if not USER_RE.match(name): self.send_error_json(400, "invalid user name") return - records = read_user_records() - if name in records: - self.send_error_json(409, "user already exists") - return - users = read_telemt_users() - seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode() - secret = hashlib.sha256(seed).hexdigest()[:32] - users[name] = secret try: - write_telemt_users(users) + with FileLock(USER_LOCK_FILE): + records = read_user_records() + if name in records: + self.send_error_json(409, "user already exists") + return + users = read_telemt_users() + seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode() + secret = hashlib.sha256(seed).hexdigest()[:32] + users[name] = secret + write_telemt_users(users) except Exception as exc: self.send_error_json(500, f"failed to save config: {exc}") return - restarted = restart_service("telemt") - self.send_json({"ok": True, "data": user_payload(name, secret, True), "restarted": restarted}) + restart_requested = request_service_restart("telemt") + self.send_json({"ok": True, "data": user_payload(name, secret, True), "restart": {"mode": "async", "requested": restart_requested}}) elif path.startswith("/api/users/") and path.endswith("/enabled"): name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")]) if name == "main": self.send_error_json(400, "main user cannot be disabled") return enabled = bool(body.get("enabled")) - active = read_telemt_users() - disabled = read_disabled_users() - records = read_user_records() - if name not in records: - self.send_error_json(404, "user not found") - return - if enabled: - secret = disabled.pop(name, records[name]["secret"]) - active[name] = secret - else: - secret = active.pop(name, records[name]["secret"]) - disabled[name] = secret try: - if enabled: - write_telemt_users(active) - write_disabled_users(disabled) - else: - write_disabled_users(disabled) - write_telemt_users(active) + with FileLock(USER_LOCK_FILE): + active = read_telemt_users() + disabled = read_disabled_users() + records = read_user_records() + if name not in records: + self.send_error_json(404, "user not found") + return + if enabled: + secret = disabled.pop(name, records[name]["secret"]) + active[name] = secret + else: + secret = active.pop(name, records[name]["secret"]) + disabled[name] = secret + if enabled: + write_telemt_users(active) + write_disabled_users(disabled) + else: + write_disabled_users(disabled) + write_telemt_users(active) except Exception as exc: self.send_error_json(500, f"failed to save config: {exc}") return - restarted = restart_service("telemt") - self.send_json({"ok": True, "data": user_payload(name, secret, enabled), "restarted": restarted}) + restart_requested = request_service_restart("telemt") + self.send_json({"ok": True, "data": user_payload(name, secret, enabled), "restart": {"mode": "async", "requested": restart_requested}}) elif path == "/api/backups": ok, result = create_backup() self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) @@ -716,22 +900,23 @@ class AdminHandler(BaseHTTPRequestHandler): if name == "main": self.send_error_json(400, "main user cannot be deleted") return - active = read_telemt_users() - disabled = read_disabled_users() - records = read_user_records() - if name not in records: - self.send_error_json(404, "user not found") - return - active.pop(name, None) - disabled.pop(name, None) try: - write_telemt_users(active) - write_disabled_users(disabled) + with FileLock(USER_LOCK_FILE): + active = read_telemt_users() + disabled = read_disabled_users() + records = read_user_records() + if name not in records: + self.send_error_json(404, "user not found") + return + active.pop(name, None) + disabled.pop(name, None) + write_telemt_users(active) + write_disabled_users(disabled) except Exception as exc: self.send_error_json(500, f"failed to save config: {exc}") return - restarted = restart_service("telemt") - self.send_json({"ok": True, "restarted": restarted}) + restart_requested = request_service_restart("telemt") + self.send_json({"ok": True, "restart": {"mode": "async", "requested": restart_requested}}) def send_static(self, parsed: urllib.parse.ParseResult) -> None: rel = parsed.path.lstrip("/") or "index.html" diff --git a/admin-web/static/app.js b/admin-web/static/app.js index dad2532..5184a78 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -15,14 +15,16 @@ const i18n = { themeLight: "Light", metricMode: "Mode", metricKeys: "Keys", - metricProxyTraffic: "Proxy Traffic", - metricSiteTraffic: "Site Traffic", + metricProxyTraffic: "Proxy traffic", + metricSiteTraffic: "Site traffic", configuredUsers: "configured users", packets: "packets", servicesEyebrow: "Services", - servicesTitle: "Runtime health", + servicesTitle: "Service health", + servicesHelp: "Systemd service status for telemt, nginx, the bot, the traffic collector and the local admin.", runtimeEyebrow: "Runtime", runtimeTitle: "telemt summary", + runtimeHelp: "Runtime data comes from the local telemt API and shows what the proxy engine sees right now.", trafficEyebrow: "Traffic", trafficTitle: "History", keysEyebrow: "Access", @@ -40,9 +42,12 @@ const i18n = { collector: "Collector", lastPoint: "Last point", historyRows: "History rows", - collectStats: "Collect", - repairStats: "Repair stats", + collectStats: "Update stats", + collectStatsHelp: "Run one traffic collection now.", + repairStats: "Restart collector", + repairStatsHelp: "Reinstall and restart the background service that writes traffic history.", tableTime: "Time", + tablePeriod: "Period", tableStatus: "Status", tableProxyDelta: "Proxy delta", tableSiteDelta: "Site delta", @@ -59,6 +64,8 @@ const i18n = { delete: "Delete", enabled: "Enabled", disabled: "Disabled", + applying: "Applying...", + changesApplyInBackground: "Changes are being applied in the background", disableKey: "Disable key", enableKey: "Enable key", main: "main", @@ -72,6 +79,7 @@ const i18n = { noBackups: "No backups yet", noEvents: "No events yet", noHistory: "No traffic history yet", + noTrafficForRange: "No data for this range yet", noRuntime: "Runtime data is not available", badConnections: "Bad connections", connections: "Connections", @@ -103,7 +111,7 @@ const i18n = { keyDeleted: "Key deleted", backupCreated: "Backup created", serviceRestarted: "Service restarted", - statsRepaired: "Statistics repaired", + statsRepaired: "Collector restarted", statsCollected: "Statistics collected", confirmDelete: "Delete key", confirmRestart: "Restart", @@ -128,8 +136,34 @@ const i18n = { languageSaved: "Language saved", keyEnabled: "Key enabled", keyDisabled: "Key disabled", - visualTitle: "443 shared edge", - visualText: "Website, MTProxy and local admin status in one operational view.", + visualTitle: "Port 443 listeners", + visualText: "Actual TCP/UDP listeners on public port 443: telemt, website, Xray/3x-ui, AmneziaWG or another service.", + port443Checked: "checked", + port443NoListeners: "No 443 listeners found", + port443Listeners: "listeners", + port443Error: "Port check failed", + roleMtproxy: "MTProxy", + roleSite: "Website", + roleXray: "Xray / 3x-ui", + roleAmneziawg: "AmneziaWG", + roleOther: "Other", + range15m: "15 min", + range1h: "1 hour", + range24h: "24 hours", + rangeMonth: "Month", + viewChart: "Chart", + viewRows: "Rows", + chartMax: "max {value} per interval", + chartProxy: "proxy", + chartSite: "site", + encrypted: "encrypted", + ariaAdminSections: "Admin sections", + ariaMenu: "Open menu", + ariaLanguage: "Language", + ariaClose: "Close", + ariaTrafficHistory: "Traffic history", + ariaTrafficRange: "Traffic range", + ariaTrafficView: "Traffic view", promoEyebrow: "Promo", promoTitle: "Support goTelegram Pro", promoHosting1: "Hosting #1", @@ -161,14 +195,16 @@ const i18n = { themeLight: "Светлая", metricMode: "Режим", metricKeys: "Ключи", - metricProxyTraffic: "Трафик proxy", + metricProxyTraffic: "Трафик прокси", metricSiteTraffic: "Трафик сайта", configuredUsers: "настроенных пользователей", packets: "пакетов", servicesEyebrow: "Сервисы", - servicesTitle: "Состояние runtime", - runtimeEyebrow: "Runtime", - runtimeTitle: "сводка telemt", + servicesTitle: "Состояние служб", + servicesHelp: "Статус systemd-служб: telemt, nginx, бот, сборщик трафика и локальная админка.", + runtimeEyebrow: "Среда выполнения", + runtimeTitle: "Сводка telemt", + runtimeHelp: "Данные среды выполнения берутся из локального API telemt и показывают, что ядро прокси видит прямо сейчас.", trafficEyebrow: "Трафик", trafficTitle: "История", keysEyebrow: "Доступ", @@ -186,25 +222,30 @@ const i18n = { collector: "Сборщик", lastPoint: "Последняя точка", historyRows: "Строк истории", - collectStats: "Собрать", - repairStats: "Починить статистику", + collectStats: "Обновить статистику", + collectStatsHelp: "Запустить один сбор трафика прямо сейчас.", + repairStats: "Перезапустить сборщик", + repairStatsHelp: "Переустановить и перезапустить фоновую службу, которая пишет историю трафика.", tableTime: "Время", + tablePeriod: "Период", tableStatus: "Статус", - tableProxyDelta: "Proxy delta", - tableSiteDelta: "Site delta", - tableProxyTotal: "Proxy всего", - tableSiteTotal: "Site всего", + tableProxyDelta: "Прирост прокси", + tableSiteDelta: "Прирост сайта", + tableProxyTotal: "Всего прокси", + tableSiteTotal: "Всего по сайту", tableUser: "Пользователь", - tableSecret: "Secret", + tableSecret: "Секрет", tableLink: "Ссылка", tableActions: "Действия", userPlaceholder: "client-name", addKey: "Добавить ключ", copyLink: "Копировать ссылку", - copySecret: "Копировать secret", + copySecret: "Копировать секрет", delete: "Удалить", enabled: "Включён", disabled: "Отключён", + applying: "Применяется...", + changesApplyInBackground: "Изменения применяются в фоне", disableKey: "Отключить ключ", enableKey: "Включить ключ", main: "основной", @@ -212,13 +253,14 @@ const i18n = { loadLogs: "Загрузить", panelLanguage: "Язык панели", theme: "Тема", - bindAddress: "Адрес bind", + bindAddress: "Адрес привязки", dashboard: "Обзор", noKeys: "Ключей пока нет", noBackups: "Бекапов пока нет", noEvents: "Событий пока нет", noHistory: "Истории трафика пока нет", - noRuntime: "Runtime-данные недоступны", + noTrafficForRange: "За этот период данных пока нет", + noRuntime: "Данные среды выполнения недоступны", badConnections: "Ошибочные подключения", connections: "Подключения", uptime: "Аптайм", @@ -240,16 +282,16 @@ const i18n = { statusUnknown: "неизвестно", statsMissing: "Сборщик не запущен", statsOk: "Сборщик работает", - statsStale: "Snapshot устарел", + statsStale: "Снимок устарел", statsError: "Ошибка сборщика", - restart: "Рестарт", + restart: "Перезапустить", copied: "Скопировано", copyFailed: "Не удалось скопировать", keyCreated: "Ключ создан", keyDeleted: "Ключ удалён", backupCreated: "Бекап создан", serviceRestarted: "Сервис перезапущен", - statsRepaired: "Статистика починена", + statsRepaired: "Сборщик перезапущен", statsCollected: "Статистика собрана", confirmDelete: "Удалить ключ", confirmRestart: "Перезапустить", @@ -274,8 +316,34 @@ const i18n = { languageSaved: "Язык сохранён", keyEnabled: "Ключ включён", keyDisabled: "Ключ отключён", - visualTitle: "Единый 443 edge", - visualText: "Сайт, MTProxy и локальная админка в одном рабочем обзоре.", + visualTitle: "Кто слушает порт 443", + visualText: "Реальные TCP/UDP-процессы на публичном 443: telemt, сайт, Xray/3x-ui, AmneziaWG или другой сервис.", + port443Checked: "проверено", + port443NoListeners: "Слушателей 443 не найдено", + port443Listeners: "слушателей", + port443Error: "Проверка порта не удалась", + roleMtproxy: "MTProxy", + roleSite: "Сайт", + roleXray: "Xray / 3x-ui", + roleAmneziawg: "AmneziaWG", + roleOther: "Другое", + range15m: "15 мин", + range1h: "1 час", + range24h: "24 часа", + rangeMonth: "Месяц", + viewChart: "График", + viewRows: "Строки", + chartMax: "макс. {value} за интервал", + chartProxy: "прокси", + chartSite: "сайт", + encrypted: "зашифровано", + ariaAdminSections: "Разделы админки", + ariaMenu: "Открыть меню", + ariaLanguage: "Язык", + ariaClose: "Закрыть", + ariaTrafficHistory: "История трафика", + ariaTrafficRange: "Период трафика", + ariaTrafficView: "Вид трафика", promoEyebrow: "Промо", promoTitle: "Поддержать goTelegram Pro", promoHosting1: "Хостинг #1", @@ -298,15 +366,21 @@ const i18n = { const state = { overview: null, + stats: null, users: [], events: [], lang: "en", page: "dashboard", theme: document.documentElement.dataset.theme || "light", + trafficRange: "1h", + trafficView: "chart", + pendingUsers: new Set(), }; const t = (key) => (i18n[state.lang] && i18n[state.lang][key]) || i18n.en[key] || key; +const trafficRanges = ["15m", "1h", "24h", "month"]; + const fmtBytes = (value = 0) => { const units = ["B", "KB", "MB", "GB", "TB"]; let n = Number(value) || 0; @@ -380,12 +454,19 @@ function applyI18n() { $$("[data-i18n-placeholder]").forEach((el) => { el.placeholder = t(el.dataset.i18nPlaceholder); }); + $$("[data-i18n-title]").forEach((el) => { + el.title = t(el.dataset.i18nTitle); + }); + $$("[data-i18n-aria-label]").forEach((el) => { + el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel)); + }); $("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark"); $("#languageSelect").value = state.lang; $("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English"; $("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme"); $("#visualTitle").textContent = t("visualTitle"); $("#visualText").textContent = t("visualText"); + updateTrafficControls(); updatePageTitle(); } @@ -394,7 +475,7 @@ function setTheme(theme) { document.documentElement.dataset.theme = state.theme; localStorage.setItem("gotelegram-theme", state.theme); applyI18n(); - if (state.overview) drawTrafficChart(state.overview.stats_history || []); + if (state.overview) renderStats(); } async function setLanguage(lang) { @@ -427,6 +508,9 @@ function setPage(page, push = true) { if (push && location.hash !== `#${next}`) { history.replaceState(null, "", `#${next}`); } + if (next === "traffic") { + refreshStats().catch((err) => toast(err.message)); + } } function updatePageTitle() { @@ -540,6 +624,42 @@ function renderSiteStatus() { statusEl.title = site.url || ""; } +function roleLabel(role) { + const key = `role${String(role || "other").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`; + const label = t(key); + return label === key ? t("roleOther") : label; +} + +function renderPort443(payload = {}) { + const listeners = Array.isArray(payload.listeners) ? payload.listeners : []; + const summary = $("#port443Summary"); + const list = $("#port443List"); + if (payload.error) { + summary.textContent = t("port443Error"); + summary.className = "port-status error"; + } else if (!listeners.length) { + summary.textContent = t("port443NoListeners"); + summary.className = "port-status warn"; + } else { + summary.textContent = `${listeners.length} ${t("port443Listeners")}`; + summary.className = "port-status ok"; + } + if (!listeners.length) { + list.innerHTML = `
${escapeHtml(payload.error || t("port443NoListeners"))}
`; + return; + } + list.innerHTML = listeners.map((item) => { + const title = `${item.proto || ""} ${item.address || ""} · ${item.process || "unknown"}${item.pid ? ` · pid ${item.pid}` : ""}`; + return `
+
+ ${escapeHtml(roleLabel(item.role))} + ${escapeHtml(item.process || "unknown")}${item.pid ? ` · pid ${escapeHtml(item.pid)}` : ""} +
+ ${escapeHtml(item.proto || "--")} · ${escapeHtml(item.address || "--")} +
`; + }).join(""); +} + function renderOverview() { const data = state.overview; if (!data) return; @@ -551,6 +671,7 @@ function renderOverview() { $("#settingsBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; $("#metricMode").textContent = cfg.mode || "--"; renderSiteStatus(); + renderPort443(data.port_443 || {}); $("#metricUsers").textContent = data.users_count ?? 0; $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`; @@ -564,10 +685,95 @@ function renderOverview() { renderConfig(); } +function statsPayload() { + if (state.stats) return state.stats; + return { + current: state.overview?.stats_current || {}, + history: state.overview?.stats_history || [], + status: state.overview?.stats_status || {}, + summary_rows: [], + }; +} + +function updateTrafficControls() { + $$("[data-traffic-range]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.trafficRange === state.trafficRange); + }); + $$("[data-traffic-view]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.trafficView === state.trafficView); + }); +} + +function trafficRangeLabel(range) { + const labels = { + "15m": t("range15m"), + "1h": t("range1h"), + "24h": t("range24h"), + month: t("rangeMonth"), + }; + return labels[range] || range; +} + +function rangeSeconds(range) { + return { + "15m": 15 * 60, + "1h": 60 * 60, + "24h": 24 * 60 * 60, + month: 30 * 24 * 60 * 60, + }[range] || 60 * 60; +} + +function filterTrafficRows(rows, range = state.trafficRange) { + if (!Array.isArray(rows) || !rows.length) return []; + const latest = Math.max(...rows.map((row) => Number(row.epoch) || 0)); + const cutoff = latest - rangeSeconds(range); + return rows.filter((row) => (Number(row.epoch) || 0) >= cutoff); +} + +function bucketTrafficRows(rows) { + const filtered = filterTrafficRows(rows); + if (filtered.length <= 140) return filtered; + const chunk = Math.ceil(filtered.length / 120); + const buckets = []; + for (let i = 0; i < filtered.length; i += chunk) { + const slice = filtered.slice(i, i + chunk); + const last = slice[slice.length - 1]; + buckets.push({ + epoch: last.epoch, + proxy_delta: slice.reduce((sum, item) => sum + (Number(item.proxy_delta) || 0), 0), + site_delta: slice.reduce((sum, item) => sum + (Number(item.site_delta) || 0), 0), + proxy_bytes: last.proxy_bytes, + site_bytes: last.site_bytes, + }); + } + return buckets; +} + +function fallbackTrafficSummaries(rows) { + return trafficRanges.map((range) => { + const windowRows = filterTrafficRows(rows, range); + if (!windowRows.length) { + return { range, points: 0, proxy_delta: 0, site_delta: 0, proxy_total: 0, site_total: 0 }; + } + const first = windowRows[0]; + const last = windowRows[windowRows.length - 1]; + return { + range, + points: windowRows.length, + proxy_delta: Math.max(0, (Number(last.proxy_bytes) || 0) - (Number(first.proxy_bytes) || 0)), + site_delta: Math.max(0, (Number(last.site_bytes) || 0) - (Number(first.site_bytes) || 0)), + proxy_total: Number(last.proxy_bytes) || 0, + site_total: Number(last.site_bytes) || 0, + }; + }); +} + function renderStats() { - const status = state.overview?.stats_status || {}; - const stats = state.overview?.stats_current || {}; - const historyRows = state.overview?.stats_history || []; + const payload = statsPayload(); + const status = payload.status || {}; + const stats = payload.current || {}; + const historyRows = payload.history || []; + const summaryRows = payload.summary_rows?.length ? payload.summary_rows : fallbackTrafficSummaries(historyRows); $("#statsHealth").className = `status-pill health-${escapeAttr(status.health || "unknown")}`; $("#statsHealth").textContent = healthLabel(status.health); $("#collectorState").textContent = status.service ? statusLabel(status.service) : "--"; @@ -577,18 +783,21 @@ function renderStats() { $("#collectStatsBtn").disabled = status.service === "not_installed"; $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); + updateTrafficControls(); + $("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart"); + $("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table"); drawTrafficChart(historyRows); - renderHistoryTable(historyRows); + renderHistoryTable(summaryRows); } function drawTrafficChart(rows) { const el = $("#trafficChart"); - const points = rows.slice(-120); + const points = bucketTrafficRows(rows); const proxyColor = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb"; const siteColor = getComputedStyle(document.documentElement).getPropertyValue("--green").trim() || "#0f9f6e"; if (points.length < 2) { el.innerHTML = `
- ${escapeHtml(t("noHistory"))} + ${escapeHtml(points.length ? t("noTrafficForRange") : t("noHistory"))} ${escapeHtml(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))}
`; return; @@ -606,30 +815,30 @@ function drawTrafficChart(rows) { const y = pad.t + (plotH / 4) * i; return ``; }).join(""); - el.innerHTML = ` + const axis = t("chartMax").replace("{value}", fmtBytes(max)); + el.innerHTML = ` ${grid} - max ${escapeHtml(fmtBytes(max))}/min - proxy - site + ${escapeHtml(axis)} + ${escapeHtml(t("chartProxy"))} + ${escapeHtml(t("chartSite"))} `; } function renderHistoryTable(rows) { - const latest = rows.slice(-12).reverse(); - if (!latest.length) { + if (!rows.length) { $("#historyTable").innerHTML = `
${escapeHtml(t("noHistory"))}
`; return; } - $("#historyTable").innerHTML = latest.map((row) => ` + $("#historyTable").innerHTML = rows.map((row) => `
- ${escapeHtml(fmtDate(row.epoch))} +
${escapeHtml(trafficRangeLabel(row.range))}${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))} ${escapeHtml(fmtBytes(row.proxy_delta))} ${escapeHtml(fmtBytes(row.site_delta))} - ${escapeHtml(fmtBytes(row.proxy_bytes))} - ${escapeHtml(fmtBytes(row.site_bytes))} + ${escapeHtml(fmtBytes(row.proxy_total))} + ${escapeHtml(fmtBytes(row.site_total))} `).join(""); } @@ -640,18 +849,20 @@ function renderUsers() { tbody.innerHTML = `${escapeHtml(t("noKeys"))}`; return; } - tbody.innerHTML = state.users.map((user) => ` - + tbody.innerHTML = state.users.map((user) => { + const pending = state.pendingUsers.has(user.name); + return ` + ${escapeHtml(user.name)}${user.main ? ` ${escapeHtml(t("main"))}` : ""}
- ${escapeHtml(user.enabled ? t("enabled") : t("disabled"))} + ${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}
${escapeHtml(user.secret)} @@ -661,7 +872,7 @@ function renderUsers() { - `).join(""); + `; }).join(""); } function renderBackups(backups) { @@ -676,7 +887,7 @@ function renderBackups(backups) { ${escapeHtml(item.name)} ${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))} -
${escapeHtml(fmtBytes(item.size))}${item.encrypted ? " · encrypted" : ""}
+
${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}
`).join(""); } @@ -721,6 +932,20 @@ async function refreshAll() { state.overview = await api("/api/overview"); updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); + if (!state.stats) { + state.stats = { + current: state.overview.stats_current || {}, + history: state.overview.stats_history || [], + status: state.overview.stats_status || {}, + summary_rows: [], + }; + } else { + state.stats = { + ...state.stats, + current: state.overview.stats_current || state.stats.current || {}, + status: state.overview.stats_status || state.stats.status || {}, + }; + } renderOverview(); renderUsers(); } catch (err) { @@ -730,6 +955,12 @@ async function refreshAll() { } } +async function refreshStats() { + const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`); + state.stats = data; + renderStats(); +} + async function addUser(name) { const data = await api("/api/users", { method: "POST", @@ -748,14 +979,29 @@ async function deleteUser(name) { } async function setUserEnabled(name, enabled) { - const data = await api(`/api/users/${encodeURIComponent(name)}/enabled`, { - method: "POST", - body: JSON.stringify({ enabled }), - }); - const message = data.enabled ? t("keyEnabled") : t("keyDisabled"); - addEvent(message, name); - toast(message); - await refreshAll(); + const previousUsers = state.users.map((user) => ({ ...user })); + state.pendingUsers.add(name); + state.users = state.users.map((user) => user.name === name ? { ...user, enabled } : user); + renderUsers(); + try { + const data = await api(`/api/users/${encodeURIComponent(name)}/enabled`, { + method: "POST", + body: JSON.stringify({ enabled }), + }); + state.users = state.users.map((user) => user.name === name ? { ...user, enabled: data.enabled } : user); + const message = data.enabled ? t("keyEnabled") : t("keyDisabled"); + addEvent(message, name); + toast(t("changesApplyInBackground")); + setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1200); + } catch (err) { + state.users = previousUsers; + toast(err.message); + } finally { + setTimeout(() => { + state.pendingUsers.delete(name); + renderUsers(); + }, 700); + } } async function createBackup() { @@ -812,6 +1058,7 @@ async function repairStats() { addEvent(t("statsRepaired")); toast(t("statsRepaired")); await refreshAll(); + await refreshStats(); } catch (err) { toast(err.message); } finally { @@ -827,6 +1074,7 @@ async function collectStats() { addEvent(t("statsCollected")); toast(t("statsCollected")); await refreshAll(); + await refreshStats(); } catch (err) { toast(err.message); } finally { @@ -875,6 +1123,14 @@ document.addEventListener("click", async (eventObj) => { setTheme(state.theme === "dark" ? "light" : "dark"); } else if (button.id === "menuBtn") { $("#sidebar").classList.toggle("open"); + } else if (button.dataset.trafficRange) { + state.trafficRange = trafficRanges.includes(button.dataset.trafficRange) ? button.dataset.trafficRange : "1h"; + updateTrafficControls(); + renderStats(); + refreshStats().catch((err) => toast(err.message)); + } else if (button.dataset.trafficView) { + state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart"; + renderStats(); } else if (button.dataset.copy) { await copyText(button.dataset.copy); } else if (button.dataset.delete) { diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 9178b36..a649359 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -11,7 +11,7 @@ document.documentElement.dataset.theme = theme; }()); - +
@@ -24,7 +24,7 @@
-