v2.5.0: redesign local admin and repair stats

This commit is contained in:
Виталий Литвинов
2026-04-24 22:30:09 +03:00
parent 008143a617
commit d9e4831e44
10 changed files with 1521 additions and 391 deletions

View File

@@ -442,15 +442,18 @@ switch_language ru|en
Локальная web-админка находится в `admin-web/` и устанавливается в `/opt/gotelegram-admin`: Локальная web-админка находится в `admin-web/` и устанавливается в `/opt/gotelegram-admin`:
- backend: `admin-web/server.py`, Python stdlib only, без pip/npm dependencies; - 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`; - systemd service: `gotelegram-admin`;
- bind: `127.0.0.1:1984`, доступ только через SSH tunnel; - bind: `127.0.0.1:1984`, доступ только через SSH tunnel;
- токена нет: после SSH tunnel открывается `http://127.0.0.1:1984/`; - токена нет: после SSH tunnel открывается `http://127.0.0.1:1984/`;
- write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически. - 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. Функции: 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) ### 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 ## 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.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.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`. - **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`.

View File

@@ -141,9 +141,11 @@ CLI и бот переведены на русский и английский.
- слушает только `127.0.0.1:1984`; - слушает только `127.0.0.1:1984`;
- наружу не публикуется и рассчитана на доступ через SSH tunnel; - наружу не публикуется и рассчитана на доступ через SSH tunnel;
- после туннеля открывается обычным URL `http://127.0.0.1:1984/`; - после туннеля открывается обычным 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`. - 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 (коротко) ## 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.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.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. - **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.

View File

@@ -69,6 +69,18 @@ def load_json(path: Path, fallback: Any = None) -> Any:
return fallback 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]: def read_telemt_users() -> dict[str, str]:
if not TELEMT_CONFIG.exists(): if not TELEMT_CONFIG.exists():
return {} return {}
@@ -257,6 +269,74 @@ def load_stats_history(limit: int = 240) -> list[dict[str, int]]:
return enriched 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]]: def list_backups() -> list[dict[str, Any]]:
if not BACKUP_DIR.exists(): if not BACKUP_DIR.exists():
return [] return []
@@ -307,8 +387,10 @@ def user_payload(name: str, secret: str, include_runtime: bool = False) -> dict[
def overview_payload() -> dict[str, Any]: def overview_payload() -> dict[str, Any]:
config = load_json(GOTELEGRAM_CONFIG, {}) or {} config = load_json(GOTELEGRAM_CONFIG, {}) or {}
language = read_language(config)
users = read_telemt_users() users = read_telemt_users()
current = load_json(CURRENT_STATS, {}) or {} current = load_json(CURRENT_STATS, {}) or {}
history = load_stats_history()
summary = telemt_api("/v1/stats/summary") summary = telemt_api("/v1/stats/summary")
services = { services = {
"telemt": service_status("telemt"), "telemt": service_status("telemt"),
@@ -320,11 +402,14 @@ def overview_payload() -> dict[str, Any]:
return { return {
"version": VERSION, "version": VERSION,
"time": utc_now(), "time": utc_now(),
"language": language,
"admin_bind": {"host": HOST, "port": PORT},
"config": config, "config": config,
"users_count": len(users), "users_count": len(users),
"services": services, "services": services,
"stats_current": current, "stats_current": current,
"stats_history": load_stats_history(), "stats_history": history,
"stats_status": stats_status(current, history),
"runtime_summary": summary, "runtime_summary": summary,
"backups": list_backups(), "backups": list_backups(),
} }
@@ -378,6 +463,10 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_json({"ok": True, "data": user_payload(name, users[name], include_runtime=True)}) self.send_json({"ok": True, "data": user_payload(name, users[name], include_runtime=True)})
elif path == "/api/backups": elif path == "/api/backups":
self.send_json({"ok": True, "data": list_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": elif path == "/api/logs":
qs = urllib.parse.parse_qs(parsed.query) qs = urllib.parse.parse_qs(parsed.query)
service = qs.get("service", ["telemt"])[0] service = qs.get("service", ["telemt"])[0]
@@ -422,6 +511,14 @@ class AdminHandler(BaseHTTPRequestHandler):
elif path == "/api/backups": elif path == "/api/backups":
ok, result = create_backup() ok, result = create_backup()
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) 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"): elif path.startswith("/api/services/") and path.endswith("/restart"):
service = path[len("/api/services/"):-len("/restart")] service = path[len("/api/services/"):-len("/restart")]
allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats"} allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats"}

View File

@@ -1,5 +1,263 @@
const $ = (sel) => document.querySelector(sel); 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 fmtBytes = (value = 0) => {
const units = ["B", "KB", "MB", "GB", "TB"]; const units = ["B", "KB", "MB", "GB", "TB"];
@@ -12,19 +270,44 @@ const fmtBytes = (value = 0) => {
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; 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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[ch]);
const escapeAttr = (value) => escapeHtml(value).replace(/`/g, "&#096;");
const toast = (message) => { const toast = (message) => {
const el = $("#toast"); const el = $("#toast");
el.textContent = message; el.textContent = message;
el.classList.add("show"); el.classList.add("show");
clearTimeout(toast._timer); 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.unshift({ title, detail, time: new Date() });
state.events = state.events.slice(0, 8); state.events = state.events.slice(0, 10);
renderEvents(); renderEvents();
}; };
@@ -41,6 +324,70 @@ async function api(path, options = {}) {
return data.data ?? data; 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 = {}) { function renderServices(services = {}) {
const items = [ const items = [
{ key: "telemt", label: "telemt", api: "telemt" }, { key: "telemt", label: "telemt", api: "telemt" },
@@ -51,46 +398,163 @@ function renderServices(services = {}) {
]; ];
$("#services").innerHTML = items.map((item) => { $("#services").innerHTML = items.map((item) => {
const status = services[item.key] || "unknown"; const status = services[item.key] || "unknown";
return `<article class="service ${status}"> const disabled = item.key === "admin" || status === "not_installed";
<strong>${item.label}</strong> return `<article class="service status-${escapeAttr(status)}">
<div class="status"><span class="dot"></span><span>${status}</span></div> <div>
<button class="soft" data-restart="${item.api}" ${item.key === "admin" ? "disabled" : ""}>Restart</button> <strong>${escapeHtml(item.label)}</strong>
<span><i></i>${escapeHtml(statusLabel(status))}</span>
</div>
<button class="soft" data-restart="${escapeAttr(item.api)}" ${disabled ? "disabled" : ""}>${escapeHtml(t("restart"))}</button>
</article>`; </article>`;
}).join(""); }).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 = `<div class="empty">${escapeHtml(t("noRuntime"))}</div>`;
$("#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]) => `
<article>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</article>
`).join("");
const bad = Array.isArray(data.connections_bad_by_class) ? data.connections_bad_by_class : [];
$("#runtimeIssues").innerHTML = bad.length ? bad.map((item) => `
<div class="issue">
<span>${escapeHtml(item.class || "unknown")}</span>
<strong>${escapeHtml(item.total ?? 0)}</strong>
</div>
`).join("") : "";
}
function renderOverview() { function renderOverview() {
const data = state.overview; const data = state.overview;
if (!data) return; if (!data) return;
const cfg = data.config || {}; const cfg = data.config || {};
const stats = data.stats_current || {}; 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 || "--"; $("#metricMode").textContent = cfg.mode || "--";
$("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--"; $("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--";
$("#metricUsers").textContent = data.users_count ?? 0; $("#metricUsers").textContent = data.users_count ?? 0;
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#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); $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
$("#metricSitePackets").textContent = `${stats.site_pkts || 0} packets`; $("#metricSitePackets").textContent = `${stats.site_pkts || 0} ${t("packets")}`;
$("#runtimeBox").textContent = JSON.stringify(data.runtime_summary || {}, null, 2); $("#lastRefresh").textContent = fmtDate(Math.floor(Date.now() / 1000));
renderServices(data.services || {}); renderServices(data.services || {});
renderRuntime();
renderStats();
renderBackups(data.backups || []); 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 = `<div class="empty-chart">
<strong>${escapeHtml(t("noHistory"))}</strong>
<span>${escapeHtml(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))}</span>
</div>`;
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 `<line x1="${pad.l}" y1="${y}" x2="${width - pad.r}" y2="${y}"></line>`;
}).join("");
el.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Traffic history">
<g class="grid">${grid}</g>
<path class="area proxy-area" d="${pathFor("proxy_delta")} L${width - pad.r},${height - pad.b} L${pad.l},${height - pad.b} Z"></path>
<path class="line proxy-line" d="${pathFor("proxy_delta")}"></path>
<path class="line site-line" d="${pathFor("site_delta")}"></path>
<text x="${pad.l}" y="17" class="axis">max ${escapeHtml(fmtBytes(max))}/min</text>
<text x="${pad.l}" y="${height - 12}" class="legend" fill="${proxyColor}">proxy</text>
<text x="${pad.l + 74}" y="${height - 12}" class="legend" fill="${siteColor}">site</text>
</svg>`;
}
function renderHistoryTable(rows) {
const latest = rows.slice(-12).reverse();
if (!latest.length) {
$("#historyTable").innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
return;
}
$("#historyTable").innerHTML = latest.map((row) => `
<tr>
<td data-label="${escapeAttr(t("tableTime"))}">${escapeHtml(fmtDate(row.epoch))}</td>
<td data-label="${escapeAttr(t("tableProxyDelta"))}">${escapeHtml(fmtBytes(row.proxy_delta))}</td>
<td data-label="${escapeAttr(t("tableSiteDelta"))}">${escapeHtml(fmtBytes(row.site_delta))}</td>
<td data-label="${escapeAttr(t("tableProxyTotal"))}">${escapeHtml(fmtBytes(row.proxy_bytes))}</td>
<td data-label="${escapeAttr(t("tableSiteTotal"))}">${escapeHtml(fmtBytes(row.site_bytes))}</td>
</tr>
`).join("");
} }
function renderUsers() { function renderUsers() {
const tbody = $("#usersTable"); const tbody = $("#usersTable");
if (!state.users.length) { if (!state.users.length) {
tbody.innerHTML = `<tr><td colspan="4">No keys yet</td></tr>`; tbody.innerHTML = `<tr><td colspan="4" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
return; return;
} }
tbody.innerHTML = state.users.map((user) => ` tbody.innerHTML = state.users.map((user) => `
<tr> <tr>
<td><strong>${escapeHtml(user.name)}</strong>${user.main ? " <small>main</small>" : ""}</td> <td data-label="${escapeAttr(t("tableUser"))}">
<td><code title="${escapeHtml(user.secret)}">${escapeHtml(user.secret)}</code></td> <strong>${escapeHtml(user.name)}</strong>${user.main ? ` <small>${escapeHtml(t("main"))}</small>` : ""}
<td><button class="soft" data-copy="${escapeAttr(user.link)}">Copy link</button></td> </td>
<td class="actions"> <td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
<button class="soft" data-copy="${escapeAttr(user.secret)}">Copy secret</button> <td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}">${escapeHtml(t("copyLink"))}</button></td>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>Delete</button> <td data-label="${escapeAttr(t("tableActions"))}" class="actions">
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
</td> </td>
</tr> </tr>
`).join(""); `).join("");
@@ -99,22 +563,27 @@ function renderUsers() {
function renderBackups(backups) { function renderBackups(backups) {
const box = $("#backupsList"); const box = $("#backupsList");
if (!backups.length) { if (!backups.length) {
box.innerHTML = `<div class="backup-item"><strong>No backups</strong><span></span></div>`; box.innerHTML = `<div class="empty">${escapeHtml(t("noBackups"))}</div>`;
return; return;
} }
box.innerHTML = backups.map((item) => ` box.innerHTML = backups.map((item) => `
<div class="backup-item"> <div class="backup-item">
<div> <div>
<strong>${escapeHtml(item.name)}</strong> <strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.path)} · ${fmtDate(item.mtime)}</span> <span>${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}</span>
</div> </div>
<div>${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}</div> <div>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? " · encrypted" : ""}</div>
</div> </div>
`).join(""); `).join("");
} }
function renderEvents() { function renderEvents() {
$("#events").innerHTML = state.events.map((item) => ` const box = $("#events");
if (!state.events.length) {
box.innerHTML = `<div class="empty">${escapeHtml(t("noEvents"))}</div>`;
return;
}
box.innerHTML = state.events.map((item) => `
<div class="event"> <div class="event">
<strong>${escapeHtml(item.title)}</strong> <strong>${escapeHtml(item.title)}</strong>
<small>${escapeHtml(item.detail || item.time.toLocaleTimeString())}</small> <small>${escapeHtml(item.detail || item.time.toLocaleTimeString())}</small>
@@ -122,58 +591,21 @@ function renderEvents() {
`).join(""); `).join("");
} }
function drawTrafficChart(rows) { function renderConfig() {
const canvas = $("#trafficChart"); const cfg = state.overview?.config || {};
const ctx = canvas.getContext("2d"); const items = [
const ratio = window.devicePixelRatio || 1; [t("configMode"), cfg.mode || "--"],
const rect = canvas.getBoundingClientRect(); [t("configDomain"), cfg.domain || cfg.mask_host || "--"],
canvas.width = Math.max(320, rect.width) * ratio; [t("configTemplate"), cfg.template_id || cfg.template || "--"],
canvas.height = 260 * ratio; [t("configVersion"), state.overview?.version || "--"],
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); [t("bindAddress"), `${state.overview?.admin_bind?.host || "127.0.0.1"}:${state.overview?.admin_bind?.port || 1984}`],
const w = canvas.width / ratio; ];
const h = canvas.height / ratio; $("#configList").innerHTML = items.map(([label, value]) => `
ctx.clearRect(0, 0, w, h); <div>
ctx.fillStyle = "#ffffff"; <span>${escapeHtml(label)}</span>
ctx.fillRect(0, 0, w, h); <strong>${escapeHtml(value)}</strong>
</div>
const pad = { l: 48, r: 18, t: 20, b: 34 }; `).join("");
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);
} }
async function refreshAll() { async function refreshAll() {
@@ -181,11 +613,12 @@ async function refreshAll() {
btn.disabled = true; btn.disabled = true;
try { try {
state.overview = await api("/api/overview"); state.overview = await api("/api/overview");
updateLanguageFromOverview(state.overview);
state.users = await api("/api/users"); state.users = await api("/api/users");
renderOverview(); renderOverview();
renderUsers(); renderUsers();
} catch (err) { } catch (err) {
if (err.message !== "Unauthorized") toast(err.message); toast(err.message);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -196,15 +629,15 @@ async function addUser(name) {
method: "POST", method: "POST",
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
event("Key created", data.name); addEvent(t("keyCreated"), data.name);
toast("Key created"); toast(t("keyCreated"));
await refreshAll(); await refreshAll();
} }
async function deleteUser(name) { async function deleteUser(name) {
await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" }); await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" });
event("Key deleted", name); addEvent(t("keyDeleted"), name);
toast("Key deleted"); toast(t("keyDeleted"));
await refreshAll(); await refreshAll();
} }
@@ -213,8 +646,8 @@ async function createBackup() {
btn.disabled = true; btn.disabled = true;
try { try {
const data = await api("/api/backups", { method: "POST", body: "{}" }); const data = await api("/api/backups", { method: "POST", body: "{}" });
event("Backup created", data.path || ""); addEvent(t("backupCreated"), data.path || "");
toast("Backup created"); toast(t("backupCreated"));
await refreshAll(); await refreshAll();
} catch (err) { } catch (err) {
toast(err.message); toast(err.message);
@@ -225,7 +658,7 @@ async function createBackup() {
async function loadLogs() { async function loadLogs() {
const service = $("#logService").value; const service = $("#logService").value;
$("#logsBox").textContent = "Loading..."; $("#logsBox").textContent = t("loading");
try { try {
$("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`); $("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`);
} catch (err) { } catch (err) {
@@ -235,36 +668,81 @@ async function loadLogs() {
async function restartService(name) { async function restartService(name) {
await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" }); await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" });
event("Service restarted", name); addEvent(t("serviceRestarted"), name);
toast(`${name} restarted`); toast(`${name} ${t("serviceRestarted").toLowerCase()}`);
await refreshAll(); await refreshAll();
} }
function escapeHtml(value) { async function repairStats() {
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ const btn = $("#repairStatsBtn");
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;", btn.disabled = true;
})[ch]); 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) { async function collectStats() {
return escapeHtml(value).replace(/`/g, "&#096;"); 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) => { document.addEventListener("click", async (eventObj) => {
const target = eventObj.target.closest("button"); const nav = eventObj.target.closest("[data-nav]");
if (!target) return; if (nav) {
setPage(nav.dataset.nav);
return;
}
if (target.dataset.copy) { const button = eventObj.target.closest("button");
await navigator.clipboard.writeText(target.dataset.copy); if (!button) return;
toast("Copied");
} if (button.id === "themeToggle") {
if (target.dataset.delete) { setTheme(state.theme === "dark" ? "light" : "dark");
const name = target.dataset.delete; } else if (button.id === "menuBtn") {
if (confirm(`Delete key ${name}?`)) deleteUser(name).catch((err) => toast(err.message)); $("#sidebar").classList.toggle("open");
} } else if (button.dataset.copy) {
if (target.dataset.restart) { await copyText(button.dataset.copy);
const name = target.dataset.restart; } else if (button.dataset.delete) {
if (confirm(`Restart ${name}?`)) restartService(name).catch((err) => toast(err.message)); 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 input = $("#userName");
const name = input.value.trim(); const name = input.value.trim();
if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) { if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) {
toast("Use latin letters, digits, _, . or -"); toast(t("invalidUser"));
return; return;
} }
input.value = ""; input.value = "";
@@ -283,14 +761,12 @@ $("#addUserForm").addEventListener("submit", (eventObj) => {
$("#refreshBtn").addEventListener("click", refreshAll); $("#refreshBtn").addEventListener("click", refreshAll);
$("#createBackupBtn").addEventListener("click", createBackup); $("#createBackupBtn").addEventListener("click", createBackup);
$("#loadLogsBtn").addEventListener("click", loadLogs); $("#loadLogsBtn").addEventListener("click", loadLogs);
window.addEventListener("resize", () => state.overview && drawTrafficChart(state.overview.stats_history || [])); $("#repairStatsBtn").addEventListener("click", repairStats);
$("#collectStatsBtn").addEventListener("click", collectStats);
document.querySelectorAll("nav a").forEach((link) => { window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false));
link.addEventListener("click", () => {
document.querySelectorAll("nav a").forEach((item) => item.classList.remove("active"));
link.classList.add("active");
});
});
setPage((location.hash || "#dashboard").slice(1), false);
setTheme(state.theme);
renderEvents();
refreshAll(); refreshAll();
loadLogs(); loadLogs();

View File

@@ -1,147 +1,266 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoTelegram Admin</title> <title>GoTelegram Admin</title>
<script>
(function () {
var stored = localStorage.getItem("gotelegram-theme");
var theme = stored || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.dataset.theme = theme;
}());
</script>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
</head> </head>
<body> <body>
<div class="shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar" id="sidebar">
<div class="brand"> <div class="brand">
<div class="brand-mark">GT</div> <div class="brand-mark">GT</div>
<div> <div>
<strong>GoTelegram</strong> <strong>GoTelegram</strong>
<span>Local Admin</span> <span data-i18n="brandSubtitle">Local Admin</span>
</div> </div>
</div> </div>
<nav>
<a href="#dashboard" class="active">Dashboard</a> <nav class="nav-tabs" aria-label="Admin sections">
<a href="#keys">Keys</a> <button type="button" class="nav-item active" data-nav="dashboard" data-i18n="navDashboard">Dashboard</button>
<a href="#traffic">Traffic</a> <button type="button" class="nav-item" data-nav="traffic" data-i18n="navTraffic">Traffic</button>
<a href="#backups">Backups</a> <button type="button" class="nav-item" data-nav="keys" data-i18n="navKeys">Keys</button>
<a href="#logs">Logs</a> <button type="button" class="nav-item" data-nav="backups" data-i18n="navBackups">Backups</button>
<button type="button" class="nav-item" data-nav="logs" data-i18n="navLogs">Logs</button>
<button type="button" class="nav-item" data-nav="settings" data-i18n="navSettings">Settings</button>
</nav> </nav>
<div class="sidebar-foot">
<span id="sidebarVersion">v--</span>
<span id="sidebarBind">127.0.0.1:1984</span>
</div>
</aside> </aside>
<main> <div class="workspace">
<header class="topbar"> <header class="topbar">
<div> <button id="menuBtn" class="icon-btn mobile-only" type="button" aria-label="Menu"></button>
<p class="eyebrow">127.0.0.1:1984</p> <div class="title-block">
<h1>GoTelegram Admin</h1> <p class="eyebrow" id="pageKicker">Local Admin</p>
<h1 id="pageTitle">Dashboard</h1>
<small id="lastRefresh">--</small>
</div>
<div class="top-actions">
<span class="pill" id="languageBadge">EN</span>
<button id="themeToggle" class="ghost" type="button">Theme</button>
<button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
</div> </div>
<button id="refreshBtn" class="ghost">Refresh</button>
</header> </header>
<section id="dashboard" class="section"> <main class="content">
<div class="metric-grid"> <section class="page-panel active" data-page="dashboard">
<article class="metric-card"> <div class="metric-grid">
<span>Mode</span> <article class="metric-card accent-blue">
<strong id="metricMode">--</strong> <span data-i18n="metricMode">Mode</span>
<small id="metricDomain">--</small> <strong id="metricMode">--</strong>
</article> <small id="metricDomain">--</small>
<article class="metric-card"> </article>
<span>Keys</span> <article class="metric-card accent-green">
<strong id="metricUsers">0</strong> <span data-i18n="metricKeys">Keys</span>
<small>configured users</small> <strong id="metricUsers">0</strong>
</article> <small data-i18n="configuredUsers">configured users</small>
<article class="metric-card"> </article>
<span>Proxy Traffic</span> <article class="metric-card accent-violet">
<strong id="metricProxyTraffic">0 B</strong> <span data-i18n="metricProxyTraffic">Proxy Traffic</span>
<small id="metricProxyPackets">0 packets</small> <strong id="metricProxyTraffic">0 B</strong>
</article> <small id="metricProxyPackets">0 packets</small>
<article class="metric-card"> </article>
<span>Site Traffic</span> <article class="metric-card accent-amber">
<strong id="metricSiteTraffic">0 B</strong> <span data-i18n="metricSiteTraffic">Site Traffic</span>
<small id="metricSitePackets">0 packets</small> <strong id="metricSiteTraffic">0 B</strong>
</article> <small id="metricSitePackets">0 packets</small>
</div> </article>
</div>
<div class="service-grid" id="services"></div> <div class="grid-two">
</section> <section class="panel">
<div class="panel-head">
<div>
<p class="eyebrow" data-i18n="servicesEyebrow">Services</p>
<h2 data-i18n="servicesTitle">Runtime health</h2>
</div>
</div>
<div class="service-grid" id="services"></div>
</section>
<section id="traffic" class="section split"> <section class="panel">
<div> <div class="panel-head">
<div class="section-head"> <div>
<div> <p class="eyebrow" data-i18n="runtimeEyebrow">Runtime</p>
<p class="eyebrow">Traffic</p> <h2 data-i18n="runtimeTitle">telemt summary</h2>
<h2>History</h2> </div>
</div>
<div id="runtimeCards" class="runtime-grid"></div>
<div id="runtimeIssues" class="issue-list"></div>
</section>
</div>
</section>
<section class="page-panel" data-page="traffic">
<div class="panel">
<div class="panel-head">
<div>
<p class="eyebrow" data-i18n="trafficEyebrow">Traffic</p>
<h2 data-i18n="trafficTitle">History</h2>
</div>
<div class="panel-actions">
<span id="statsHealth" class="status-pill">--</span>
<button id="collectStatsBtn" class="ghost" type="button" data-i18n="collectStats">Collect</button>
<button id="repairStatsBtn" type="button" data-i18n="repairStats">Repair stats</button>
</div>
</div>
<div class="traffic-summary">
<article>
<span data-i18n="collector">Collector</span>
<strong id="collectorState">--</strong>
</article>
<article>
<span data-i18n="lastPoint">Last point</span>
<strong id="lastStatsPoint">--</strong>
</article>
<article>
<span data-i18n="historyRows">History rows</span>
<strong id="historyRows">0</strong>
</article>
</div>
<div id="trafficChart" class="traffic-chart"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="tableTime">Time</th>
<th data-i18n="tableProxyDelta">Proxy delta</th>
<th data-i18n="tableSiteDelta">Site delta</th>
<th data-i18n="tableProxyTotal">Proxy total</th>
<th data-i18n="tableSiteTotal">Site total</th>
</tr>
</thead>
<tbody id="historyTable"></tbody>
</table>
</div> </div>
</div> </div>
<div class="chart-wrap"> </section>
<canvas id="trafficChart" height="260"></canvas>
</div>
</div>
<aside class="activity">
<p class="eyebrow">Runtime</p>
<pre id="runtimeBox">{}</pre>
</aside>
</section>
<section id="keys" class="section"> <section class="page-panel" data-page="keys">
<div class="section-head"> <div class="panel">
<div> <div class="panel-head">
<p class="eyebrow">Access</p> <div>
<h2>User keys</h2> <p class="eyebrow" data-i18n="keysEyebrow">Access</p>
</div> <h2 data-i18n="keysTitle">User keys</h2>
<form id="addUserForm" class="inline-form"> </div>
<input id="userName" autocomplete="off" placeholder="client-name"> <form id="addUserForm" class="inline-form">
<button type="submit">Add key</button> <input id="userName" autocomplete="off" placeholder="client-name" data-i18n-placeholder="userPlaceholder">
</form> <button type="submit" data-i18n="addKey">Add key</button>
</div> </form>
<div class="table-wrap"> </div>
<table> <div class="table-wrap">
<thead> <table>
<tr> <thead>
<th>User</th> <tr>
<th>Secret</th> <th data-i18n="tableUser">User</th>
<th>Link</th> <th data-i18n="tableSecret">Secret</th>
<th></th> <th data-i18n="tableLink">Link</th>
</tr> <th data-i18n="tableActions">Actions</th>
</thead> </tr>
<tbody id="usersTable"></tbody> </thead>
</table> <tbody id="usersTable"></tbody>
</div> </table>
</section>
<section id="backups" class="section split">
<div>
<div class="section-head">
<div>
<p class="eyebrow">Snapshots</p>
<h2>Backups</h2>
</div> </div>
<button id="createBackupBtn">Create backup</button>
</div> </div>
<div id="backupsList" class="backup-list"></div> </section>
</div>
<aside class="activity">
<p class="eyebrow">Events</p>
<div id="events"></div>
</aside>
</section>
<section id="logs" class="section"> <section class="page-panel" data-page="backups">
<div class="section-head"> <div class="grid-two">
<div> <section class="panel">
<p class="eyebrow">Journal</p> <div class="panel-head">
<h2>Logs</h2> <div>
<p class="eyebrow" data-i18n="backupsEyebrow">Snapshots</p>
<h2 data-i18n="backupsTitle">Backups</h2>
</div>
<button id="createBackupBtn" type="button" data-i18n="createBackup">Create backup</button>
</div>
<div id="backupsList" class="backup-list"></div>
</section>
<aside class="panel">
<div class="panel-head">
<div>
<p class="eyebrow" data-i18n="eventsEyebrow">Events</p>
<h2 data-i18n="eventsTitle">Activity</h2>
</div>
</div>
<div id="events" class="events-list"></div>
</aside>
</div> </div>
<div class="inline-form"> </section>
<select id="logService">
<option value="telemt">telemt</option> <section class="page-panel" data-page="logs">
<option value="nginx">nginx</option> <div class="panel">
<option value="gotelegram-bot">bot</option> <div class="panel-head">
<option value="gotelegram-stats">stats</option> <div>
<option value="gotelegram-admin">admin</option> <p class="eyebrow" data-i18n="logsEyebrow">Journal</p>
</select> <h2 data-i18n="logsTitle">Logs</h2>
<button id="loadLogsBtn" type="button">Load</button> </div>
<div class="inline-form">
<select id="logService">
<option value="telemt">telemt</option>
<option value="nginx">nginx</option>
<option value="gotelegram-bot">bot</option>
<option value="gotelegram-stats">stats</option>
<option value="gotelegram-admin">admin</option>
</select>
<button id="loadLogsBtn" type="button" data-i18n="loadLogs">Load</button>
</div>
</div>
<pre id="logsBox" class="logs"></pre>
</div> </div>
</div> </section>
<pre id="logsBox" class="logs"></pre>
</section> <section class="page-panel" data-page="settings">
</main> <div class="grid-two">
<section class="panel">
<div class="panel-head">
<div>
<p class="eyebrow" data-i18n="settingsEyebrow">Settings</p>
<h2 data-i18n="settingsTitle">Panel preferences</h2>
</div>
</div>
<div class="settings-list">
<div>
<span data-i18n="panelLanguage">Panel language</span>
<strong id="settingsLanguage">--</strong>
</div>
<div>
<span data-i18n="theme">Theme</span>
<strong id="settingsTheme">--</strong>
</div>
<div>
<span data-i18n="bindAddress">Bind address</span>
<strong id="settingsBind">127.0.0.1:1984</strong>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<p class="eyebrow" data-i18n="configEyebrow">Config</p>
<h2 data-i18n="configTitle">Installation state</h2>
</div>
</div>
<div id="configList" class="settings-list"></div>
</section>
</div>
</section>
</main>
</div>
</div> </div>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>

View File

@@ -1,22 +1,50 @@
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f5f7fb; --bg: #f4f7fb;
--panel: #ffffff; --panel: #ffffff;
--panel-2: #f9fbff; --panel-soft: #f8fafd;
--text: #172033; --panel-strong: #eef3fa;
--muted: #647087; --text: #111827;
--line: #dfe6f1; --muted: #667085;
--line: #dde5ef;
--blue: #2563eb; --blue: #2563eb;
--green: #0f9f6e; --green: #0f9f6e;
--amber: #c77700; --amber: #c77700;
--red: #d92d20; --red: #d92d20;
--ink: #0d172a; --violet: #7c3aed;
--shadow: 0 18px 55px rgba(16, 24, 40, 0.08); --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; } * { box-sizing: border-box; }
html { scroll-behavior: smooth; } html { min-width: 320px; }
body { body {
margin: 0; margin: 0;
@@ -26,44 +54,58 @@ body {
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
button, input, select { button, input, select { font: inherit; }
font: inherit;
}
button { button {
min-height: 40px;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
padding: 10px 14px; padding: 10px 14px;
background: var(--ink); background: var(--button);
color: #fff; color: var(--button-text);
cursor: pointer; 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:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(15, 23, 42, .16); }
button:disabled { opacity: .55; cursor: wait; transform: none; box-shadow: none; } button:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; }
button.ghost { background: #e8eef8; color: var(--ink); } button.ghost { background: var(--panel-strong); color: var(--text); }
button.danger { background: #fee4e2; color: #b42318; } button.soft { background: color-mix(in srgb, var(--blue) 12%, transparent); color: var(--blue); }
button.soft { background: #eef4ff; color: #1d4ed8; } button.danger { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
button.attention { background: var(--amber); color: #111827; }
input, select { input, select {
min-height: 42px; min-height: 42px;
min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--panel);
color: var(--text); color: var(--text);
padding: 0 12px; padding: 0 12px;
outline: none; outline: none;
} }
input:focus, select:focus { input:focus, select:focus {
border-color: #8bb4ff; border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(37, 99, 235, .12); 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; display: grid;
grid-template-columns: 248px minmax(0, 1fr); grid-template-columns: 260px minmax(0, 1fr);
min-height: 100vh; min-height: 100vh;
} }
@@ -71,21 +113,25 @@ input:focus, select:focus {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
gap: 26px;
padding: 24px 18px; padding: 24px 18px;
background: #0f172a; background: var(--sidebar);
color: #e5edf8; color: var(--sidebar-text);
} }
.brand { .brand {
display: flex; display: flex;
gap: 12px;
align-items: center; align-items: center;
margin-bottom: 30px; gap: 12px;
min-width: 0;
} }
.brand-mark, .mark { .brand-mark {
display: grid; display: grid;
place-items: center; place-items: center;
flex: 0 0 42px;
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 8px; border-radius: 8px;
@@ -96,66 +142,143 @@ input:focus, select:focus {
.brand span { .brand span {
display: block; display: block;
color: #93a4bc; color: var(--sidebar-muted);
font-size: 12px; font-size: 12px;
} }
nav { .nav-tabs {
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
nav a { .nav-item {
color: #b8c5d8; width: 100%;
text-decoration: none; justify-content: flex-start;
border-radius: 8px; text-align: left;
padding: 10px 12px; background: transparent;
transition: background .18s ease, color .18s ease; color: var(--sidebar-muted);
box-shadow: none;
} }
nav a:hover, nav a.active { .nav-item:hover,
background: rgba(255,255,255,.08); .nav-item.active {
color: #fff; background: rgba(255, 255, 255, .08);
color: var(--sidebar-text);
box-shadow: none;
} }
main { .sidebar-foot {
width: min(1400px, 100%); margin-top: auto;
padding: 26px; 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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; 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 { .title-block {
margin-bottom: 24px; min-width: 0;
} }
h1, h2, p { .title-block small {
margin: 0; color: var(--muted);
} }
h1 { .top-actions {
font-size: 30px; display: flex;
line-height: 1.15; align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
} }
h2 { .icon-btn {
font-size: 20px; 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 { .eyebrow {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 800;
} }
.section { .panel,
margin-bottom: 24px; .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 { .metric-grid {
@@ -164,109 +287,219 @@ h2 {
gap: 14px; 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 { .metric-card {
min-height: 124px; position: relative;
min-height: 126px;
padding: 18px; padding: 18px;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; 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); color: var(--muted);
} }
.metric-card strong { .metric-card strong {
font-size: 28px; min-width: 0;
line-height: 1.1; 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 { .service-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(150px, 1fr));
gap: 12px; gap: 12px;
margin-top: 14px;
} }
.service { .service {
display: grid;
gap: 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--panel-2); background: var(--panel-soft);
padding: 14px; padding: 14px;
} }
.status { .service strong {
display: block;
margin-bottom: 6px;
}
.service span {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: var(--muted); color: var(--muted);
} }
.dot { .service i {
width: 9px; width: 9px;
height: 9px; height: 9px;
border-radius: 50%; border-radius: 50%;
background: var(--muted); background: var(--muted);
} }
.running .dot { background: var(--green); } .status-running i { background: var(--green); }
.failed .dot, .not_installed .dot { background: var(--red); } .status-failed i { background: var(--red); }
.inactive .dot, .stopped .dot { background: var(--amber); } .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; display: grid;
grid-template-columns: minmax(0, 1fr) 360px; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 10px;
} }
.chart-wrap { .runtime-grid article,
margin-top: 14px; .traffic-summary article {
padding: 16px; border: 1px solid var(--line);
}
.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;
border-radius: 8px; border-radius: 8px;
background: var(--panel-soft);
padding: 12px; padding: 12px;
} }
.inline-form { .runtime-grid span,
display: flex; .traffic-summary span,
align-items: center; .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; 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 { .table-wrap {
margin-top: 14px; margin-top: 14px;
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
} }
table { table {
width: 100%; width: 100%;
min-width: 720px;
border-collapse: collapse; border-collapse: collapse;
background: var(--panel);
} }
th, td { th,
td {
padding: 14px 16px; padding: 14px 16px;
text-align: left; text-align: left;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -279,68 +512,85 @@ th {
text-transform: uppercase; text-transform: uppercase;
} }
tr:last-child td {
border-bottom: 0;
}
td code { td code {
display: inline-block; display: inline-block;
max-width: 280px; max-width: 320px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
vertical-align: middle; vertical-align: middle;
} }
td small {
color: var(--muted);
}
.actions { .actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
} }
.backup-list { .empty-cell {
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;
color: var(--muted); color: var(--muted);
font-size: 12px; text-align: center;
} }
#events { .backup-list,
.events-list,
.settings-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.event { .backup-item,
padding: 10px 0; .event,
border-bottom: 1px solid var(--line); .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 { .event small {
display: block; display: block;
color: var(--muted); 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 { .toast {
position: fixed; position: fixed;
right: 22px; right: 22px;
bottom: 22px; bottom: 22px;
z-index: 20;
min-width: 240px; min-width: 240px;
max-width: 420px; max-width: min(420px, calc(100vw - 32px));
padding: 13px 14px; padding: 13px 14px;
border-radius: 8px; border-radius: 8px;
background: #0f172a; background: var(--button);
color: white; color: var(--button-text);
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
pointer-events: none; pointer-events: none;
@@ -352,31 +602,170 @@ td code {
transform: translateY(0); transform: translateY(0);
} }
.hidden { @media (max-width: 1280px) {
display: none; .service-grid {
} grid-template-columns: repeat(3, minmax(150px, 1fr));
}
@media (max-width: 1040px) {
.shell { grid-template-columns: 1fr; } .metric-grid {
.sidebar { grid-template-columns: repeat(2, minmax(0, 1fr));
position: static; }
height: auto;
display: flex; .grid-two {
align-items: center; grid-template-columns: 1fr;
justify-content: space-between;
} }
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) { @media (max-width: 980px) {
main { padding: 18px; } .app-shell {
.sidebar { padding: 18px; align-items: flex-start; flex-direction: column; } grid-template-columns: 1fr;
.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; } .mobile-only {
.inline-form input, .inline-form select { flex: 1 1 180px; } display: inline-grid;
.inline-form button { flex: 0 0 auto; } }
.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;
}
} }

View File

@@ -24,12 +24,37 @@ logger = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).resolve().parent _MODULE_DIR = Path(__file__).resolve().parent
LANG_DIR = _MODULE_DIR / "lang" LANG_DIR = _MODULE_DIR / "lang"
USER_LANG_FILE = Path("/opt/gotelegram-bot/user_langs.json") 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 codes; keep in sync with lang/*.json
SUPPORTED_LANGS = ("en", "ru") 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 = { LANG_NAMES = {
"en": "English", "en": "English",

View File

@@ -924,6 +924,9 @@ SVCEOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$ADMIN_WEB_SERVICE" &>/dev/null systemctl enable "$ADMIN_WEB_SERVICE" &>/dev/null
systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$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 || 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}" log_success "Web admin installed: ${ADMIN_WEB_HOST}:${ADMIN_WEB_PORT}"
} }

View File

@@ -41,6 +41,8 @@ fi
# ── Каталог бота ───────────────────────────────────────────────────────────── # ── Каталог бота ─────────────────────────────────────────────────────────────
mkdir -p "$BOT_DIR" mkdir -p "$BOT_DIR"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then
echo -e "${GREEN}[*] Копирование файлов бота...${NC}" echo -e "${GREEN}[*] Копирование файлов бота...${NC}"
@@ -146,6 +148,9 @@ EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$ADMIN_WEB_SERVICE" systemctl enable "$ADMIN_WEB_SERVICE"
systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$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 fi
echo "" echo ""

View File

@@ -397,7 +397,18 @@ EOF
chmod 644 "$service_file" chmod 644 "$service_file"
systemctl daemon-reload systemctl daemon-reload
systemctl enable gotelegram-stats.service 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 echo "Сервис gotelegram-stats установлен и запущен" >&2
} }