diff --git a/DOCS_AI.md b/DOCS_AI.md index d471453..6d4eec0 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -442,15 +442,18 @@ switch_language ru|en Локальная web-админка находится в `admin-web/` и устанавливается в `/opt/gotelegram-admin`: - backend: `admin-web/server.py`, Python stdlib only, без pip/npm dependencies; -- frontend: `admin-web/static/`, vanilla JS/CSS, canvas-график без CDN; +- frontend: `admin-web/static/`, vanilla JS/CSS, SVG-график без CDN; - systemd service: `gotelegram-admin`; - bind: `127.0.0.1:1984`, доступ только через SSH tunnel; - токена нет: после SSH tunnel открывается `http://127.0.0.1:1984/`; - write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически. +- язык панели читается из `config.json.language`, затем из `/opt/gotelegram/.language`, fallback `en`; `gotelegram-bot/i18n.py` использует тот же источник как default до per-user override; +- UI построен вкладками (`dashboard`, `traffic`, `keys`, `backups`, `logs`, `settings`), есть light/dark theme в `localStorage`; +- `/api/overview` отдаёт `stats_status` и `admin_bind`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`. Функции: overview, service status/restart, чтение/запись `[access.users]`, генерация proxy links, traffic history из `/opt/gotelegram/stats_history.csv`, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, journal logs. -`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. Backup v1.3 сохраняет `admin_web/server.py` и `admin_web/static/`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. +`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Repair stats в Traffic. Backup v1.3 сохраняет `admin_web/server.py` и `admin_web/static/`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. ### 13.2 Upgrade migration (v2.5.0) @@ -626,7 +629,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-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.3` сохраняет bot `.env`, bot lang files, 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-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена, вкладочной UI-навигацией, i18n от языка установки, 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.3` сохраняет bot `.env`, bot lang files, 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 fc37afa..9466500 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -141,9 +141,11 @@ CLI и бот переведены на русский и английский. - слушает только `127.0.0.1:1984`; - наружу не публикуется и рассчитана на доступ через SSH tunnel; - после туннеля открывается обычным URL `http://127.0.0.1:1984/`; +- язык берётся из `config.json.language` / `/opt/gotelegram/.language`, как в CLI и Telegram-боте; +- есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile; - Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`. -В админке есть dashboard, статус сервисов, управление `[access.users]`, генерация ссылок, график трафика, список бекапов и просмотр логов. +В админке есть dashboard, статус сервисов, управление `[access.users]`, генерация ссылок, SVG-график traffic history, кнопка восстановления сборщика статистики, список бекапов и просмотр логов. --- @@ -236,7 +238,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-админка на `127.0.0.1:1984` под SSH tunnel без отдельного токена; 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]`): список, добавление, удаление, ссылка и runtime/API-информация; добавлена локальная web-админка на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, i18n от языка установки, тёмной темой, адаптивом и repair-кнопкой для статистики; 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 87932e8..eefa06d 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -69,6 +69,18 @@ def load_json(path: Path, fallback: Any = None) -> Any: return fallback +def read_language(config: dict[str, Any] | None = None) -> str: + config = config or load_json(GOTELEGRAM_CONFIG, {}) or {} + lang = str(config.get("language") or config.get("lang") or "").strip().lower() + marker = INSTALL_DIR / ".language" + if lang not in {"en", "ru"} and marker.exists(): + try: + lang = marker.read_text(encoding="utf-8", errors="ignore").strip().lower()[:2] + except OSError: + lang = "" + return lang if lang in {"en", "ru"} else "en" + + def read_telemt_users() -> dict[str, str]: if not TELEMT_CONFIG.exists(): return {} @@ -257,6 +269,74 @@ def load_stats_history(limit: int = 240) -> list[dict[str, int]]: return enriched +def count_history_rows() -> int: + if not HISTORY_FILE.exists(): + return 0 + try: + with HISTORY_FILE.open("r", encoding="utf-8", errors="ignore") as fh: + return sum(1 for line in fh if line and line[0].isdigit()) + except OSError: + return 0 + + +def stats_status(current: dict[str, Any] | None = None, history: list[dict[str, int]] | None = None) -> dict[str, Any]: + current = current if current is not None else (load_json(CURRENT_STATS, {}) or {}) + history = history if history is not None else load_stats_history(limit=2) + service = service_status("gotelegram-stats") + now = int(time.time()) + ts = int(current.get("ts") or 0) if isinstance(current, dict) else 0 + age = max(0, now - ts) if ts else None + error = str(current.get("error") or "") if isinstance(current, dict) else "" + history_rows = count_history_rows() + if error: + health = "error" + elif service == "running" and current and age is not None and age <= 180: + health = "ok" + elif service == "running": + health = "stale" + elif service == "not_installed": + health = "not_installed" + else: + health = "stopped" + return { + "health": health, + "service": service, + "current_exists": CURRENT_STATS.exists(), + "history_exists": HISTORY_FILE.exists(), + "history_rows": history_rows, + "history_points": len(history or []), + "last_ts": ts, + "age_seconds": age, + "error": error, + } + + +def run_stats_action(action: str) -> tuple[bool, str, dict[str, Any]]: + if action == "repair": + body = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/stats.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "install_stats_collector; " + "stats_collect" + ) + timeout = 180 + else: + body = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/stats.sh; " + "stats_init >/dev/null 2>&1 || true; " + "stats_collect" + ) + timeout = 30 + code, stdout, stderr = run(["bash", "-lc", body], timeout=timeout) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + current = load_json(CURRENT_STATS, {}) or {} + history = load_stats_history() + return code == 0, message, {"current": current, "history": history, "status": stats_status(current, history)} + + def list_backups() -> list[dict[str, Any]]: if not BACKUP_DIR.exists(): return [] @@ -307,8 +387,10 @@ def user_payload(name: str, secret: str, include_runtime: bool = False) -> dict[ def overview_payload() -> dict[str, Any]: config = load_json(GOTELEGRAM_CONFIG, {}) or {} + language = read_language(config) users = read_telemt_users() current = load_json(CURRENT_STATS, {}) or {} + history = load_stats_history() summary = telemt_api("/v1/stats/summary") services = { "telemt": service_status("telemt"), @@ -320,11 +402,14 @@ def overview_payload() -> dict[str, Any]: return { "version": VERSION, "time": utc_now(), + "language": language, + "admin_bind": {"host": HOST, "port": PORT}, "config": config, "users_count": len(users), "services": services, "stats_current": current, - "stats_history": load_stats_history(), + "stats_history": history, + "stats_status": stats_status(current, history), "runtime_summary": summary, "backups": list_backups(), } @@ -378,6 +463,10 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_json({"ok": True, "data": user_payload(name, users[name], include_runtime=True)}) elif path == "/api/backups": self.send_json({"ok": True, "data": list_backups()}) + elif path == "/api/stats": + 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)}}) elif path == "/api/logs": qs = urllib.parse.parse_qs(parsed.query) service = qs.get("service", ["telemt"])[0] @@ -422,6 +511,14 @@ class AdminHandler(BaseHTTPRequestHandler): elif path == "/api/backups": ok, result = create_backup() self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) + elif path == "/api/stats/collect": + ok, message, payload = run_stats_action("collect") + payload["message"] = message + self.send_json({"ok": ok, "data": payload}, 200 if ok else 500) + elif path == "/api/stats/repair": + ok, message, payload = run_stats_action("repair") + payload["message"] = message + self.send_json({"ok": ok, "data": payload}, 200 if ok else 500) elif path.startswith("/api/services/") and path.endswith("/restart"): service = path[len("/api/services/"):-len("/restart")] allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats"} diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 9bb519a..29ef6d1 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -1,5 +1,263 @@ const $ = (sel) => document.querySelector(sel); -const state = { overview: null, users: [], events: [] }; +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +const i18n = { + en: { + brandSubtitle: "Local Admin", + navDashboard: "Dashboard", + navTraffic: "Traffic", + navKeys: "Keys", + navBackups: "Backups", + navLogs: "Logs", + navSettings: "Settings", + refresh: "Refresh", + themeDark: "Dark", + themeLight: "Light", + metricMode: "Mode", + metricKeys: "Keys", + metricProxyTraffic: "Proxy Traffic", + metricSiteTraffic: "Site Traffic", + configuredUsers: "configured users", + packets: "packets", + servicesEyebrow: "Services", + servicesTitle: "Runtime health", + runtimeEyebrow: "Runtime", + runtimeTitle: "telemt summary", + trafficEyebrow: "Traffic", + trafficTitle: "History", + keysEyebrow: "Access", + keysTitle: "User keys", + backupsEyebrow: "Snapshots", + backupsTitle: "Backups", + eventsEyebrow: "Events", + eventsTitle: "Activity", + logsEyebrow: "Journal", + logsTitle: "Logs", + settingsEyebrow: "Settings", + settingsTitle: "Panel preferences", + configEyebrow: "Config", + configTitle: "Installation state", + collector: "Collector", + lastPoint: "Last point", + historyRows: "History rows", + collectStats: "Collect", + repairStats: "Repair stats", + tableTime: "Time", + tableProxyDelta: "Proxy delta", + tableSiteDelta: "Site delta", + tableProxyTotal: "Proxy total", + tableSiteTotal: "Site total", + tableUser: "User", + tableSecret: "Secret", + tableLink: "Link", + tableActions: "Actions", + userPlaceholder: "client-name", + addKey: "Add key", + copyLink: "Copy link", + copySecret: "Copy secret", + delete: "Delete", + main: "main", + createBackup: "Create backup", + loadLogs: "Load", + panelLanguage: "Panel language", + theme: "Theme", + bindAddress: "Bind address", + dashboard: "Dashboard", + noKeys: "No keys yet", + noBackups: "No backups yet", + noEvents: "No events yet", + noHistory: "No traffic history yet", + noRuntime: "Runtime data is not available", + badConnections: "Bad connections", + connections: "Connections", + uptime: "Uptime", + users: "Users", + revision: "Revision", + healthOk: "OK", + healthError: "Error", + healthStale: "Stale", + healthStopped: "Stopped", + healthNotInstalled: "Not installed", + healthUnknown: "Unknown", + statusRunning: "running", + statusInactive: "inactive", + statusStopped: "stopped", + statusFailed: "failed", + statusNotInstalled: "not installed", + statusActivating: "activating", + statusDeactivating: "deactivating", + statusUnknown: "unknown", + statsMissing: "Collector is not running", + statsOk: "Collector is running", + statsStale: "Snapshot is stale", + statsError: "Collector error", + restart: "Restart", + copied: "Copied", + copyFailed: "Copy failed", + keyCreated: "Key created", + keyDeleted: "Key deleted", + backupCreated: "Backup created", + serviceRestarted: "Service restarted", + statsRepaired: "Statistics repaired", + statsCollected: "Statistics collected", + confirmDelete: "Delete key", + confirmRestart: "Restart", + invalidUser: "Use latin letters, digits, _, . or -", + loading: "Loading...", + never: "never", + lightTheme: "Light", + darkTheme: "Dark", + configMode: "Mode", + configDomain: "Domain", + configTemplate: "Template", + configVersion: "Version", + pageDashboardTitle: "Dashboard", + pageDashboardKicker: "Local Admin", + pageTrafficTitle: "Traffic", + pageTrafficKicker: "Statistics", + pageKeysTitle: "Keys", + pageKeysKicker: "Access", + pageBackupsTitle: "Backups", + pageBackupsKicker: "Migration", + pageLogsTitle: "Logs", + pageLogsKicker: "Journal", + pageSettingsTitle: "Settings", + pageSettingsKicker: "Preferences", + }, + ru: { + brandSubtitle: "Локальная админка", + navDashboard: "Обзор", + navTraffic: "Трафик", + navKeys: "Ключи", + navBackups: "Бекапы", + navLogs: "Логи", + navSettings: "Настройки", + refresh: "Обновить", + themeDark: "Тёмная", + themeLight: "Светлая", + metricMode: "Режим", + metricKeys: "Ключи", + metricProxyTraffic: "Трафик proxy", + metricSiteTraffic: "Трафик сайта", + configuredUsers: "настроенных пользователей", + packets: "пакетов", + servicesEyebrow: "Сервисы", + servicesTitle: "Состояние runtime", + runtimeEyebrow: "Runtime", + runtimeTitle: "сводка telemt", + trafficEyebrow: "Трафик", + trafficTitle: "История", + keysEyebrow: "Доступ", + keysTitle: "Ключи пользователей", + backupsEyebrow: "Снимки", + backupsTitle: "Бекапы", + eventsEyebrow: "События", + eventsTitle: "Активность", + logsEyebrow: "Журнал", + logsTitle: "Логи", + settingsEyebrow: "Настройки", + settingsTitle: "Параметры панели", + configEyebrow: "Конфиг", + configTitle: "Состояние установки", + collector: "Сборщик", + lastPoint: "Последняя точка", + historyRows: "Строк истории", + collectStats: "Собрать", + repairStats: "Починить статистику", + tableTime: "Время", + tableProxyDelta: "Proxy delta", + tableSiteDelta: "Site delta", + tableProxyTotal: "Proxy всего", + tableSiteTotal: "Site всего", + tableUser: "Пользователь", + tableSecret: "Secret", + tableLink: "Ссылка", + tableActions: "Действия", + userPlaceholder: "client-name", + addKey: "Добавить ключ", + copyLink: "Копировать ссылку", + copySecret: "Копировать secret", + delete: "Удалить", + main: "основной", + createBackup: "Создать бекап", + loadLogs: "Загрузить", + panelLanguage: "Язык панели", + theme: "Тема", + bindAddress: "Адрес bind", + dashboard: "Обзор", + noKeys: "Ключей пока нет", + noBackups: "Бекапов пока нет", + noEvents: "Событий пока нет", + noHistory: "Истории трафика пока нет", + noRuntime: "Runtime-данные недоступны", + badConnections: "Ошибочные подключения", + connections: "Подключения", + uptime: "Аптайм", + users: "Пользователи", + revision: "Ревизия", + healthOk: "OK", + healthError: "Ошибка", + healthStale: "Устарело", + healthStopped: "Остановлено", + healthNotInstalled: "Не установлен", + healthUnknown: "Неизвестно", + statusRunning: "работает", + statusInactive: "неактивен", + statusStopped: "остановлен", + statusFailed: "ошибка", + statusNotInstalled: "не установлен", + statusActivating: "запускается", + statusDeactivating: "останавливается", + statusUnknown: "неизвестно", + statsMissing: "Сборщик не запущен", + statsOk: "Сборщик работает", + statsStale: "Snapshot устарел", + statsError: "Ошибка сборщика", + restart: "Рестарт", + copied: "Скопировано", + copyFailed: "Не удалось скопировать", + keyCreated: "Ключ создан", + keyDeleted: "Ключ удалён", + backupCreated: "Бекап создан", + serviceRestarted: "Сервис перезапущен", + statsRepaired: "Статистика починена", + statsCollected: "Статистика собрана", + confirmDelete: "Удалить ключ", + confirmRestart: "Перезапустить", + invalidUser: "Используйте латиницу, цифры, _, . или -", + loading: "Загрузка...", + never: "никогда", + lightTheme: "Светлая", + darkTheme: "Тёмная", + configMode: "Режим", + configDomain: "Домен", + configTemplate: "Шаблон", + configVersion: "Версия", + pageDashboardTitle: "Обзор", + pageDashboardKicker: "Локальная админка", + pageTrafficTitle: "Трафик", + pageTrafficKicker: "Статистика", + pageKeysTitle: "Ключи", + pageKeysKicker: "Доступ", + pageBackupsTitle: "Бекапы", + pageBackupsKicker: "Переезд", + pageLogsTitle: "Логи", + pageLogsKicker: "Журнал", + pageSettingsTitle: "Настройки", + pageSettingsKicker: "Параметры", + }, +}; + +const state = { + overview: null, + users: [], + events: [], + lang: "en", + page: "dashboard", + theme: document.documentElement.dataset.theme || "light", +}; + +const t = (key) => (i18n[state.lang] && i18n[state.lang][key]) || i18n.en[key] || key; const fmtBytes = (value = 0) => { const units = ["B", "KB", "MB", "GB", "TB"]; @@ -12,19 +270,44 @@ const fmtBytes = (value = 0) => { return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; }; -const fmtDate = (epoch) => new Date(epoch * 1000).toLocaleString(); +const fmtDate = (epoch) => { + if (!epoch) return t("never"); + return new Date(epoch * 1000).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US"); +}; + +const fmtDuration = (seconds = 0) => { + let value = Math.max(0, Math.floor(Number(seconds) || 0)); + const days = Math.floor(value / 86400); + value %= 86400; + const hours = Math.floor(value / 3600); + value %= 3600; + const minutes = Math.floor(value / 60); + if (days) return `${days}d ${hours}h`; + if (hours) return `${hours}h ${minutes}m`; + return `${minutes}m`; +}; + +const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +})[ch]); + +const escapeAttr = (value) => escapeHtml(value).replace(/`/g, "`"); const toast = (message) => { const el = $("#toast"); el.textContent = message; el.classList.add("show"); clearTimeout(toast._timer); - toast._timer = setTimeout(() => el.classList.remove("show"), 2600); + toast._timer = setTimeout(() => el.classList.remove("show"), 2800); }; -const event = (title, detail = "") => { +const addEvent = (title, detail = "") => { state.events.unshift({ title, detail, time: new Date() }); - state.events = state.events.slice(0, 8); + state.events = state.events.slice(0, 10); renderEvents(); }; @@ -41,6 +324,70 @@ async function api(path, options = {}) { return data.data ?? data; } +function applyI18n() { + document.documentElement.lang = state.lang; + $$("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + $$("[data-i18n-placeholder]").forEach((el) => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + $("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark"); + $("#languageBadge").textContent = state.lang.toUpperCase(); + $("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English"; + $("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme"); + updatePageTitle(); +} + +function setTheme(theme) { + state.theme = theme === "dark" ? "dark" : "light"; + document.documentElement.dataset.theme = state.theme; + localStorage.setItem("gotelegram-theme", state.theme); + applyI18n(); + if (state.overview) drawTrafficChart(state.overview.stats_history || []); +} + +function setPage(page, push = true) { + const next = $(`[data-page="${page}"]`) ? page : "dashboard"; + state.page = next; + $$(".page-panel").forEach((panel) => panel.classList.toggle("active", panel.dataset.page === next)); + $$("[data-nav]").forEach((item) => item.classList.toggle("active", item.dataset.nav === next)); + $("#sidebar").classList.remove("open"); + updatePageTitle(); + if (push && location.hash !== `#${next}`) { + history.replaceState(null, "", `#${next}`); + } +} + +function updatePageTitle() { + const cap = state.page.charAt(0).toUpperCase() + state.page.slice(1); + $("#pageTitle").textContent = t(`page${cap}Title`); + $("#pageKicker").textContent = t(`page${cap}Kicker`); +} + +function updateLanguageFromOverview(data) { + const lang = String(data.language || data.config?.language || "en").toLowerCase(); + state.lang = lang === "ru" ? "ru" : "en"; + applyI18n(); +} + +function statusLabel(status) { + const key = `status${String(status || "unknown").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`; + const label = t(key); + return label === key ? (status || t("healthUnknown")) : label; +} + +function healthLabel(health) { + const labels = { + ok: t("healthOk"), + error: t("healthError"), + stale: t("healthStale"), + stopped: t("healthStopped"), + not_installed: t("healthNotInstalled"), + }; + return labels[health] || t("healthUnknown"); +} + function renderServices(services = {}) { const items = [ { key: "telemt", label: "telemt", api: "telemt" }, @@ -51,46 +398,163 @@ function renderServices(services = {}) { ]; $("#services").innerHTML = items.map((item) => { const status = services[item.key] || "unknown"; - return `
- ${item.label} -
${status}
- + const disabled = item.key === "admin" || status === "not_installed"; + return `
+
+ ${escapeHtml(item.label)} + ${escapeHtml(statusLabel(status))} +
+
`; }).join(""); } +function runtimeData() { + const raw = state.overview?.runtime_summary; + if (!raw || typeof raw !== "object") return null; + return raw.data && typeof raw.data === "object" ? raw.data : raw; +} + +function renderRuntime() { + const data = runtimeData(); + if (!data) { + $("#runtimeCards").innerHTML = `
${escapeHtml(t("noRuntime"))}
`; + $("#runtimeIssues").innerHTML = ""; + return; + } + const revision = String(data.revision || state.overview?.runtime_summary?.revision || "--"); + const cards = [ + [t("uptime"), fmtDuration(data.uptime_seconds)], + [t("connections"), data.connections_total ?? 0], + [t("badConnections"), data.connections_bad_total ?? 0], + [t("users"), data.configured_users ?? state.overview?.users_count ?? 0], + [t("revision"), revision.slice(0, 10)], + ]; + $("#runtimeCards").innerHTML = cards.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `).join(""); + const bad = Array.isArray(data.connections_bad_by_class) ? data.connections_bad_by_class : []; + $("#runtimeIssues").innerHTML = bad.length ? bad.map((item) => ` +
+ ${escapeHtml(item.class || "unknown")} + ${escapeHtml(item.total ?? 0)} +
+ `).join("") : ""; +} + function renderOverview() { const data = state.overview; if (!data) return; const cfg = data.config || {}; const stats = data.stats_current || {}; + const bind = data.admin_bind || {}; + $("#sidebarVersion").textContent = `v${data.version || "--"}`; + $("#sidebarBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; + $("#settingsBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; $("#metricMode").textContent = cfg.mode || "--"; $("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--"; $("#metricUsers").textContent = data.users_count ?? 0; $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); - $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} packets`; + $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`; $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); - $("#metricSitePackets").textContent = `${stats.site_pkts || 0} packets`; - $("#runtimeBox").textContent = JSON.stringify(data.runtime_summary || {}, null, 2); + $("#metricSitePackets").textContent = `${stats.site_pkts || 0} ${t("packets")}`; + $("#lastRefresh").textContent = fmtDate(Math.floor(Date.now() / 1000)); renderServices(data.services || {}); + renderRuntime(); + renderStats(); renderBackups(data.backups || []); - drawTrafficChart(data.stats_history || []); + renderConfig(); +} + +function renderStats() { + const status = state.overview?.stats_status || {}; + const stats = state.overview?.stats_current || {}; + const historyRows = state.overview?.stats_history || []; + $("#statsHealth").className = `status-pill health-${escapeAttr(status.health || "unknown")}`; + $("#statsHealth").textContent = healthLabel(status.health); + $("#collectorState").textContent = status.service ? statusLabel(status.service) : "--"; + $("#lastStatsPoint").textContent = status.last_ts ? fmtDate(status.last_ts) : t("never"); + $("#historyRows").textContent = status.history_rows ?? historyRows.length; + $("#repairStatsBtn").classList.toggle("attention", status.health !== "ok"); + $("#collectStatsBtn").disabled = status.service === "not_installed"; + $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); + $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); + drawTrafficChart(historyRows); + renderHistoryTable(historyRows); +} + +function drawTrafficChart(rows) { + const el = $("#trafficChart"); + const points = rows.slice(-120); + 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(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))} +
`; + return; + } + const width = 900; + const height = 300; + const pad = { l: 54, r: 22, t: 24, b: 42 }; + const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0))); + const plotW = width - pad.l - pad.r; + const plotH = height - pad.t - pad.b; + const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1); + const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH; + const pathFor = (key) => points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p[key]).toFixed(1)}`).join(" "); + const grid = Array.from({ length: 5 }, (_, i) => { + const y = pad.t + (plotH / 4) * i; + return ``; + }).join(""); + el.innerHTML = ` + ${grid} + + + + max ${escapeHtml(fmtBytes(max))}/min + proxy + site + `; +} + +function renderHistoryTable(rows) { + const latest = rows.slice(-12).reverse(); + if (!latest.length) { + $("#historyTable").innerHTML = `${escapeHtml(t("noHistory"))}`; + return; + } + $("#historyTable").innerHTML = latest.map((row) => ` + + ${escapeHtml(fmtDate(row.epoch))} + ${escapeHtml(fmtBytes(row.proxy_delta))} + ${escapeHtml(fmtBytes(row.site_delta))} + ${escapeHtml(fmtBytes(row.proxy_bytes))} + ${escapeHtml(fmtBytes(row.site_bytes))} + + `).join(""); } function renderUsers() { const tbody = $("#usersTable"); if (!state.users.length) { - tbody.innerHTML = `No keys yet`; + tbody.innerHTML = `${escapeHtml(t("noKeys"))}`; return; } tbody.innerHTML = state.users.map((user) => ` - ${escapeHtml(user.name)}${user.main ? " main" : ""} - ${escapeHtml(user.secret)} - - - - + + ${escapeHtml(user.name)}${user.main ? ` ${escapeHtml(t("main"))}` : ""} + + ${escapeHtml(user.secret)} + + + + `).join(""); @@ -99,22 +563,27 @@ function renderUsers() { function renderBackups(backups) { const box = $("#backupsList"); if (!backups.length) { - box.innerHTML = `
No backups
`; + box.innerHTML = `
${escapeHtml(t("noBackups"))}
`; return; } box.innerHTML = backups.map((item) => `
${escapeHtml(item.name)} - ${escapeHtml(item.path)} · ${fmtDate(item.mtime)} + ${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}
-
${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}
+
${escapeHtml(fmtBytes(item.size))}${item.encrypted ? " · encrypted" : ""}
`).join(""); } function renderEvents() { - $("#events").innerHTML = state.events.map((item) => ` + const box = $("#events"); + if (!state.events.length) { + box.innerHTML = `
${escapeHtml(t("noEvents"))}
`; + return; + } + box.innerHTML = state.events.map((item) => `
${escapeHtml(item.title)} ${escapeHtml(item.detail || item.time.toLocaleTimeString())} @@ -122,58 +591,21 @@ function renderEvents() { `).join(""); } -function drawTrafficChart(rows) { - const canvas = $("#trafficChart"); - const ctx = canvas.getContext("2d"); - const ratio = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = Math.max(320, rect.width) * ratio; - canvas.height = 260 * ratio; - ctx.setTransform(ratio, 0, 0, ratio, 0, 0); - const w = canvas.width / ratio; - const h = canvas.height / ratio; - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, w, h); - - const pad = { l: 48, r: 18, t: 20, b: 34 }; - const points = rows.length ? rows : [{ proxy_delta: 0, site_delta: 0 }, { proxy_delta: 0, site_delta: 0 }]; - const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0))); - const plotW = w - pad.l - pad.r; - const plotH = h - pad.t - pad.b; - - ctx.strokeStyle = "#dfe6f1"; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let i = 0; i <= 4; i += 1) { - const y = pad.t + (plotH / 4) * i; - ctx.moveTo(pad.l, y); - ctx.lineTo(w - pad.r, y); - } - ctx.stroke(); - - const line = (key, color) => { - ctx.strokeStyle = color; - ctx.lineWidth = 2.4; - ctx.beginPath(); - points.forEach((p, i) => { - const x = pad.l + (plotW * i) / Math.max(1, points.length - 1); - const y = pad.t + plotH - ((p[key] || 0) / max) * plotH; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.stroke(); - }; - line("proxy_delta", "#2563eb"); - line("site_delta", "#0f9f6e"); - - ctx.fillStyle = "#647087"; - ctx.font = "12px system-ui"; - ctx.fillText(`max ${fmtBytes(max)}/min`, pad.l, 14); - ctx.fillStyle = "#2563eb"; - ctx.fillText("proxy", pad.l, h - 10); - ctx.fillStyle = "#0f9f6e"; - ctx.fillText("site", pad.l + 58, h - 10); +function renderConfig() { + const cfg = state.overview?.config || {}; + const items = [ + [t("configMode"), cfg.mode || "--"], + [t("configDomain"), cfg.domain || cfg.mask_host || "--"], + [t("configTemplate"), cfg.template_id || cfg.template || "--"], + [t("configVersion"), state.overview?.version || "--"], + [t("bindAddress"), `${state.overview?.admin_bind?.host || "127.0.0.1"}:${state.overview?.admin_bind?.port || 1984}`], + ]; + $("#configList").innerHTML = items.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `).join(""); } async function refreshAll() { @@ -181,11 +613,12 @@ async function refreshAll() { btn.disabled = true; try { state.overview = await api("/api/overview"); + updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); renderOverview(); renderUsers(); } catch (err) { - if (err.message !== "Unauthorized") toast(err.message); + toast(err.message); } finally { btn.disabled = false; } @@ -196,15 +629,15 @@ async function addUser(name) { method: "POST", body: JSON.stringify({ name }), }); - event("Key created", data.name); - toast("Key created"); + addEvent(t("keyCreated"), data.name); + toast(t("keyCreated")); await refreshAll(); } async function deleteUser(name) { await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" }); - event("Key deleted", name); - toast("Key deleted"); + addEvent(t("keyDeleted"), name); + toast(t("keyDeleted")); await refreshAll(); } @@ -213,8 +646,8 @@ async function createBackup() { btn.disabled = true; try { const data = await api("/api/backups", { method: "POST", body: "{}" }); - event("Backup created", data.path || ""); - toast("Backup created"); + addEvent(t("backupCreated"), data.path || ""); + toast(t("backupCreated")); await refreshAll(); } catch (err) { toast(err.message); @@ -225,7 +658,7 @@ async function createBackup() { async function loadLogs() { const service = $("#logService").value; - $("#logsBox").textContent = "Loading..."; + $("#logsBox").textContent = t("loading"); try { $("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`); } catch (err) { @@ -235,36 +668,81 @@ async function loadLogs() { async function restartService(name) { await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" }); - event("Service restarted", name); - toast(`${name} restarted`); + addEvent(t("serviceRestarted"), name); + toast(`${name} ${t("serviceRestarted").toLowerCase()}`); await refreshAll(); } -function escapeHtml(value) { - return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'", - })[ch]); +async function repairStats() { + const btn = $("#repairStatsBtn"); + btn.disabled = true; + try { + await api("/api/stats/repair", { method: "POST", body: "{}" }); + addEvent(t("statsRepaired")); + toast(t("statsRepaired")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } } -function escapeAttr(value) { - return escapeHtml(value).replace(/`/g, "`"); +async function collectStats() { + const btn = $("#collectStatsBtn"); + btn.disabled = true; + try { + await api("/api/stats/collect", { method: "POST", body: "{}" }); + addEvent(t("statsCollected")); + toast(t("statsCollected")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + +async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + toast(t("copied")); + } catch (_) { + const area = document.createElement("textarea"); + area.value = value; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + const ok = document.execCommand("copy"); + area.remove(); + toast(ok ? t("copied") : t("copyFailed")); + } } document.addEventListener("click", async (eventObj) => { - const target = eventObj.target.closest("button"); - if (!target) return; + const nav = eventObj.target.closest("[data-nav]"); + if (nav) { + setPage(nav.dataset.nav); + return; + } - if (target.dataset.copy) { - await navigator.clipboard.writeText(target.dataset.copy); - toast("Copied"); - } - if (target.dataset.delete) { - const name = target.dataset.delete; - if (confirm(`Delete key ${name}?`)) deleteUser(name).catch((err) => toast(err.message)); - } - if (target.dataset.restart) { - const name = target.dataset.restart; - if (confirm(`Restart ${name}?`)) restartService(name).catch((err) => toast(err.message)); + const button = eventObj.target.closest("button"); + if (!button) return; + + if (button.id === "themeToggle") { + setTheme(state.theme === "dark" ? "light" : "dark"); + } else if (button.id === "menuBtn") { + $("#sidebar").classList.toggle("open"); + } else if (button.dataset.copy) { + await copyText(button.dataset.copy); + } else if (button.dataset.delete) { + const name = button.dataset.delete; + if (confirm(`${t("confirmDelete")} ${name}?`)) deleteUser(name).catch((err) => toast(err.message)); + } else if (button.dataset.restart) { + const name = button.dataset.restart; + if (confirm(`${t("confirmRestart")} ${name}?`)) restartService(name).catch((err) => toast(err.message)); } }); @@ -273,7 +751,7 @@ $("#addUserForm").addEventListener("submit", (eventObj) => { const input = $("#userName"); const name = input.value.trim(); if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) { - toast("Use latin letters, digits, _, . or -"); + toast(t("invalidUser")); return; } input.value = ""; @@ -283,14 +761,12 @@ $("#addUserForm").addEventListener("submit", (eventObj) => { $("#refreshBtn").addEventListener("click", refreshAll); $("#createBackupBtn").addEventListener("click", createBackup); $("#loadLogsBtn").addEventListener("click", loadLogs); -window.addEventListener("resize", () => state.overview && drawTrafficChart(state.overview.stats_history || [])); - -document.querySelectorAll("nav a").forEach((link) => { - link.addEventListener("click", () => { - document.querySelectorAll("nav a").forEach((item) => item.classList.remove("active")); - link.classList.add("active"); - }); -}); +$("#repairStatsBtn").addEventListener("click", repairStats); +$("#collectStatsBtn").addEventListener("click", collectStats); +window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false)); +setPage((location.hash || "#dashboard").slice(1), false); +setTheme(state.theme); +renderEvents(); refreshAll(); loadLogs(); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index a844338..a782325 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -1,147 +1,266 @@ - + GoTelegram Admin + -
-
diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 402aea9..1ed52d3 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -1,22 +1,50 @@ :root { color-scheme: light; - --bg: #f5f7fb; + --bg: #f4f7fb; --panel: #ffffff; - --panel-2: #f9fbff; - --text: #172033; - --muted: #647087; - --line: #dfe6f1; + --panel-soft: #f8fafd; + --panel-strong: #eef3fa; + --text: #111827; + --muted: #667085; + --line: #dde5ef; --blue: #2563eb; --green: #0f9f6e; --amber: #c77700; --red: #d92d20; - --ink: #0d172a; - --shadow: 0 18px 55px rgba(16, 24, 40, 0.08); + --violet: #7c3aed; + --sidebar: #111827; + --sidebar-text: #dbe5f2; + --sidebar-muted: #8fa1b8; + --button: #121926; + --button-text: #ffffff; + --shadow: 0 18px 50px rgba(15, 23, 42, .08); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #070b12; + --panel: #0f1724; + --panel-soft: #111c2d; + --panel-strong: #172235; + --text: #e7edf7; + --muted: #98a7bd; + --line: #263348; + --blue: #60a5fa; + --green: #34d399; + --amber: #fbbf24; + --red: #f87171; + --violet: #a78bfa; + --sidebar: #050810; + --sidebar-text: #eef4ff; + --sidebar-muted: #8d9bb1; + --button: #e7edf7; + --button-text: #0b1220; + --shadow: 0 18px 55px rgba(0, 0, 0, .34); } * { box-sizing: border-box; } -html { scroll-behavior: smooth; } +html { min-width: 320px; } body { margin: 0; @@ -26,44 +54,58 @@ body { font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -button, input, select { - font: inherit; -} +button, input, select { font: inherit; } button { + min-height: 40px; border: 0; border-radius: 8px; padding: 10px 14px; - background: var(--ink); - color: #fff; + background: var(--button); + color: var(--button-text); cursor: pointer; - transition: transform .18s ease, box-shadow .18s ease, background .18s ease; + transition: transform .16s ease, box-shadow .16s ease, background .16s ease, opacity .16s ease; } -button:hover { transform: translateY(-1px); box-shadow: 0 12px 25px rgba(16, 24, 40, .16); } -button:disabled { opacity: .55; cursor: wait; transform: none; box-shadow: none; } -button.ghost { background: #e8eef8; color: var(--ink); } -button.danger { background: #fee4e2; color: #b42318; } -button.soft { background: #eef4ff; color: #1d4ed8; } +button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(15, 23, 42, .16); } +button:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; } +button.ghost { background: var(--panel-strong); color: var(--text); } +button.soft { background: color-mix(in srgb, var(--blue) 12%, transparent); color: var(--blue); } +button.danger { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } +button.attention { background: var(--amber); color: #111827; } input, select { min-height: 42px; + min-width: 0; border: 1px solid var(--line); border-radius: 8px; - background: #fff; + background: var(--panel); color: var(--text); padding: 0 12px; outline: none; } input:focus, select:focus { - border-color: #8bb4ff; - box-shadow: 0 0 0 3px rgba(37, 99, 235, .12); + border-color: var(--blue); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 16%, transparent); } -.shell { +h1, h2, p { margin: 0; } + +h1 { + font-size: clamp(24px, 3vw, 34px); + line-height: 1.12; + letter-spacing: 0; +} + +h2 { + font-size: clamp(18px, 2vw, 22px); + line-height: 1.2; +} + +.app-shell { display: grid; - grid-template-columns: 248px minmax(0, 1fr); + grid-template-columns: 260px minmax(0, 1fr); min-height: 100vh; } @@ -71,21 +113,25 @@ input:focus, select:focus { position: sticky; top: 0; height: 100vh; + display: flex; + flex-direction: column; + gap: 26px; padding: 24px 18px; - background: #0f172a; - color: #e5edf8; + background: var(--sidebar); + color: var(--sidebar-text); } .brand { display: flex; - gap: 12px; align-items: center; - margin-bottom: 30px; + gap: 12px; + min-width: 0; } -.brand-mark, .mark { +.brand-mark { display: grid; place-items: center; + flex: 0 0 42px; width: 42px; height: 42px; border-radius: 8px; @@ -96,66 +142,143 @@ input:focus, select:focus { .brand span { display: block; - color: #93a4bc; + color: var(--sidebar-muted); font-size: 12px; } -nav { +.nav-tabs { display: grid; gap: 6px; } -nav a { - color: #b8c5d8; - text-decoration: none; - border-radius: 8px; - padding: 10px 12px; - transition: background .18s ease, color .18s ease; +.nav-item { + width: 100%; + justify-content: flex-start; + text-align: left; + background: transparent; + color: var(--sidebar-muted); + box-shadow: none; } -nav a:hover, nav a.active { - background: rgba(255,255,255,.08); - color: #fff; +.nav-item:hover, +.nav-item.active { + background: rgba(255, 255, 255, .08); + color: var(--sidebar-text); + box-shadow: none; } -main { - width: min(1400px, 100%); - padding: 26px; +.sidebar-foot { + margin-top: auto; + display: grid; + gap: 4px; + color: var(--sidebar-muted); + font-size: 12px; } -.topbar, .section-head { +.workspace { + min-width: 0; +} + +.topbar { + position: sticky; + top: 0; + z-index: 5; display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 22px 28px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 86%, transparent); + backdrop-filter: blur(16px); } -.topbar { - margin-bottom: 24px; +.title-block { + min-width: 0; } -h1, h2, p { - margin: 0; +.title-block small { + color: var(--muted); } -h1 { - font-size: 30px; - line-height: 1.15; +.top-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; } -h2 { - font-size: 20px; +.icon-btn { + width: 42px; + padding: 0; +} + +.mobile-only { + display: none; +} + +.pill, +.status-pill { + display: inline-flex; + align-items: center; + min-height: 32px; + border-radius: 8px; + padding: 6px 10px; + background: var(--panel-strong); + color: var(--text); + font-weight: 700; + font-size: 12px; +} + +.content { + width: min(1480px, 100%); + padding: 28px; +} + +.page-panel { + display: none; +} + +.page-panel.active { + display: grid; + gap: 18px; } .eyebrow { color: var(--muted); font-size: 12px; text-transform: uppercase; - font-weight: 700; + font-weight: 800; } -.section { - margin-bottom: 24px; +.panel, +.metric-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.panel { + padding: 18px; + min-width: 0; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.panel-actions, +.inline-form { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; } .metric-grid { @@ -164,109 +287,219 @@ h2 { gap: 14px; } -.metric-card, .chart-wrap, .table-wrap, .activity, .backup-list, .logs { - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: var(--shadow); -} - .metric-card { - min-height: 124px; + position: relative; + min-height: 126px; padding: 18px; + overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; } -.metric-card span, .metric-card small { +.metric-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: var(--blue); +} + +.metric-card.accent-green::before { background: var(--green); } +.metric-card.accent-violet::before { background: var(--violet); } +.metric-card.accent-amber::before { background: var(--amber); } + +.metric-card span, +.metric-card small { color: var(--muted); } .metric-card strong { - font-size: 28px; - line-height: 1.1; + min-width: 0; + overflow-wrap: anywhere; + font-size: clamp(24px, 3vw, 32px); + line-height: 1.05; +} + +.grid-two { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(320px, .9fr); + gap: 18px; } .service-grid { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(150px, 1fr)); gap: 12px; - margin-top: 14px; } .service { + display: grid; + gap: 12px; border: 1px solid var(--line); border-radius: 8px; - background: var(--panel-2); + background: var(--panel-soft); padding: 14px; } -.status { +.service strong { + display: block; + margin-bottom: 6px; +} + +.service span { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); } -.dot { +.service i { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); } -.running .dot { background: var(--green); } -.failed .dot, .not_installed .dot { background: var(--red); } -.inactive .dot, .stopped .dot { background: var(--amber); } +.status-running i { background: var(--green); } +.status-failed i { background: var(--red); } +.status-inactive i, +.status-stopped i, +.status-activating i, +.status-deactivating i { background: var(--amber); } +.status-not_installed i { background: var(--muted); } -.split { +.runtime-grid { display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - gap: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; } -.chart-wrap { - margin-top: 14px; - padding: 16px; -} - -.activity { - padding: 16px; - overflow: hidden; -} - -pre { - white-space: pre-wrap; - word-break: break-word; -} - -#runtimeBox, .logs { - max-height: 340px; - overflow: auto; - color: #344054; - background: #f7f9fc; +.runtime-grid article, +.traffic-summary article { + border: 1px solid var(--line); border-radius: 8px; + background: var(--panel-soft); padding: 12px; } -.inline-form { - display: flex; - align-items: center; +.runtime-grid span, +.traffic-summary span, +.settings-list span { + display: block; + color: var(--muted); + font-size: 12px; +} + +.runtime-grid strong, +.traffic-summary strong { + display: block; + margin-top: 3px; + overflow-wrap: anywhere; + font-size: 18px; +} + +.issue-list { + margin-top: 12px; + display: grid; gap: 8px; } +.issue { + display: flex; + justify-content: space-between; + gap: 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--amber) 12%, transparent); + color: var(--text); + padding: 10px 12px; +} + +.traffic-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.health-ok { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } +.health-error { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); } +.health-stale, +.health-stopped { background: color-mix(in srgb, var(--amber) 20%, transparent); color: var(--amber); } +.health-not_installed { background: var(--panel-strong); color: var(--muted); } + +.traffic-chart { + width: 100%; + min-height: 320px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + overflow: hidden; +} + +.traffic-chart svg { + display: block; + width: 100%; + height: auto; + min-height: 300px; +} + +.traffic-chart .grid line { + stroke: var(--line); + stroke-width: 1; +} + +.traffic-chart .line { + fill: none; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.traffic-chart .proxy-line { stroke: var(--blue); } +.traffic-chart .site-line { stroke: var(--green); } +.traffic-chart .proxy-area { + fill: color-mix(in srgb, var(--blue) 10%, transparent); +} + +.traffic-chart .axis, +.traffic-chart .legend { + fill: var(--muted); + font: 13px system-ui, sans-serif; +} + +.empty-chart, +.empty { + min-height: 160px; + display: grid; + place-items: center; + align-content: center; + gap: 6px; + color: var(--muted); + text-align: center; + padding: 18px; +} + +.empty-chart strong { + color: var(--text); +} + .table-wrap { margin-top: 14px; overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; } table { width: 100%; + min-width: 720px; border-collapse: collapse; + background: var(--panel); } -th, td { +th, +td { padding: 14px 16px; text-align: left; border-bottom: 1px solid var(--line); @@ -279,68 +512,85 @@ th { text-transform: uppercase; } +tr:last-child td { + border-bottom: 0; +} + td code { display: inline-block; - max-width: 280px; + max-width: 320px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; } +td small { + color: var(--muted); +} + .actions { display: flex; justify-content: flex-end; gap: 8px; } -.backup-list { - margin-top: 14px; - padding: 8px; -} - -.backup-item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - padding: 12px; - border-radius: 8px; -} - -.backup-item + .backup-item { - border-top: 1px solid var(--line); -} - -.backup-item span { - display: block; +.empty-cell { color: var(--muted); - font-size: 12px; + text-align: center; } -#events { +.backup-list, +.events-list, +.settings-list { display: grid; gap: 10px; } -.event { - padding: 10px 0; - border-bottom: 1px solid var(--line); +.backup-item, +.event, +.settings-list > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; } +.backup-item span, .event small { display: block; color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} + +.logs { + min-height: 460px; + max-height: calc(100vh - 260px); + overflow: auto; + margin: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + color: var(--text); + padding: 14px; + white-space: pre-wrap; + word-break: break-word; } .toast { position: fixed; right: 22px; bottom: 22px; + z-index: 20; min-width: 240px; - max-width: 420px; + max-width: min(420px, calc(100vw - 32px)); padding: 13px 14px; border-radius: 8px; - background: #0f172a; - color: white; + background: var(--button); + color: var(--button-text); opacity: 0; transform: translateY(10px); pointer-events: none; @@ -352,31 +602,170 @@ td code { transform: translateY(0); } -.hidden { - display: none; -} - -@media (max-width: 1040px) { - .shell { grid-template-columns: 1fr; } - .sidebar { - position: static; - height: auto; - display: flex; - align-items: center; - justify-content: space-between; +@media (max-width: 1280px) { + .service-grid { + grid-template-columns: repeat(3, minmax(150px, 1fr)); + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid-two { + grid-template-columns: 1fr; } - nav { display: flex; flex-wrap: wrap; } - .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .split { grid-template-columns: 1fr; } } -@media (max-width: 640px) { - main { padding: 18px; } - .sidebar { padding: 18px; align-items: flex-start; flex-direction: column; } - .metric-grid, .service-grid { grid-template-columns: 1fr; } - .topbar, .section-head { align-items: flex-start; flex-direction: column; } - .inline-form { width: 100%; flex-wrap: wrap; } - .inline-form input, .inline-form select { flex: 1 1 180px; } - .inline-form button { flex: 0 0 auto; } +@media (max-width: 980px) { + .app-shell { + grid-template-columns: 1fr; + } + + .mobile-only { + display: inline-grid; + } + + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 10; + width: min(300px, calc(100vw - 54px)); + transform: translateX(-105%); + transition: transform .2s ease; + } + + .sidebar.open { + transform: translateX(0); + } +} + +@media (max-width: 720px) { + .topbar { + align-items: flex-start; + padding: 16px; + } + + .top-actions { + width: 100%; + justify-content: flex-start; + } + + .content { + padding: 16px; + } + + .panel { + padding: 14px; + } + + .panel-head { + align-items: flex-start; + flex-direction: column; + } + + .metric-grid, + .service-grid, + .runtime-grid, + .traffic-summary { + grid-template-columns: 1fr; + } + + .inline-form, + .panel-actions { + width: 100%; + } + + .inline-form input, + .inline-form select, + .inline-form button, + .panel-actions button, + .panel-actions .status-pill { + width: 100%; + } + + .table-wrap { + overflow: visible; + border: 0; + } + + table, + thead, + tbody, + tr, + th, + td { + display: block; + min-width: 0; + } + + thead { + display: none; + } + + tr { + margin-bottom: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + overflow: hidden; + } + + td { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 12px; + border-bottom: 1px solid var(--line); + } + + td::before { + content: attr(data-label); + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + } + + td.empty-cell { + display: block; + } + + td.empty-cell::before { + display: none; + } + + td code { + max-width: 100%; + white-space: nowrap; + } + + .actions { + justify-content: stretch; + flex-wrap: wrap; + } + + .actions button { + flex: 1 1 140px; + } + + .backup-item, + .event, + .settings-list > div { + grid-template-columns: 1fr; + } +} + +@media (max-width: 460px) { + .topbar { + display: grid; + grid-template-columns: auto 1fr; + } + + .top-actions { + grid-column: 1 / -1; + } + + td { + grid-template-columns: 1fr; + gap: 4px; + } } diff --git a/gotelegram-bot/i18n.py b/gotelegram-bot/i18n.py index ede5fa4..60b94e1 100644 --- a/gotelegram-bot/i18n.py +++ b/gotelegram-bot/i18n.py @@ -24,12 +24,37 @@ logger = logging.getLogger(__name__) _MODULE_DIR = Path(__file__).resolve().parent LANG_DIR = _MODULE_DIR / "lang" USER_LANG_FILE = Path("/opt/gotelegram-bot/user_langs.json") +GOTELEGRAM_CONFIG = Path("/opt/gotelegram/config.json") +GOTELEGRAM_LANG_MARKER = Path("/opt/gotelegram/.language") # Supported codes; keep in sync with lang/*.json SUPPORTED_LANGS = ("en", "ru") -DEFAULT_LANG = os.getenv("BOT_LANG", "en").strip().lower() or "en" -if DEFAULT_LANG not in SUPPORTED_LANGS: - DEFAULT_LANG = "en" + + +def _detect_default_lang() -> str: + candidates = [] + try: + if GOTELEGRAM_CONFIG.exists(): + with open(GOTELEGRAM_CONFIG, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + candidates.extend([data.get("language"), data.get("lang")]) + except Exception as e: + logger.warning("failed to read GoTelegram language config: %s", e) + try: + if GOTELEGRAM_LANG_MARKER.exists(): + candidates.append(GOTELEGRAM_LANG_MARKER.read_text(encoding="utf-8").strip()[:2]) + except Exception as e: + logger.warning("failed to read GoTelegram language marker: %s", e) + candidates.append(os.getenv("BOT_LANG", "")) + for raw in candidates: + code = str(raw or "").strip().lower() + if code in SUPPORTED_LANGS: + return code + return "en" + + +DEFAULT_LANG = _detect_default_lang() LANG_NAMES = { "en": "English", diff --git a/install.sh b/install.sh index 53fd8d8..01ef69b 100755 --- a/install.sh +++ b/install.sh @@ -924,6 +924,9 @@ SVCEOF systemctl daemon-reload systemctl enable "$ADMIN_WEB_SERVICE" &>/dev/null systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE" + if type install_stats_collector &>/dev/null; then + install_stats_collector >/dev/null 2>&1 || log_warning "stats collector was not started; open Web Admin traffic page and use Repair" + fi log_success "Web admin installed: ${ADMIN_WEB_HOST}:${ADMIN_WEB_PORT}" } diff --git a/install_gotelegram_bot.sh b/install_gotelegram_bot.sh index 9287774..02b0415 100755 --- a/install_gotelegram_bot.sh +++ b/install_gotelegram_bot.sh @@ -41,6 +41,8 @@ fi # ── Каталог бота ───────────────────────────────────────────────────────────── mkdir -p "$BOT_DIR" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "$SCRIPT_DIR/lib/common.sh" ] && source "$SCRIPT_DIR/lib/common.sh" || true +[ -f "$SCRIPT_DIR/lib/stats.sh" ] && source "$SCRIPT_DIR/lib/stats.sh" || true if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then echo -e "${GREEN}[*] Копирование файлов бота...${NC}" @@ -146,6 +148,9 @@ EOF systemctl daemon-reload systemctl enable "$ADMIN_WEB_SERVICE" systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE" + if type install_stats_collector &>/dev/null; then + install_stats_collector >/dev/null 2>&1 || echo -e "${YELLOW}[!] Сборщик статистики не запущен; откройте Traffic в Web Admin и нажмите Repair.${NC}" + fi fi echo "" diff --git a/lib/stats.sh b/lib/stats.sh index f7bf753..0878597 100644 --- a/lib/stats.sh +++ b/lib/stats.sh @@ -397,7 +397,18 @@ EOF chmod 644 "$service_file" systemctl daemon-reload systemctl enable gotelegram-stats.service - systemctl start gotelegram-stats.service + systemctl restart gotelegram-stats.service + + if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then + local tmp + tmp=$(mktemp) + if jq '.stats_enabled = true' "$CONFIG_FILE" > "$tmp" 2>/dev/null; then + mv "$tmp" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" 2>/dev/null || true + else + rm -f "$tmp" 2>/dev/null + fi + fi echo "Сервис gotelegram-stats установлен и запущен" >&2 }