mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-23 12:16:03 +00:00
v2.5.0: improve admin domain language logs
This commit is contained in:
@@ -447,11 +447,11 @@ switch_language ru|en
|
|||||||
- 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;
|
- язык панели читается из `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`;
|
- 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`.
|
`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
|
## 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.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`.
|
||||||
|
|||||||
@@ -141,11 +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-боте;
|
- язык берётся из `config.json.language` / `/opt/gotelegram/.language`, как в CLI и Telegram-боте; в верхней панели можно переключить RU/EN, выбор сохраняется в общий конфиг;
|
||||||
- есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile;
|
- есть светлая/тёмная тема, вкладки и адаптивная вёрстка под 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]`, генерация ссылок, SVG-график traffic history, кнопка восстановления сборщика статистики, список бекапов и просмотр логов.
|
В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, управление `[access.users]`, генерация ссылок, SVG-график traffic history, кнопка восстановления сборщика статистики, список бекапов и просмотр логов с количеством строк и статусом `journalctl`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -238,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 без отдельного токена, с вкладками, 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.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.
|
||||||
|
|||||||
@@ -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"))
|
CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json"))
|
||||||
BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups"))
|
BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups"))
|
||||||
INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram"))
|
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")
|
HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
|
||||||
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
|
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
|
||||||
VERSION = "2.5.0"
|
VERSION = "2.5.0"
|
||||||
USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
||||||
|
LANG_RE = re.compile(r"^(en|ru)$")
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -69,6 +71,14 @@ def load_json(path: Path, fallback: Any = None) -> Any:
|
|||||||
return fallback
|
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:
|
def read_language(config: dict[str, Any] | None = None) -> str:
|
||||||
config = config or load_json(GOTELEGRAM_CONFIG, {}) or {}
|
config = config or load_json(GOTELEGRAM_CONFIG, {}) or {}
|
||||||
lang = str(config.get("language") or config.get("lang") or "").strip().lower()
|
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"
|
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]:
|
def read_telemt_users() -> dict[str, str]:
|
||||||
if not TELEMT_CONFIG.exists():
|
if not TELEMT_CONFIG.exists():
|
||||||
return {}
|
return {}
|
||||||
@@ -236,6 +276,31 @@ def telemt_api(path: str) -> Any:
|
|||||||
return None
|
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]]:
|
def load_stats_history(limit: int = 240) -> list[dict[str, int]]:
|
||||||
if not HISTORY_FILE.exists():
|
if not HISTORY_FILE.exists():
|
||||||
return []
|
return []
|
||||||
@@ -373,6 +438,25 @@ def create_backup() -> tuple[bool, str]:
|
|||||||
return code == 0, text
|
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]:
|
def user_payload(name: str, secret: str, include_runtime: bool = False) -> dict[str, Any]:
|
||||||
item: dict[str, Any] = {
|
item: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -405,6 +489,7 @@ def overview_payload() -> dict[str, Any]:
|
|||||||
"language": language,
|
"language": language,
|
||||||
"admin_bind": {"host": HOST, "port": PORT},
|
"admin_bind": {"host": HOST, "port": PORT},
|
||||||
"config": config,
|
"config": config,
|
||||||
|
"site_status": site_status(config),
|
||||||
"users_count": len(users),
|
"users_count": len(users),
|
||||||
"services": services,
|
"services": services,
|
||||||
"stats_current": current,
|
"stats_current": current,
|
||||||
@@ -467,15 +552,17 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
current = load_json(CURRENT_STATS, {}) or {}
|
current = load_json(CURRENT_STATS, {}) or {}
|
||||||
history = load_stats_history()
|
history = load_stats_history()
|
||||||
self.send_json({"ok": True, "data": {"current": current, "history": history, "status": stats_status(current, 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":
|
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]
|
||||||
allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"}
|
try:
|
||||||
if service not in allowed:
|
payload = read_log_payload(service)
|
||||||
|
except ValueError:
|
||||||
self.send_error_json(400, "unsupported service")
|
self.send_error_json(400, "unsupported service")
|
||||||
return
|
return
|
||||||
code, stdout, stderr = run(["journalctl", "-u", service, "-n", "120", "--no-pager"], timeout=8)
|
self.send_json({"ok": True, "data": payload})
|
||||||
self.send_json({"ok": code == 0, "data": stdout if code == 0 else stderr})
|
|
||||||
else:
|
else:
|
||||||
self.send_error_json(404, "not found")
|
self.send_error_json(404, "not found")
|
||||||
|
|
||||||
@@ -519,6 +606,13 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
ok, message, payload = run_stats_action("repair")
|
ok, message, payload = run_stats_action("repair")
|
||||||
payload["message"] = message
|
payload["message"] = message
|
||||||
self.send_json({"ok": ok, "data": payload}, 200 if ok else 500)
|
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"):
|
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"}
|
||||||
|
|||||||
@@ -109,8 +109,18 @@ const i18n = {
|
|||||||
darkTheme: "Dark",
|
darkTheme: "Dark",
|
||||||
configMode: "Mode",
|
configMode: "Mode",
|
||||||
configDomain: "Domain",
|
configDomain: "Domain",
|
||||||
|
configSiteStatus: "Site check",
|
||||||
configTemplate: "Template",
|
configTemplate: "Template",
|
||||||
configVersion: "Version",
|
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",
|
pageDashboardTitle: "Dashboard",
|
||||||
pageDashboardKicker: "Local Admin",
|
pageDashboardKicker: "Local Admin",
|
||||||
pageTrafficTitle: "Traffic",
|
pageTrafficTitle: "Traffic",
|
||||||
@@ -231,8 +241,18 @@ const i18n = {
|
|||||||
darkTheme: "Тёмная",
|
darkTheme: "Тёмная",
|
||||||
configMode: "Режим",
|
configMode: "Режим",
|
||||||
configDomain: "Домен",
|
configDomain: "Домен",
|
||||||
|
configSiteStatus: "Проверка сайта",
|
||||||
configTemplate: "Шаблон",
|
configTemplate: "Шаблон",
|
||||||
configVersion: "Версия",
|
configVersion: "Версия",
|
||||||
|
siteOk: "Сайт 200 OK",
|
||||||
|
siteHttp: "Сайт HTTP",
|
||||||
|
siteMissing: "Домен не настроен",
|
||||||
|
siteInvalid: "Некорректный домен",
|
||||||
|
siteError: "Проверка сайта не прошла",
|
||||||
|
siteNotChecked: "Проверка сайта ожидает",
|
||||||
|
logsLines: "строк",
|
||||||
|
logsNoData: "Строк логов нет",
|
||||||
|
languageSaved: "Язык сохранён",
|
||||||
pageDashboardTitle: "Обзор",
|
pageDashboardTitle: "Обзор",
|
||||||
pageDashboardKicker: "Локальная админка",
|
pageDashboardKicker: "Локальная админка",
|
||||||
pageTrafficTitle: "Трафик",
|
pageTrafficTitle: "Трафик",
|
||||||
@@ -333,7 +353,7 @@ function applyI18n() {
|
|||||||
el.placeholder = t(el.dataset.i18nPlaceholder);
|
el.placeholder = t(el.dataset.i18nPlaceholder);
|
||||||
});
|
});
|
||||||
$("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark");
|
$("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark");
|
||||||
$("#languageBadge").textContent = state.lang.toUpperCase();
|
$("#languageSelect").value = state.lang;
|
||||||
$("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English";
|
$("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English";
|
||||||
$("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme");
|
$("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme");
|
||||||
updatePageTitle();
|
updatePageTitle();
|
||||||
@@ -347,6 +367,26 @@ function setTheme(theme) {
|
|||||||
if (state.overview) drawTrafficChart(state.overview.stats_history || []);
|
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) {
|
function setPage(page, push = true) {
|
||||||
const next = $(`[data-page="${page}"]`) ? page : "dashboard";
|
const next = $(`[data-page="${page}"]`) ? page : "dashboard";
|
||||||
state.page = next;
|
state.page = next;
|
||||||
@@ -445,6 +485,31 @@ function renderRuntime() {
|
|||||||
`).join("") : "";
|
`).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() {
|
function renderOverview() {
|
||||||
const data = state.overview;
|
const data = state.overview;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -455,7 +520,7 @@ function renderOverview() {
|
|||||||
$("#sidebarBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`;
|
$("#sidebarBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`;
|
||||||
$("#settingsBind").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 || "--";
|
renderSiteStatus();
|
||||||
$("#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} ${t("packets")}`;
|
$("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`;
|
||||||
@@ -593,9 +658,11 @@ function renderEvents() {
|
|||||||
|
|
||||||
function renderConfig() {
|
function renderConfig() {
|
||||||
const cfg = state.overview?.config || {};
|
const cfg = state.overview?.config || {};
|
||||||
|
const site = state.overview?.site_status || {};
|
||||||
const items = [
|
const items = [
|
||||||
[t("configMode"), cfg.mode || "--"],
|
[t("configMode"), cfg.mode || "--"],
|
||||||
[t("configDomain"), cfg.domain || cfg.mask_host || "--"],
|
[t("configDomain"), cfg.domain || cfg.mask_host || "--"],
|
||||||
|
[t("configSiteStatus"), siteStatusText(site)],
|
||||||
[t("configTemplate"), cfg.template_id || cfg.template || "--"],
|
[t("configTemplate"), cfg.template_id || cfg.template || "--"],
|
||||||
[t("configVersion"), state.overview?.version || "--"],
|
[t("configVersion"), state.overview?.version || "--"],
|
||||||
[t("bindAddress"), `${state.overview?.admin_bind?.host || "127.0.0.1"}:${state.overview?.admin_bind?.port || 1984}`],
|
[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 service = $("#logService").value;
|
||||||
const btn = $("#loadLogsBtn");
|
const btn = $("#loadLogsBtn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
$("#logsMeta").textContent = "";
|
||||||
$("#logsBox").textContent = t("loading");
|
$("#logsBox").textContent = t("loading");
|
||||||
try {
|
try {
|
||||||
const logs = await api(`/api/logs?service=${encodeURIComponent(service)}`);
|
const payload = await api(`/api/logs?service=${encodeURIComponent(service)}`);
|
||||||
if ($("#logService").value === 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) {
|
} catch (err) {
|
||||||
|
$("#logsMeta").textContent = "";
|
||||||
$("#logsBox").textContent = err.message;
|
$("#logsBox").textContent = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -766,6 +840,7 @@ $("#addUserForm").addEventListener("submit", (eventObj) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#refreshBtn").addEventListener("click", refreshAll);
|
$("#refreshBtn").addEventListener("click", refreshAll);
|
||||||
|
$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value));
|
||||||
$("#createBackupBtn").addEventListener("click", createBackup);
|
$("#createBackupBtn").addEventListener("click", createBackup);
|
||||||
$("#loadLogsBtn").addEventListener("click", loadLogs);
|
$("#loadLogsBtn").addEventListener("click", loadLogs);
|
||||||
$("#repairStatsBtn").addEventListener("click", repairStats);
|
$("#repairStatsBtn").addEventListener("click", repairStats);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin3">
|
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin4">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -48,7 +48,10 @@
|
|||||||
<small id="lastRefresh">--</small>
|
<small id="lastRefresh">--</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span class="pill" id="languageBadge">EN</span>
|
<select id="languageSelect" class="language-select" aria-label="Language">
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
</select>
|
||||||
<button id="themeToggle" class="ghost" type="button">Theme</button>
|
<button id="themeToggle" class="ghost" type="button">Theme</button>
|
||||||
<button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
<button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
<span data-i18n="metricMode">Mode</span>
|
<span data-i18n="metricMode">Mode</span>
|
||||||
<strong id="metricMode">--</strong>
|
<strong id="metricMode">--</strong>
|
||||||
<small id="metricDomain">--</small>
|
<small id="metricDomain">--</small>
|
||||||
|
<small id="siteStatus" class="metric-status">--</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="metric-card accent-green">
|
<article class="metric-card accent-green">
|
||||||
<span data-i18n="metricKeys">Keys</span>
|
<span data-i18n="metricKeys">Keys</span>
|
||||||
@@ -219,6 +223,7 @@
|
|||||||
<button id="loadLogsBtn" type="button" data-i18n="loadLogs">Load</button>
|
<button id="loadLogsBtn" type="button" data-i18n="loadLogs">Load</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="logsMeta" class="logs-meta"></div>
|
||||||
<pre id="logsBox" class="logs"></pre>
|
<pre id="logsBox" class="logs"></pre>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -264,6 +269,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
<script src="/app.js?v=2.5.0-admin3" type="module"></script>
|
<script src="/app.js?v=2.5.0-admin4" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ h2 {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-select {
|
||||||
|
width: 78px;
|
||||||
|
min-width: 78px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -321,6 +327,33 @@ h2 {
|
|||||||
line-height: 1.05;
|
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 {
|
.grid-two {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.5fr) minmax(320px, .9fr);
|
grid-template-columns: minmax(0, 1.5fr) minmax(320px, .9fr);
|
||||||
@@ -566,6 +599,14 @@ td small {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logs-meta {
|
||||||
|
min-height: 24px;
|
||||||
|
margin: -2px 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.logs {
|
.logs {
|
||||||
min-height: 460px;
|
min-height: 460px;
|
||||||
max-height: calc(100vh - 260px);
|
max-height: calc(100vh - 260px);
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ def get_user_lang(user_id: Optional[int]) -> str:
|
|||||||
if not _USER_LANGS_LOADED:
|
if not _USER_LANGS_LOADED:
|
||||||
_load_user_langs()
|
_load_user_langs()
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return DEFAULT_LANG
|
return _detect_default_lang()
|
||||||
return _USER_LANGS.get(int(user_id), DEFAULT_LANG)
|
return _USER_LANGS.get(int(user_id), _detect_default_lang())
|
||||||
|
|
||||||
|
|
||||||
def set_user_lang(user_id: int, code: str) -> bool:
|
def set_user_lang(user_id: int, code: str) -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user