diff --git a/DOCS_AI.md b/DOCS_AI.md index 6d4eec0..152c540 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -447,11 +447,11 @@ switch_language ru|en - 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; +- язык панели читается из `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`), есть light/dark theme в `localStorage`; -- `/api/overview` отдаёт `stats_status` и `admin_bind`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`. +- `/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`. -Функции: 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. +Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, генерация 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`). `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`. @@ -629,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 токена, вкладочной 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.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 от языка установки, ручным переключателем 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.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 9466500..ff50d27 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -141,11 +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-боте; +- язык берётся из `config.json.language` / `/opt/gotelegram/.language`, как в CLI и Telegram-боте; в верхней панели можно переключить RU/EN, выбор сохраняется в общий конфиг; - есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile; - Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`. -В админке есть dashboard, статус сервисов, управление `[access.users]`, генерация ссылок, SVG-график traffic history, кнопка восстановления сборщика статистики, список бекапов и просмотр логов. +В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, управление `[access.users]`, генерация ссылок, SVG-график traffic history, кнопка восстановления сборщика статистики, список бекапов и просмотр логов с количеством строк и статусом `journalctl`. --- @@ -238,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 без отдельного токена, с вкладками, 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.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 от языка установки, ручным переключателем 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.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 8ba309f..a6ee484 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -36,11 +36,13 @@ HISTORY_FILE = Path(os.getenv("GOTELEGRAM_STATS_HISTORY", "/opt/gotelegram/stats CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json")) 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")) HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) VERSION = "2.5.0" USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") +LANG_RE = re.compile(r"^(en|ru)$") def utc_now() -> str: @@ -69,6 +71,14 @@ def load_json(path: Path, fallback: Any = None) -> Any: return fallback +def save_json(path: Path, data: Any, mode: int = 0o600) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=4) + "\n", encoding="utf-8") + os.chmod(tmp, mode) + tmp.replace(path) + + 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() @@ -81,6 +91,36 @@ def read_language(config: dict[str, Any] | None = None) -> str: return lang if lang in {"en", "ru"} else "en" +def write_language(lang: str) -> dict[str, Any]: + lang = str(lang or "").strip().lower() + if not LANG_RE.match(lang): + raise ValueError("unsupported language") + config = load_json(GOTELEGRAM_CONFIG, {}) or {} + if not isinstance(config, dict): + config = {} + config["language"] = lang + config["updated_at"] = utc_now() + save_json(GOTELEGRAM_CONFIG, config) + INSTALL_DIR.mkdir(parents=True, exist_ok=True) + (INSTALL_DIR / ".language").write_text(lang + "\n", encoding="utf-8") + bot_env = BOT_DIR / ".env" + if bot_env.exists(): + lines = bot_env.read_text(encoding="utf-8", errors="ignore").splitlines() + found = False + out = [] + for line in lines: + if line.startswith("BOT_LANG="): + out.append(f"BOT_LANG={lang}") + found = True + else: + out.append(line) + if not found: + out.append(f"BOT_LANG={lang}") + bot_env.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + os.chmod(bot_env, 0o600) + return {"language": lang, "config": config} + + def read_telemt_users() -> dict[str, str]: if not TELEMT_CONFIG.exists(): return {} @@ -236,6 +276,31 @@ def telemt_api(path: str) -> Any: return None +def site_status(config: dict[str, Any] | None = None) -> dict[str, Any]: + config = config or load_json(GOTELEGRAM_CONFIG, {}) or {} + host = str(config.get("domain") or "").strip() + if not host: + return {"host": "", "url": "", "http_code": 0, "ok": False, "checked": False, "error": "domain_missing"} + if not re.match(r"^[A-Za-z0-9.-]{1,253}$", host) or ".." in host or host.startswith(".") or host.endswith("."): + return {"host": host, "url": "", "http_code": 0, "ok": False, "checked": False, "error": "invalid_domain"} + url = f"https://{host}/" + code, stdout, stderr = run(["curl", "-k", "-L", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "8", url], timeout=10) + raw_code = stdout.strip() + try: + http_code = int(raw_code) + except ValueError: + http_code = 0 + return { + "host": host, + "url": url, + "http_code": http_code, + "ok": code == 0 and http_code == 200, + "checked": True, + "error": "" if code == 0 else (stderr.strip() or f"curl exit {code}"), + "checked_at": int(time.time()), + } + + def load_stats_history(limit: int = 240) -> list[dict[str, int]]: if not HISTORY_FILE.exists(): return [] @@ -373,6 +438,25 @@ def create_backup() -> tuple[bool, str]: return code == 0, text +def read_log_payload(service: str) -> dict[str, Any]: + allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"} + if service not in allowed: + raise ValueError("unsupported service") + code, stdout, stderr = run(["journalctl", "-u", service, "-n", "180", "--no-pager", "-o", "short-iso"], timeout=10) + text = stdout if code == 0 else stderr + lines = text.splitlines() + if code == 0 and not lines: + text = f"No journal entries for {service}." + lines = [text] + return { + "service": service, + "ok": code == 0, + "exit_code": code, + "line_count": len(lines), + "text": text, + } + + def user_payload(name: str, secret: str, include_runtime: bool = False) -> dict[str, Any]: item: dict[str, Any] = { "name": name, @@ -405,6 +489,7 @@ def overview_payload() -> dict[str, Any]: "language": language, "admin_bind": {"host": HOST, "port": PORT}, "config": config, + "site_status": site_status(config), "users_count": len(users), "services": services, "stats_current": current, @@ -467,15 +552,17 @@ class AdminHandler(BaseHTTPRequestHandler): 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/site/check": + self.send_json({"ok": True, "data": site_status()}) elif path == "/api/logs": qs = urllib.parse.parse_qs(parsed.query) service = qs.get("service", ["telemt"])[0] - allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"} - if service not in allowed: + try: + payload = read_log_payload(service) + except ValueError: self.send_error_json(400, "unsupported service") return - code, stdout, stderr = run(["journalctl", "-u", service, "-n", "120", "--no-pager"], timeout=8) - self.send_json({"ok": code == 0, "data": stdout if code == 0 else stderr}) + self.send_json({"ok": True, "data": payload}) else: self.send_error_json(404, "not found") @@ -519,6 +606,13 @@ class AdminHandler(BaseHTTPRequestHandler): ok, message, payload = run_stats_action("repair") payload["message"] = message self.send_json({"ok": ok, "data": payload}, 200 if ok else 500) + elif path == "/api/settings/language": + try: + lang_payload = write_language(str(body.get("language", ""))) + except Exception as exc: + self.send_error_json(400, str(exc)) + return + self.send_json({"ok": True, "data": lang_payload}) 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 9166ee3..87a05c4 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -109,8 +109,18 @@ const i18n = { darkTheme: "Dark", configMode: "Mode", configDomain: "Domain", + configSiteStatus: "Site check", configTemplate: "Template", configVersion: "Version", + siteOk: "Site 200 OK", + siteHttp: "Site HTTP", + siteMissing: "Domain is not configured", + siteInvalid: "Invalid domain", + siteError: "Site check failed", + siteNotChecked: "Site check pending", + logsLines: "lines", + logsNoData: "No log lines", + languageSaved: "Language saved", pageDashboardTitle: "Dashboard", pageDashboardKicker: "Local Admin", pageTrafficTitle: "Traffic", @@ -231,8 +241,18 @@ const i18n = { darkTheme: "Тёмная", configMode: "Режим", configDomain: "Домен", + configSiteStatus: "Проверка сайта", configTemplate: "Шаблон", configVersion: "Версия", + siteOk: "Сайт 200 OK", + siteHttp: "Сайт HTTP", + siteMissing: "Домен не настроен", + siteInvalid: "Некорректный домен", + siteError: "Проверка сайта не прошла", + siteNotChecked: "Проверка сайта ожидает", + logsLines: "строк", + logsNoData: "Строк логов нет", + languageSaved: "Язык сохранён", pageDashboardTitle: "Обзор", pageDashboardKicker: "Локальная админка", pageTrafficTitle: "Трафик", @@ -333,7 +353,7 @@ function applyI18n() { el.placeholder = t(el.dataset.i18nPlaceholder); }); $("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark"); - $("#languageBadge").textContent = state.lang.toUpperCase(); + $("#languageSelect").value = state.lang; $("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English"; $("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme"); updatePageTitle(); @@ -347,6 +367,26 @@ function setTheme(theme) { if (state.overview) drawTrafficChart(state.overview.stats_history || []); } +async function setLanguage(lang) { + const previous = state.lang; + state.lang = lang === "ru" ? "ru" : "en"; + applyI18n(); + try { + const data = await api("/api/settings/language", { + method: "POST", + body: JSON.stringify({ language: state.lang }), + }); + state.lang = data.language === "ru" ? "ru" : "en"; + applyI18n(); + toast(t("languageSaved")); + await refreshAll(); + } catch (err) { + state.lang = previous; + applyI18n(); + toast(err.message); + } +} + function setPage(page, push = true) { const next = $(`[data-page="${page}"]`) ? page : "dashboard"; state.page = next; @@ -445,6 +485,31 @@ function renderRuntime() { `).join("") : ""; } +function siteStatusText(site = {}) { + if (!site.host) return t("siteMissing"); + if (site.error === "invalid_domain") return t("siteInvalid"); + if (site.ok) return t("siteOk"); + if (site.checked && site.http_code) return `${t("siteHttp")} ${site.http_code}`; + if (site.error) return t("siteError"); + return t("siteNotChecked"); +} + +function siteStatusClass(site = {}) { + if (site.ok) return "ok"; + if (!site.host || !site.checked) return "warn"; + return "error"; +} + +function renderSiteStatus() { + const cfg = state.overview?.config || {}; + const site = state.overview?.site_status || {}; + $("#metricDomain").textContent = site.host || cfg.domain || cfg.mask_host || "--"; + const statusEl = $("#siteStatus"); + statusEl.textContent = siteStatusText(site); + statusEl.className = `metric-status ${siteStatusClass(site)}`; + statusEl.title = site.url || ""; +} + function renderOverview() { const data = state.overview; if (!data) return; @@ -455,7 +520,7 @@ function renderOverview() { $("#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 || "--"; + renderSiteStatus(); $("#metricUsers").textContent = data.users_count ?? 0; $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`; @@ -593,9 +658,11 @@ function renderEvents() { function renderConfig() { const cfg = state.overview?.config || {}; + const site = state.overview?.site_status || {}; const items = [ [t("configMode"), cfg.mode || "--"], [t("configDomain"), cfg.domain || cfg.mask_host || "--"], + [t("configSiteStatus"), siteStatusText(site)], [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}`], @@ -660,13 +727,20 @@ async function loadLogs() { const service = $("#logService").value; const btn = $("#loadLogsBtn"); btn.disabled = true; + $("#logsMeta").textContent = ""; $("#logsBox").textContent = t("loading"); try { - const logs = await api(`/api/logs?service=${encodeURIComponent(service)}`); + const payload = await api(`/api/logs?service=${encodeURIComponent(service)}`); if ($("#logService").value === service) { - $("#logsBox").textContent = logs; + const structured = payload && typeof payload === "object"; + const text = typeof payload === "string" ? payload : (payload?.text || ""); + const lines = structured ? (payload.line_count ?? text.split("\n").filter(Boolean).length) : text.split("\n").filter(Boolean).length; + const stateText = structured ? (payload.ok ? "OK" : `exit ${payload.exit_code ?? "?"}`) : "OK"; + $("#logsMeta").textContent = `${service} · ${lines} ${t("logsLines")} · ${stateText}`; + $("#logsBox").textContent = text || t("logsNoData"); } } catch (err) { + $("#logsMeta").textContent = ""; $("#logsBox").textContent = err.message; } finally { btn.disabled = false; @@ -766,6 +840,7 @@ $("#addUserForm").addEventListener("submit", (eventObj) => { }); $("#refreshBtn").addEventListener("click", refreshAll); +$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value)); $("#createBackupBtn").addEventListener("click", createBackup); $("#loadLogsBtn").addEventListener("click", loadLogs); $("#repairStatsBtn").addEventListener("click", repairStats); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index e274c4e..7aa7311 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -11,7 +11,7 @@ document.documentElement.dataset.theme = theme; }()); - +
@@ -48,7 +48,10 @@ --
- EN +
@@ -61,6 +64,7 @@ Mode -- -- + --
Keys @@ -219,6 +223,7 @@ +

           
         
@@ -264,6 +269,6 @@
   
 
   
- + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 1ed52d3..4245ddb 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -209,6 +209,12 @@ h2 { flex-wrap: wrap; } +.language-select { + width: 78px; + min-width: 78px; + font-weight: 800; +} + .icon-btn { width: 42px; padding: 0; @@ -321,6 +327,33 @@ h2 { line-height: 1.05; } +.metric-status { + display: inline-flex; + align-items: center; + width: fit-content; + margin-top: 7px; + border-radius: 8px; + padding: 4px 8px; + background: var(--panel-strong); + font-size: 12px; + font-weight: 800; +} + +.metric-status.ok { + background: color-mix(in srgb, var(--green) 16%, transparent); + color: var(--green); +} + +.metric-status.warn { + background: color-mix(in srgb, var(--amber) 18%, transparent); + color: var(--amber); +} + +.metric-status.error { + background: color-mix(in srgb, var(--red) 16%, transparent); + color: var(--red); +} + .grid-two { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(320px, .9fr); @@ -566,6 +599,14 @@ td small { overflow-wrap: anywhere; } +.logs-meta { + min-height: 24px; + margin: -2px 0 8px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + .logs { min-height: 460px; max-height: calc(100vh - 260px); diff --git a/gotelegram-bot/i18n.py b/gotelegram-bot/i18n.py index 60b94e1..30bff0f 100644 --- a/gotelegram-bot/i18n.py +++ b/gotelegram-bot/i18n.py @@ -125,8 +125,8 @@ def get_user_lang(user_id: Optional[int]) -> str: if not _USER_LANGS_LOADED: _load_user_langs() if user_id is None: - return DEFAULT_LANG - return _USER_LANGS.get(int(user_id), DEFAULT_LANG) + return _detect_default_lang() + return _USER_LANGS.get(int(user_id), _detect_default_lang()) def set_user_lang(user_id: int, code: str) -> bool: