mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 14:36:05 +00:00
v2.5.0: refine admin traffic and port status
This commit is contained in:
@@ -9,6 +9,7 @@ through an SSH tunnel; it must never be exposed directly on the public network.
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
@@ -38,6 +39,7 @@ BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups"))
|
||||
INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram"))
|
||||
BOT_DIR = Path(os.getenv("GOTELEGRAM_BOT_DIR", "/opt/gotelegram-bot"))
|
||||
DISABLED_USERS_FILE = Path(os.getenv("GOTELEGRAM_DISABLED_USERS", "/opt/gotelegram/disabled_users.json"))
|
||||
USER_LOCK_FILE = Path(os.getenv("GOTELEGRAM_USER_LOCK", "/run/gotelegram/admin-users.lock"))
|
||||
|
||||
HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
|
||||
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
|
||||
@@ -45,6 +47,12 @@ VERSION = "2.5.0"
|
||||
USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
||||
LANG_RE = re.compile(r"^(en|ru)$")
|
||||
SENSITIVE_CONFIG_KEYS = {"secret"}
|
||||
TRAFFIC_WINDOWS = {
|
||||
"15m": 15 * 60,
|
||||
"1h": 60 * 60,
|
||||
"24h": 24 * 60 * 60,
|
||||
"month": 30 * 24 * 60 * 60,
|
||||
}
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -65,6 +73,23 @@ def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]:
|
||||
return 125, "", str(exc)
|
||||
|
||||
|
||||
class FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.handle: Any = None
|
||||
|
||||
def __enter__(self) -> "FileLock":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.handle = self.path.open("w", encoding="utf-8")
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
if self.handle:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
def load_json(path: Path, fallback: Any = None) -> Any:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
@@ -226,8 +251,10 @@ def write_telemt_users(users: dict[str, str]) -> None:
|
||||
out.append("[access.users]")
|
||||
out.extend(rendered)
|
||||
|
||||
TELEMT_CONFIG.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
|
||||
os.chmod(TELEMT_CONFIG, 0o600)
|
||||
tmp = TELEMT_CONFIG.with_name(TELEMT_CONFIG.name + ".tmp")
|
||||
tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(TELEMT_CONFIG)
|
||||
|
||||
|
||||
def restart_service(name: str) -> bool:
|
||||
@@ -239,6 +266,19 @@ def restart_service(name: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def request_service_restart(name: str) -> bool:
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["systemctl", "restart", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def service_status(name: str) -> str:
|
||||
code, stdout, _ = run(["systemctl", "is-active", name], timeout=3)
|
||||
value = stdout.strip()
|
||||
@@ -271,6 +311,84 @@ def read_telemt_port() -> int:
|
||||
return 443
|
||||
|
||||
|
||||
def _is_port_addr(value: str, port: int) -> bool:
|
||||
token = value.strip()
|
||||
if token.startswith("[") and "]:" in token:
|
||||
return token.rsplit(":", 1)[-1] == str(port)
|
||||
return token.rsplit(":", 1)[-1] == str(port) if ":" in token else False
|
||||
|
||||
|
||||
def _process_role(process: str) -> str:
|
||||
lowered = process.lower()
|
||||
if "telemt" in lowered or "mtproto" in lowered:
|
||||
return "mtproxy"
|
||||
if "nginx" in lowered or "apache" in lowered or "caddy" in lowered:
|
||||
return "site"
|
||||
if "xray" in lowered or "x-ui" in lowered or "3x-ui" in lowered or "xui" in lowered:
|
||||
return "xray"
|
||||
if "amnezia" in lowered or "awg" in lowered or "wireguard" in lowered or re.search(r"\bwg\b", lowered):
|
||||
return "amneziawg"
|
||||
return "other"
|
||||
|
||||
|
||||
def parse_ss_listeners(output: str, proto: str, port: int = 443) -> list[dict[str, Any]]:
|
||||
listeners: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
address = next((part for part in parts if _is_port_addr(part, port)), "")
|
||||
if not address:
|
||||
continue
|
||||
matches = re.findall(r'\("([^"]+)",pid=(\d+)', line)
|
||||
if matches:
|
||||
process_names = []
|
||||
pids = []
|
||||
for proc, pid in matches:
|
||||
if proc not in process_names:
|
||||
process_names.append(proc)
|
||||
if pid not in pids:
|
||||
pids.append(pid)
|
||||
process = ", ".join(process_names)
|
||||
pid_text = ", ".join(pids)
|
||||
else:
|
||||
process = "unknown"
|
||||
pid_text = ""
|
||||
key = (proto, address, process)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
listeners.append({
|
||||
"proto": proto.upper(),
|
||||
"address": address,
|
||||
"process": process,
|
||||
"pid": pid_text,
|
||||
"role": _process_role(process),
|
||||
})
|
||||
return listeners
|
||||
|
||||
|
||||
def port_443_status() -> dict[str, Any]:
|
||||
listeners: list[dict[str, Any]] = []
|
||||
errors: list[str] = []
|
||||
for proto, args in {
|
||||
"tcp": ["ss", "-H", "-ltnp"],
|
||||
"udp": ["ss", "-H", "-lunp"],
|
||||
}.items():
|
||||
code, stdout, stderr = run(args, timeout=2)
|
||||
if code == 0:
|
||||
listeners.extend(parse_ss_listeners(stdout, proto, 443))
|
||||
elif stderr.strip():
|
||||
errors.append(stderr.strip())
|
||||
listeners.sort(key=lambda item: (item["proto"], item["address"], item["process"]))
|
||||
return {
|
||||
"checked_at": int(time.time()),
|
||||
"configured_port": read_telemt_port(),
|
||||
"listeners": listeners,
|
||||
"ok": not errors,
|
||||
"error": "; ".join(errors[:2]),
|
||||
}
|
||||
|
||||
|
||||
def wait_tcp_port(port: int, timeout: int = 90) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
@@ -345,7 +463,7 @@ def site_status(config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def load_stats_history(limit: int = 240) -> list[dict[str, int]]:
|
||||
def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]:
|
||||
if not HISTORY_FILE.exists():
|
||||
return []
|
||||
rows: list[dict[str, int]] = []
|
||||
@@ -362,7 +480,8 @@ def load_stats_history(limit: int = 240) -> list[dict[str, int]]:
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
rows = rows[-limit:]
|
||||
if limit:
|
||||
rows = rows[-limit:]
|
||||
previous = None
|
||||
enriched: list[dict[str, int]] = []
|
||||
for row in rows:
|
||||
@@ -378,6 +497,56 @@ def load_stats_history(limit: int = 240) -> list[dict[str, int]]:
|
||||
return enriched
|
||||
|
||||
|
||||
def history_limit_for_range(range_key: str) -> int:
|
||||
return {
|
||||
"15m": 180,
|
||||
"1h": 240,
|
||||
"24h": 1800,
|
||||
"month": 50000,
|
||||
}.get(range_key, 240)
|
||||
|
||||
|
||||
def normalize_range(range_key: str) -> str:
|
||||
return range_key if range_key in TRAFFIC_WINDOWS else "1h"
|
||||
|
||||
|
||||
def filter_history_by_range(rows: list[dict[str, int]], range_key: str) -> list[dict[str, int]]:
|
||||
if not rows:
|
||||
return []
|
||||
seconds = TRAFFIC_WINDOWS[normalize_range(range_key)]
|
||||
latest = max(row.get("epoch", 0) for row in rows)
|
||||
cutoff = latest - seconds
|
||||
return [row for row in rows if row.get("epoch", 0) >= cutoff]
|
||||
|
||||
|
||||
def traffic_interval_summaries(rows: list[dict[str, int]]) -> list[dict[str, Any]]:
|
||||
if not rows:
|
||||
return [
|
||||
{"range": key, "points": 0, "from": 0, "to": 0, "proxy_delta": 0, "site_delta": 0, "proxy_total": 0, "site_total": 0}
|
||||
for key in TRAFFIC_WINDOWS
|
||||
]
|
||||
latest = max(row.get("epoch", 0) for row in rows)
|
||||
summaries = []
|
||||
for key, seconds in TRAFFIC_WINDOWS.items():
|
||||
window = [row for row in rows if row.get("epoch", 0) >= latest - seconds]
|
||||
if not window:
|
||||
summaries.append({"range": key, "points": 0, "from": 0, "to": latest, "proxy_delta": 0, "site_delta": 0, "proxy_total": 0, "site_total": 0})
|
||||
continue
|
||||
first = window[0]
|
||||
last = window[-1]
|
||||
summaries.append({
|
||||
"range": key,
|
||||
"points": len(window),
|
||||
"from": first.get("epoch", 0),
|
||||
"to": last.get("epoch", 0),
|
||||
"proxy_delta": max(0, int(last.get("proxy_bytes", 0)) - int(first.get("proxy_bytes", 0))),
|
||||
"site_delta": max(0, int(last.get("site_bytes", 0)) - int(first.get("site_bytes", 0))),
|
||||
"proxy_total": int(last.get("proxy_bytes", 0)),
|
||||
"site_total": int(last.get("site_bytes", 0)),
|
||||
})
|
||||
return summaries
|
||||
|
||||
|
||||
def count_history_rows() -> int:
|
||||
if not HISTORY_FILE.exists():
|
||||
return 0
|
||||
@@ -537,6 +706,7 @@ def overview_payload() -> dict[str, Any]:
|
||||
"site_status": site_status(config),
|
||||
"users_count": len(users),
|
||||
"services": services,
|
||||
"port_443": port_443_status(),
|
||||
"stats_current": current,
|
||||
"stats_history": history,
|
||||
"stats_status": stats_status(current, history),
|
||||
@@ -599,9 +769,21 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/backups":
|
||||
self.send_json({"ok": True, "data": list_backups()})
|
||||
elif path == "/api/stats":
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
range_key = normalize_range(qs.get("range", ["1h"])[0])
|
||||
current = load_json(CURRENT_STATS, {}) or {}
|
||||
history = load_stats_history()
|
||||
self.send_json({"ok": True, "data": {"current": current, "history": history, "status": stats_status(current, history)}})
|
||||
all_history = load_stats_history(limit=history_limit_for_range("month"))
|
||||
history = filter_history_by_range(all_history[-history_limit_for_range(range_key):], range_key)
|
||||
self.send_json({
|
||||
"ok": True,
|
||||
"data": {
|
||||
"range": range_key,
|
||||
"current": current,
|
||||
"history": history,
|
||||
"summary_rows": traffic_interval_summaries(all_history),
|
||||
"status": stats_status(current, history),
|
||||
},
|
||||
})
|
||||
elif path == "/api/site/check":
|
||||
self.send_json({"ok": True, "data": site_status()})
|
||||
elif path == "/api/logs":
|
||||
@@ -631,51 +813,53 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
if not USER_RE.match(name):
|
||||
self.send_error_json(400, "invalid user name")
|
||||
return
|
||||
records = read_user_records()
|
||||
if name in records:
|
||||
self.send_error_json(409, "user already exists")
|
||||
return
|
||||
users = read_telemt_users()
|
||||
seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode()
|
||||
secret = hashlib.sha256(seed).hexdigest()[:32]
|
||||
users[name] = secret
|
||||
try:
|
||||
write_telemt_users(users)
|
||||
with FileLock(USER_LOCK_FILE):
|
||||
records = read_user_records()
|
||||
if name in records:
|
||||
self.send_error_json(409, "user already exists")
|
||||
return
|
||||
users = read_telemt_users()
|
||||
seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode()
|
||||
secret = hashlib.sha256(seed).hexdigest()[:32]
|
||||
users[name] = secret
|
||||
write_telemt_users(users)
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, f"failed to save config: {exc}")
|
||||
return
|
||||
restarted = restart_service("telemt")
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, True), "restarted": restarted})
|
||||
restart_requested = request_service_restart("telemt")
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, True), "restart": {"mode": "async", "requested": restart_requested}})
|
||||
elif path.startswith("/api/users/") and path.endswith("/enabled"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")])
|
||||
if name == "main":
|
||||
self.send_error_json(400, "main user cannot be disabled")
|
||||
return
|
||||
enabled = bool(body.get("enabled"))
|
||||
active = read_telemt_users()
|
||||
disabled = read_disabled_users()
|
||||
records = read_user_records()
|
||||
if name not in records:
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
if enabled:
|
||||
secret = disabled.pop(name, records[name]["secret"])
|
||||
active[name] = secret
|
||||
else:
|
||||
secret = active.pop(name, records[name]["secret"])
|
||||
disabled[name] = secret
|
||||
try:
|
||||
if enabled:
|
||||
write_telemt_users(active)
|
||||
write_disabled_users(disabled)
|
||||
else:
|
||||
write_disabled_users(disabled)
|
||||
write_telemt_users(active)
|
||||
with FileLock(USER_LOCK_FILE):
|
||||
active = read_telemt_users()
|
||||
disabled = read_disabled_users()
|
||||
records = read_user_records()
|
||||
if name not in records:
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
if enabled:
|
||||
secret = disabled.pop(name, records[name]["secret"])
|
||||
active[name] = secret
|
||||
else:
|
||||
secret = active.pop(name, records[name]["secret"])
|
||||
disabled[name] = secret
|
||||
if enabled:
|
||||
write_telemt_users(active)
|
||||
write_disabled_users(disabled)
|
||||
else:
|
||||
write_disabled_users(disabled)
|
||||
write_telemt_users(active)
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, f"failed to save config: {exc}")
|
||||
return
|
||||
restarted = restart_service("telemt")
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, enabled), "restarted": restarted})
|
||||
restart_requested = request_service_restart("telemt")
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, enabled), "restart": {"mode": "async", "requested": restart_requested}})
|
||||
elif path == "/api/backups":
|
||||
ok, result = create_backup()
|
||||
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
|
||||
@@ -716,22 +900,23 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
if name == "main":
|
||||
self.send_error_json(400, "main user cannot be deleted")
|
||||
return
|
||||
active = read_telemt_users()
|
||||
disabled = read_disabled_users()
|
||||
records = read_user_records()
|
||||
if name not in records:
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
active.pop(name, None)
|
||||
disabled.pop(name, None)
|
||||
try:
|
||||
write_telemt_users(active)
|
||||
write_disabled_users(disabled)
|
||||
with FileLock(USER_LOCK_FILE):
|
||||
active = read_telemt_users()
|
||||
disabled = read_disabled_users()
|
||||
records = read_user_records()
|
||||
if name not in records:
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
active.pop(name, None)
|
||||
disabled.pop(name, None)
|
||||
write_telemt_users(active)
|
||||
write_disabled_users(disabled)
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, f"failed to save config: {exc}")
|
||||
return
|
||||
restarted = restart_service("telemt")
|
||||
self.send_json({"ok": True, "restarted": restarted})
|
||||
restart_requested = request_service_restart("telemt")
|
||||
self.send_json({"ok": True, "restart": {"mode": "async", "requested": restart_requested}})
|
||||
|
||||
def send_static(self, parsed: urllib.parse.ParseResult) -> None:
|
||||
rel = parsed.path.lstrip("/") or "index.html"
|
||||
|
||||
@@ -15,14 +15,16 @@ const i18n = {
|
||||
themeLight: "Light",
|
||||
metricMode: "Mode",
|
||||
metricKeys: "Keys",
|
||||
metricProxyTraffic: "Proxy Traffic",
|
||||
metricSiteTraffic: "Site Traffic",
|
||||
metricProxyTraffic: "Proxy traffic",
|
||||
metricSiteTraffic: "Site traffic",
|
||||
configuredUsers: "configured users",
|
||||
packets: "packets",
|
||||
servicesEyebrow: "Services",
|
||||
servicesTitle: "Runtime health",
|
||||
servicesTitle: "Service health",
|
||||
servicesHelp: "Systemd service status for telemt, nginx, the bot, the traffic collector and the local admin.",
|
||||
runtimeEyebrow: "Runtime",
|
||||
runtimeTitle: "telemt summary",
|
||||
runtimeHelp: "Runtime data comes from the local telemt API and shows what the proxy engine sees right now.",
|
||||
trafficEyebrow: "Traffic",
|
||||
trafficTitle: "History",
|
||||
keysEyebrow: "Access",
|
||||
@@ -40,9 +42,12 @@ const i18n = {
|
||||
collector: "Collector",
|
||||
lastPoint: "Last point",
|
||||
historyRows: "History rows",
|
||||
collectStats: "Collect",
|
||||
repairStats: "Repair stats",
|
||||
collectStats: "Update stats",
|
||||
collectStatsHelp: "Run one traffic collection now.",
|
||||
repairStats: "Restart collector",
|
||||
repairStatsHelp: "Reinstall and restart the background service that writes traffic history.",
|
||||
tableTime: "Time",
|
||||
tablePeriod: "Period",
|
||||
tableStatus: "Status",
|
||||
tableProxyDelta: "Proxy delta",
|
||||
tableSiteDelta: "Site delta",
|
||||
@@ -59,6 +64,8 @@ const i18n = {
|
||||
delete: "Delete",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
applying: "Applying...",
|
||||
changesApplyInBackground: "Changes are being applied in the background",
|
||||
disableKey: "Disable key",
|
||||
enableKey: "Enable key",
|
||||
main: "main",
|
||||
@@ -72,6 +79,7 @@ const i18n = {
|
||||
noBackups: "No backups yet",
|
||||
noEvents: "No events yet",
|
||||
noHistory: "No traffic history yet",
|
||||
noTrafficForRange: "No data for this range yet",
|
||||
noRuntime: "Runtime data is not available",
|
||||
badConnections: "Bad connections",
|
||||
connections: "Connections",
|
||||
@@ -103,7 +111,7 @@ const i18n = {
|
||||
keyDeleted: "Key deleted",
|
||||
backupCreated: "Backup created",
|
||||
serviceRestarted: "Service restarted",
|
||||
statsRepaired: "Statistics repaired",
|
||||
statsRepaired: "Collector restarted",
|
||||
statsCollected: "Statistics collected",
|
||||
confirmDelete: "Delete key",
|
||||
confirmRestart: "Restart",
|
||||
@@ -128,8 +136,34 @@ const i18n = {
|
||||
languageSaved: "Language saved",
|
||||
keyEnabled: "Key enabled",
|
||||
keyDisabled: "Key disabled",
|
||||
visualTitle: "443 shared edge",
|
||||
visualText: "Website, MTProxy and local admin status in one operational view.",
|
||||
visualTitle: "Port 443 listeners",
|
||||
visualText: "Actual TCP/UDP listeners on public port 443: telemt, website, Xray/3x-ui, AmneziaWG or another service.",
|
||||
port443Checked: "checked",
|
||||
port443NoListeners: "No 443 listeners found",
|
||||
port443Listeners: "listeners",
|
||||
port443Error: "Port check failed",
|
||||
roleMtproxy: "MTProxy",
|
||||
roleSite: "Website",
|
||||
roleXray: "Xray / 3x-ui",
|
||||
roleAmneziawg: "AmneziaWG",
|
||||
roleOther: "Other",
|
||||
range15m: "15 min",
|
||||
range1h: "1 hour",
|
||||
range24h: "24 hours",
|
||||
rangeMonth: "Month",
|
||||
viewChart: "Chart",
|
||||
viewRows: "Rows",
|
||||
chartMax: "max {value} per interval",
|
||||
chartProxy: "proxy",
|
||||
chartSite: "site",
|
||||
encrypted: "encrypted",
|
||||
ariaAdminSections: "Admin sections",
|
||||
ariaMenu: "Open menu",
|
||||
ariaLanguage: "Language",
|
||||
ariaClose: "Close",
|
||||
ariaTrafficHistory: "Traffic history",
|
||||
ariaTrafficRange: "Traffic range",
|
||||
ariaTrafficView: "Traffic view",
|
||||
promoEyebrow: "Promo",
|
||||
promoTitle: "Support goTelegram Pro",
|
||||
promoHosting1: "Hosting #1",
|
||||
@@ -161,14 +195,16 @@ const i18n = {
|
||||
themeLight: "Светлая",
|
||||
metricMode: "Режим",
|
||||
metricKeys: "Ключи",
|
||||
metricProxyTraffic: "Трафик proxy",
|
||||
metricProxyTraffic: "Трафик прокси",
|
||||
metricSiteTraffic: "Трафик сайта",
|
||||
configuredUsers: "настроенных пользователей",
|
||||
packets: "пакетов",
|
||||
servicesEyebrow: "Сервисы",
|
||||
servicesTitle: "Состояние runtime",
|
||||
runtimeEyebrow: "Runtime",
|
||||
runtimeTitle: "сводка telemt",
|
||||
servicesTitle: "Состояние служб",
|
||||
servicesHelp: "Статус systemd-служб: telemt, nginx, бот, сборщик трафика и локальная админка.",
|
||||
runtimeEyebrow: "Среда выполнения",
|
||||
runtimeTitle: "Сводка telemt",
|
||||
runtimeHelp: "Данные среды выполнения берутся из локального API telemt и показывают, что ядро прокси видит прямо сейчас.",
|
||||
trafficEyebrow: "Трафик",
|
||||
trafficTitle: "История",
|
||||
keysEyebrow: "Доступ",
|
||||
@@ -186,25 +222,30 @@ const i18n = {
|
||||
collector: "Сборщик",
|
||||
lastPoint: "Последняя точка",
|
||||
historyRows: "Строк истории",
|
||||
collectStats: "Собрать",
|
||||
repairStats: "Починить статистику",
|
||||
collectStats: "Обновить статистику",
|
||||
collectStatsHelp: "Запустить один сбор трафика прямо сейчас.",
|
||||
repairStats: "Перезапустить сборщик",
|
||||
repairStatsHelp: "Переустановить и перезапустить фоновую службу, которая пишет историю трафика.",
|
||||
tableTime: "Время",
|
||||
tablePeriod: "Период",
|
||||
tableStatus: "Статус",
|
||||
tableProxyDelta: "Proxy delta",
|
||||
tableSiteDelta: "Site delta",
|
||||
tableProxyTotal: "Proxy всего",
|
||||
tableSiteTotal: "Site всего",
|
||||
tableProxyDelta: "Прирост прокси",
|
||||
tableSiteDelta: "Прирост сайта",
|
||||
tableProxyTotal: "Всего прокси",
|
||||
tableSiteTotal: "Всего по сайту",
|
||||
tableUser: "Пользователь",
|
||||
tableSecret: "Secret",
|
||||
tableSecret: "Секрет",
|
||||
tableLink: "Ссылка",
|
||||
tableActions: "Действия",
|
||||
userPlaceholder: "client-name",
|
||||
addKey: "Добавить ключ",
|
||||
copyLink: "Копировать ссылку",
|
||||
copySecret: "Копировать secret",
|
||||
copySecret: "Копировать секрет",
|
||||
delete: "Удалить",
|
||||
enabled: "Включён",
|
||||
disabled: "Отключён",
|
||||
applying: "Применяется...",
|
||||
changesApplyInBackground: "Изменения применяются в фоне",
|
||||
disableKey: "Отключить ключ",
|
||||
enableKey: "Включить ключ",
|
||||
main: "основной",
|
||||
@@ -212,13 +253,14 @@ const i18n = {
|
||||
loadLogs: "Загрузить",
|
||||
panelLanguage: "Язык панели",
|
||||
theme: "Тема",
|
||||
bindAddress: "Адрес bind",
|
||||
bindAddress: "Адрес привязки",
|
||||
dashboard: "Обзор",
|
||||
noKeys: "Ключей пока нет",
|
||||
noBackups: "Бекапов пока нет",
|
||||
noEvents: "Событий пока нет",
|
||||
noHistory: "Истории трафика пока нет",
|
||||
noRuntime: "Runtime-данные недоступны",
|
||||
noTrafficForRange: "За этот период данных пока нет",
|
||||
noRuntime: "Данные среды выполнения недоступны",
|
||||
badConnections: "Ошибочные подключения",
|
||||
connections: "Подключения",
|
||||
uptime: "Аптайм",
|
||||
@@ -240,16 +282,16 @@ const i18n = {
|
||||
statusUnknown: "неизвестно",
|
||||
statsMissing: "Сборщик не запущен",
|
||||
statsOk: "Сборщик работает",
|
||||
statsStale: "Snapshot устарел",
|
||||
statsStale: "Снимок устарел",
|
||||
statsError: "Ошибка сборщика",
|
||||
restart: "Рестарт",
|
||||
restart: "Перезапустить",
|
||||
copied: "Скопировано",
|
||||
copyFailed: "Не удалось скопировать",
|
||||
keyCreated: "Ключ создан",
|
||||
keyDeleted: "Ключ удалён",
|
||||
backupCreated: "Бекап создан",
|
||||
serviceRestarted: "Сервис перезапущен",
|
||||
statsRepaired: "Статистика починена",
|
||||
statsRepaired: "Сборщик перезапущен",
|
||||
statsCollected: "Статистика собрана",
|
||||
confirmDelete: "Удалить ключ",
|
||||
confirmRestart: "Перезапустить",
|
||||
@@ -274,8 +316,34 @@ const i18n = {
|
||||
languageSaved: "Язык сохранён",
|
||||
keyEnabled: "Ключ включён",
|
||||
keyDisabled: "Ключ отключён",
|
||||
visualTitle: "Единый 443 edge",
|
||||
visualText: "Сайт, MTProxy и локальная админка в одном рабочем обзоре.",
|
||||
visualTitle: "Кто слушает порт 443",
|
||||
visualText: "Реальные TCP/UDP-процессы на публичном 443: telemt, сайт, Xray/3x-ui, AmneziaWG или другой сервис.",
|
||||
port443Checked: "проверено",
|
||||
port443NoListeners: "Слушателей 443 не найдено",
|
||||
port443Listeners: "слушателей",
|
||||
port443Error: "Проверка порта не удалась",
|
||||
roleMtproxy: "MTProxy",
|
||||
roleSite: "Сайт",
|
||||
roleXray: "Xray / 3x-ui",
|
||||
roleAmneziawg: "AmneziaWG",
|
||||
roleOther: "Другое",
|
||||
range15m: "15 мин",
|
||||
range1h: "1 час",
|
||||
range24h: "24 часа",
|
||||
rangeMonth: "Месяц",
|
||||
viewChart: "График",
|
||||
viewRows: "Строки",
|
||||
chartMax: "макс. {value} за интервал",
|
||||
chartProxy: "прокси",
|
||||
chartSite: "сайт",
|
||||
encrypted: "зашифровано",
|
||||
ariaAdminSections: "Разделы админки",
|
||||
ariaMenu: "Открыть меню",
|
||||
ariaLanguage: "Язык",
|
||||
ariaClose: "Закрыть",
|
||||
ariaTrafficHistory: "История трафика",
|
||||
ariaTrafficRange: "Период трафика",
|
||||
ariaTrafficView: "Вид трафика",
|
||||
promoEyebrow: "Промо",
|
||||
promoTitle: "Поддержать goTelegram Pro",
|
||||
promoHosting1: "Хостинг #1",
|
||||
@@ -298,15 +366,21 @@ const i18n = {
|
||||
|
||||
const state = {
|
||||
overview: null,
|
||||
stats: null,
|
||||
users: [],
|
||||
events: [],
|
||||
lang: "en",
|
||||
page: "dashboard",
|
||||
theme: document.documentElement.dataset.theme || "light",
|
||||
trafficRange: "1h",
|
||||
trafficView: "chart",
|
||||
pendingUsers: new Set(),
|
||||
};
|
||||
|
||||
const t = (key) => (i18n[state.lang] && i18n[state.lang][key]) || i18n.en[key] || key;
|
||||
|
||||
const trafficRanges = ["15m", "1h", "24h", "month"];
|
||||
|
||||
const fmtBytes = (value = 0) => {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let n = Number(value) || 0;
|
||||
@@ -380,12 +454,19 @@ function applyI18n() {
|
||||
$$("[data-i18n-placeholder]").forEach((el) => {
|
||||
el.placeholder = t(el.dataset.i18nPlaceholder);
|
||||
});
|
||||
$$("[data-i18n-title]").forEach((el) => {
|
||||
el.title = t(el.dataset.i18nTitle);
|
||||
});
|
||||
$$("[data-i18n-aria-label]").forEach((el) => {
|
||||
el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel));
|
||||
});
|
||||
$("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark");
|
||||
$("#languageSelect").value = state.lang;
|
||||
$("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English";
|
||||
$("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme");
|
||||
$("#visualTitle").textContent = t("visualTitle");
|
||||
$("#visualText").textContent = t("visualText");
|
||||
updateTrafficControls();
|
||||
updatePageTitle();
|
||||
}
|
||||
|
||||
@@ -394,7 +475,7 @@ function setTheme(theme) {
|
||||
document.documentElement.dataset.theme = state.theme;
|
||||
localStorage.setItem("gotelegram-theme", state.theme);
|
||||
applyI18n();
|
||||
if (state.overview) drawTrafficChart(state.overview.stats_history || []);
|
||||
if (state.overview) renderStats();
|
||||
}
|
||||
|
||||
async function setLanguage(lang) {
|
||||
@@ -427,6 +508,9 @@ function setPage(page, push = true) {
|
||||
if (push && location.hash !== `#${next}`) {
|
||||
history.replaceState(null, "", `#${next}`);
|
||||
}
|
||||
if (next === "traffic") {
|
||||
refreshStats().catch((err) => toast(err.message));
|
||||
}
|
||||
}
|
||||
|
||||
function updatePageTitle() {
|
||||
@@ -540,6 +624,42 @@ function renderSiteStatus() {
|
||||
statusEl.title = site.url || "";
|
||||
}
|
||||
|
||||
function roleLabel(role) {
|
||||
const key = `role${String(role || "other").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`;
|
||||
const label = t(key);
|
||||
return label === key ? t("roleOther") : label;
|
||||
}
|
||||
|
||||
function renderPort443(payload = {}) {
|
||||
const listeners = Array.isArray(payload.listeners) ? payload.listeners : [];
|
||||
const summary = $("#port443Summary");
|
||||
const list = $("#port443List");
|
||||
if (payload.error) {
|
||||
summary.textContent = t("port443Error");
|
||||
summary.className = "port-status error";
|
||||
} else if (!listeners.length) {
|
||||
summary.textContent = t("port443NoListeners");
|
||||
summary.className = "port-status warn";
|
||||
} else {
|
||||
summary.textContent = `${listeners.length} ${t("port443Listeners")}`;
|
||||
summary.className = "port-status ok";
|
||||
}
|
||||
if (!listeners.length) {
|
||||
list.innerHTML = `<div class="port-empty">${escapeHtml(payload.error || t("port443NoListeners"))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = listeners.map((item) => {
|
||||
const title = `${item.proto || ""} ${item.address || ""} · ${item.process || "unknown"}${item.pid ? ` · pid ${item.pid}` : ""}`;
|
||||
return `<article class="port-listener role-${escapeAttr(item.role || "other")}" title="${escapeAttr(title)}">
|
||||
<div>
|
||||
<strong>${escapeHtml(roleLabel(item.role))}</strong>
|
||||
<span>${escapeHtml(item.process || "unknown")}${item.pid ? ` · pid ${escapeHtml(item.pid)}` : ""}</span>
|
||||
</div>
|
||||
<small>${escapeHtml(item.proto || "--")} · ${escapeHtml(item.address || "--")}</small>
|
||||
</article>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const data = state.overview;
|
||||
if (!data) return;
|
||||
@@ -551,6 +671,7 @@ function renderOverview() {
|
||||
$("#settingsBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`;
|
||||
$("#metricMode").textContent = cfg.mode || "--";
|
||||
renderSiteStatus();
|
||||
renderPort443(data.port_443 || {});
|
||||
$("#metricUsers").textContent = data.users_count ?? 0;
|
||||
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
|
||||
$("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`;
|
||||
@@ -564,10 +685,95 @@ function renderOverview() {
|
||||
renderConfig();
|
||||
}
|
||||
|
||||
function statsPayload() {
|
||||
if (state.stats) return state.stats;
|
||||
return {
|
||||
current: state.overview?.stats_current || {},
|
||||
history: state.overview?.stats_history || [],
|
||||
status: state.overview?.stats_status || {},
|
||||
summary_rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function updateTrafficControls() {
|
||||
$$("[data-traffic-range]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.trafficRange === state.trafficRange);
|
||||
});
|
||||
$$("[data-traffic-view]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.trafficView === state.trafficView);
|
||||
});
|
||||
}
|
||||
|
||||
function trafficRangeLabel(range) {
|
||||
const labels = {
|
||||
"15m": t("range15m"),
|
||||
"1h": t("range1h"),
|
||||
"24h": t("range24h"),
|
||||
month: t("rangeMonth"),
|
||||
};
|
||||
return labels[range] || range;
|
||||
}
|
||||
|
||||
function rangeSeconds(range) {
|
||||
return {
|
||||
"15m": 15 * 60,
|
||||
"1h": 60 * 60,
|
||||
"24h": 24 * 60 * 60,
|
||||
month: 30 * 24 * 60 * 60,
|
||||
}[range] || 60 * 60;
|
||||
}
|
||||
|
||||
function filterTrafficRows(rows, range = state.trafficRange) {
|
||||
if (!Array.isArray(rows) || !rows.length) return [];
|
||||
const latest = Math.max(...rows.map((row) => Number(row.epoch) || 0));
|
||||
const cutoff = latest - rangeSeconds(range);
|
||||
return rows.filter((row) => (Number(row.epoch) || 0) >= cutoff);
|
||||
}
|
||||
|
||||
function bucketTrafficRows(rows) {
|
||||
const filtered = filterTrafficRows(rows);
|
||||
if (filtered.length <= 140) return filtered;
|
||||
const chunk = Math.ceil(filtered.length / 120);
|
||||
const buckets = [];
|
||||
for (let i = 0; i < filtered.length; i += chunk) {
|
||||
const slice = filtered.slice(i, i + chunk);
|
||||
const last = slice[slice.length - 1];
|
||||
buckets.push({
|
||||
epoch: last.epoch,
|
||||
proxy_delta: slice.reduce((sum, item) => sum + (Number(item.proxy_delta) || 0), 0),
|
||||
site_delta: slice.reduce((sum, item) => sum + (Number(item.site_delta) || 0), 0),
|
||||
proxy_bytes: last.proxy_bytes,
|
||||
site_bytes: last.site_bytes,
|
||||
});
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function fallbackTrafficSummaries(rows) {
|
||||
return trafficRanges.map((range) => {
|
||||
const windowRows = filterTrafficRows(rows, range);
|
||||
if (!windowRows.length) {
|
||||
return { range, points: 0, proxy_delta: 0, site_delta: 0, proxy_total: 0, site_total: 0 };
|
||||
}
|
||||
const first = windowRows[0];
|
||||
const last = windowRows[windowRows.length - 1];
|
||||
return {
|
||||
range,
|
||||
points: windowRows.length,
|
||||
proxy_delta: Math.max(0, (Number(last.proxy_bytes) || 0) - (Number(first.proxy_bytes) || 0)),
|
||||
site_delta: Math.max(0, (Number(last.site_bytes) || 0) - (Number(first.site_bytes) || 0)),
|
||||
proxy_total: Number(last.proxy_bytes) || 0,
|
||||
site_total: Number(last.site_bytes) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const status = state.overview?.stats_status || {};
|
||||
const stats = state.overview?.stats_current || {};
|
||||
const historyRows = state.overview?.stats_history || [];
|
||||
const payload = statsPayload();
|
||||
const status = payload.status || {};
|
||||
const stats = payload.current || {};
|
||||
const historyRows = payload.history || [];
|
||||
const summaryRows = payload.summary_rows?.length ? payload.summary_rows : fallbackTrafficSummaries(historyRows);
|
||||
$("#statsHealth").className = `status-pill health-${escapeAttr(status.health || "unknown")}`;
|
||||
$("#statsHealth").textContent = healthLabel(status.health);
|
||||
$("#collectorState").textContent = status.service ? statusLabel(status.service) : "--";
|
||||
@@ -577,18 +783,21 @@ function renderStats() {
|
||||
$("#collectStatsBtn").disabled = status.service === "not_installed";
|
||||
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
|
||||
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
|
||||
updateTrafficControls();
|
||||
$("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart");
|
||||
$("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table");
|
||||
drawTrafficChart(historyRows);
|
||||
renderHistoryTable(historyRows);
|
||||
renderHistoryTable(summaryRows);
|
||||
}
|
||||
|
||||
function drawTrafficChart(rows) {
|
||||
const el = $("#trafficChart");
|
||||
const points = rows.slice(-120);
|
||||
const points = bucketTrafficRows(rows);
|
||||
const proxyColor = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb";
|
||||
const siteColor = getComputedStyle(document.documentElement).getPropertyValue("--green").trim() || "#0f9f6e";
|
||||
if (points.length < 2) {
|
||||
el.innerHTML = `<div class="empty-chart">
|
||||
<strong>${escapeHtml(t("noHistory"))}</strong>
|
||||
<strong>${escapeHtml(points.length ? t("noTrafficForRange") : t("noHistory"))}</strong>
|
||||
<span>${escapeHtml(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))}</span>
|
||||
</div>`;
|
||||
return;
|
||||
@@ -606,30 +815,30 @@ function drawTrafficChart(rows) {
|
||||
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">
|
||||
const axis = t("chartMax").replace("{value}", fmtBytes(max));
|
||||
el.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(t("ariaTrafficHistory"))}">
|
||||
<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>
|
||||
<text x="${pad.l}" y="17" class="axis">${escapeHtml(axis)}</text>
|
||||
<text x="${pad.l}" y="${height - 12}" class="legend" fill="${proxyColor}">${escapeHtml(t("chartProxy"))}</text>
|
||||
<text x="${pad.l + 86}" y="${height - 12}" class="legend" fill="${siteColor}">${escapeHtml(t("chartSite"))}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function renderHistoryTable(rows) {
|
||||
const latest = rows.slice(-12).reverse();
|
||||
if (!latest.length) {
|
||||
if (!rows.length) {
|
||||
$("#historyTable").innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
$("#historyTable").innerHTML = latest.map((row) => `
|
||||
$("#historyTable").innerHTML = rows.map((row) => `
|
||||
<tr>
|
||||
<td data-label="${escapeAttr(t("tableTime"))}">${escapeHtml(fmtDate(row.epoch))}</td>
|
||||
<td data-label="${escapeAttr(t("tablePeriod"))}"><strong>${escapeHtml(trafficRangeLabel(row.range))}</strong><small>${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))}</small></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>
|
||||
<td data-label="${escapeAttr(t("tableProxyTotal"))}">${escapeHtml(fmtBytes(row.proxy_total))}</td>
|
||||
<td data-label="${escapeAttr(t("tableSiteTotal"))}">${escapeHtml(fmtBytes(row.site_total))}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
@@ -640,18 +849,20 @@ function renderUsers() {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.users.map((user) => `
|
||||
<tr class="${user.enabled ? "" : "disabled-row"}">
|
||||
tbody.innerHTML = state.users.map((user) => {
|
||||
const pending = state.pendingUsers.has(user.name);
|
||||
return `
|
||||
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""}">
|
||||
<td data-label="${escapeAttr(t("tableUser"))}">
|
||||
<strong>${escapeHtml(user.name)}</strong>${user.main ? ` <small>${escapeHtml(t("main"))}</small>` : ""}
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableStatus"))}">
|
||||
<div class="status-control">
|
||||
<label class="switch" title="${escapeAttr(user.main ? t("main") : (user.enabled ? t("disableKey") : t("enableKey")))}">
|
||||
<input type="checkbox" data-toggle-user="${escapeAttr(user.name)}" ${user.enabled ? "checked" : ""} ${user.main ? "disabled" : ""}>
|
||||
<input type="checkbox" data-toggle-user="${escapeAttr(user.name)}" ${user.enabled ? "checked" : ""} ${user.main || pending ? "disabled" : ""}>
|
||||
<span></span>
|
||||
</label>
|
||||
<strong class="${user.enabled ? "state-on" : "state-off"}">${escapeHtml(user.enabled ? t("enabled") : t("disabled"))}</strong>
|
||||
<strong class="${user.enabled ? "state-on" : "state-off"}">${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
|
||||
@@ -661,7 +872,7 @@ function renderUsers() {
|
||||
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
`; }).join("");
|
||||
}
|
||||
|
||||
function renderBackups(backups) {
|
||||
@@ -676,7 +887,7 @@ function renderBackups(backups) {
|
||||
<strong>${escapeHtml(item.name)}</strong>
|
||||
<span>${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}</span>
|
||||
</div>
|
||||
<div>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? " · encrypted" : ""}</div>
|
||||
<div>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
@@ -721,6 +932,20 @@ async function refreshAll() {
|
||||
state.overview = await api("/api/overview");
|
||||
updateLanguageFromOverview(state.overview);
|
||||
state.users = await api("/api/users");
|
||||
if (!state.stats) {
|
||||
state.stats = {
|
||||
current: state.overview.stats_current || {},
|
||||
history: state.overview.stats_history || [],
|
||||
status: state.overview.stats_status || {},
|
||||
summary_rows: [],
|
||||
};
|
||||
} else {
|
||||
state.stats = {
|
||||
...state.stats,
|
||||
current: state.overview.stats_current || state.stats.current || {},
|
||||
status: state.overview.stats_status || state.stats.status || {},
|
||||
};
|
||||
}
|
||||
renderOverview();
|
||||
renderUsers();
|
||||
} catch (err) {
|
||||
@@ -730,6 +955,12 @@ async function refreshAll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`);
|
||||
state.stats = data;
|
||||
renderStats();
|
||||
}
|
||||
|
||||
async function addUser(name) {
|
||||
const data = await api("/api/users", {
|
||||
method: "POST",
|
||||
@@ -748,14 +979,29 @@ async function deleteUser(name) {
|
||||
}
|
||||
|
||||
async function setUserEnabled(name, enabled) {
|
||||
const data = await api(`/api/users/${encodeURIComponent(name)}/enabled`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const message = data.enabled ? t("keyEnabled") : t("keyDisabled");
|
||||
addEvent(message, name);
|
||||
toast(message);
|
||||
await refreshAll();
|
||||
const previousUsers = state.users.map((user) => ({ ...user }));
|
||||
state.pendingUsers.add(name);
|
||||
state.users = state.users.map((user) => user.name === name ? { ...user, enabled } : user);
|
||||
renderUsers();
|
||||
try {
|
||||
const data = await api(`/api/users/${encodeURIComponent(name)}/enabled`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
state.users = state.users.map((user) => user.name === name ? { ...user, enabled: data.enabled } : user);
|
||||
const message = data.enabled ? t("keyEnabled") : t("keyDisabled");
|
||||
addEvent(message, name);
|
||||
toast(t("changesApplyInBackground"));
|
||||
setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1200);
|
||||
} catch (err) {
|
||||
state.users = previousUsers;
|
||||
toast(err.message);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
state.pendingUsers.delete(name);
|
||||
renderUsers();
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
@@ -812,6 +1058,7 @@ async function repairStats() {
|
||||
addEvent(t("statsRepaired"));
|
||||
toast(t("statsRepaired"));
|
||||
await refreshAll();
|
||||
await refreshStats();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
@@ -827,6 +1074,7 @@ async function collectStats() {
|
||||
addEvent(t("statsCollected"));
|
||||
toast(t("statsCollected"));
|
||||
await refreshAll();
|
||||
await refreshStats();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
@@ -875,6 +1123,14 @@ document.addEventListener("click", async (eventObj) => {
|
||||
setTheme(state.theme === "dark" ? "light" : "dark");
|
||||
} else if (button.id === "menuBtn") {
|
||||
$("#sidebar").classList.toggle("open");
|
||||
} else if (button.dataset.trafficRange) {
|
||||
state.trafficRange = trafficRanges.includes(button.dataset.trafficRange) ? button.dataset.trafficRange : "1h";
|
||||
updateTrafficControls();
|
||||
renderStats();
|
||||
refreshStats().catch((err) => toast(err.message));
|
||||
} else if (button.dataset.trafficView) {
|
||||
state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart";
|
||||
renderStats();
|
||||
} else if (button.dataset.copy) {
|
||||
await copyText(button.dataset.copy);
|
||||
} else if (button.dataset.delete) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}());
|
||||
</script>
|
||||
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin5">
|
||||
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin6">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-tabs" aria-label="Admin sections">
|
||||
<nav class="nav-tabs" aria-label="Admin sections" data-i18n-aria-label="ariaAdminSections">
|
||||
<button type="button" class="nav-item active" data-nav="dashboard"><span class="nav-icon">⌁</span><span data-i18n="navDashboard">Dashboard</span></button>
|
||||
<button type="button" class="nav-item" data-nav="traffic"><span class="nav-icon">⇅</span><span data-i18n="navTraffic">Traffic</span></button>
|
||||
<button type="button" class="nav-item" data-nav="keys"><span class="nav-icon">⚿</span><span data-i18n="navKeys">Keys</span></button>
|
||||
@@ -41,14 +41,14 @@
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<button id="menuBtn" class="icon-btn mobile-only" type="button" aria-label="Menu">☰</button>
|
||||
<button id="menuBtn" class="icon-btn mobile-only" type="button" aria-label="Menu" data-i18n-aria-label="ariaMenu">☰</button>
|
||||
<div class="title-block">
|
||||
<p class="eyebrow" id="pageKicker">Local Admin</p>
|
||||
<h1 id="pageTitle">Dashboard</h1>
|
||||
<small id="lastRefresh">--</small>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<select id="languageSelect" class="language-select" aria-label="Language">
|
||||
<select id="languageSelect" class="language-select" aria-label="Language" data-i18n-aria-label="ariaLanguage">
|
||||
<option value="en">EN</option>
|
||||
<option value="ru">RU</option>
|
||||
</select>
|
||||
@@ -62,13 +62,15 @@
|
||||
<section class="visual-overview">
|
||||
<div>
|
||||
<p class="eyebrow">goTelegram Pro</p>
|
||||
<h2 id="visualTitle">443 shared edge</h2>
|
||||
<h2 id="visualTitle">Port 443</h2>
|
||||
<p id="visualText">Website, MTProxy and local admin status in one operational view.</p>
|
||||
</div>
|
||||
<div class="signal-map" aria-hidden="true">
|
||||
<span class="node node-site">HTTPS</span>
|
||||
<span class="node node-proxy">MTProto</span>
|
||||
<span class="node node-admin">Admin</span>
|
||||
<div class="port-map" id="port443Map">
|
||||
<div class="port-map-head">
|
||||
<span>443</span>
|
||||
<strong id="port443Summary">--</strong>
|
||||
</div>
|
||||
<div id="port443List" class="port-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -101,7 +103,7 @@
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n="servicesEyebrow">Services</p>
|
||||
<h2 data-i18n="servicesTitle">Runtime health</h2>
|
||||
<h2 class="with-help"><span data-i18n="servicesTitle">Service health</span><span class="info-hint" tabindex="0" data-i18n-title="servicesHelp" title="Service health">?</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-grid" id="services"></div>
|
||||
@@ -111,7 +113,7 @@
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n="runtimeEyebrow">Runtime</p>
|
||||
<h2 data-i18n="runtimeTitle">telemt summary</h2>
|
||||
<h2 class="with-help"><span data-i18n="runtimeTitle">telemt summary</span><span class="info-hint" tabindex="0" data-i18n-title="runtimeHelp" title="Runtime data">?</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="runtimeCards" class="runtime-grid"></div>
|
||||
@@ -129,8 +131,8 @@
|
||||
</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>
|
||||
<button id="collectStatsBtn" class="ghost" type="button" data-i18n="collectStats" data-i18n-title="collectStatsHelp">Collect</button>
|
||||
<button id="repairStatsBtn" type="button" data-i18n="repairStats" data-i18n-title="repairStatsHelp">Restart collector</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-summary">
|
||||
@@ -147,12 +149,24 @@
|
||||
<strong id="historyRows">0</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div class="traffic-controls">
|
||||
<div class="segmented" id="trafficRange" aria-label="Traffic range" data-i18n-aria-label="ariaTrafficRange">
|
||||
<button type="button" data-traffic-range="15m" data-i18n="range15m">15 min</button>
|
||||
<button type="button" data-traffic-range="1h" data-i18n="range1h">1 hour</button>
|
||||
<button type="button" data-traffic-range="24h" data-i18n="range24h">24 hours</button>
|
||||
<button type="button" data-traffic-range="month" data-i18n="rangeMonth">Month</button>
|
||||
</div>
|
||||
<div class="segmented" id="trafficView" aria-label="Traffic view" data-i18n-aria-label="ariaTrafficView">
|
||||
<button type="button" data-traffic-view="chart" data-i18n="viewChart">Chart</button>
|
||||
<button type="button" data-traffic-view="table" data-i18n="viewRows">Rows</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="trafficChart" class="traffic-chart"></div>
|
||||
<div class="table-wrap">
|
||||
<div class="table-wrap" id="trafficTableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="tableTime">Time</th>
|
||||
<th data-i18n="tablePeriod">Period</th>
|
||||
<th data-i18n="tableProxyDelta">Proxy delta</th>
|
||||
<th data-i18n="tableSiteDelta">Site delta</th>
|
||||
<th data-i18n="tableProxyTotal">Proxy total</th>
|
||||
@@ -285,7 +299,7 @@
|
||||
<div id="toast" class="toast"></div>
|
||||
<div id="promoModal" class="promo-modal" hidden>
|
||||
<div class="promo-card" role="dialog" aria-modal="true" aria-labelledby="promoTitle">
|
||||
<button id="promoClose" class="icon-btn ghost" type="button" aria-label="Close">×</button>
|
||||
<button id="promoClose" class="icon-btn ghost" type="button" aria-label="Close" data-i18n-aria-label="ariaClose">×</button>
|
||||
<p class="eyebrow" data-i18n="promoEyebrow">Promo</p>
|
||||
<h2 id="promoTitle" data-i18n="promoTitle">Support goTelegram Pro</h2>
|
||||
<div class="promo-grid">
|
||||
@@ -304,6 +318,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js?v=2.5.0-admin5" type="module"></script>
|
||||
<script src="/app.js?v=2.5.0-admin6" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -273,7 +273,7 @@ h2 {
|
||||
.visual-overview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 520px);
|
||||
gap: 20px;
|
||||
min-height: 170px;
|
||||
overflow: hidden;
|
||||
@@ -343,6 +343,82 @@ h2 {
|
||||
.node-proxy { right: 24px; top: 50%; transform: translateY(-50%); }
|
||||
.node-admin { left: 50%; bottom: 18px; transform: translateX(-50%); }
|
||||
|
||||
.port-map {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 128px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.port-map-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.port-map-head span {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--blue) 14%, transparent);
|
||||
color: var(--blue);
|
||||
font-weight: 900;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.port-status {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.port-status.ok { color: var(--green); }
|
||||
.port-status.warn { color: var(--amber); }
|
||||
.port-status.error { color: var(--red); }
|
||||
|
||||
.port-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.port-listener,
|
||||
.port-empty {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.port-listener strong,
|
||||
.port-listener span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.port-listener span,
|
||||
.port-listener small,
|
||||
.port-empty {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.port-listener.role-mtproxy { border-left: 4px solid var(--green); }
|
||||
.port-listener.role-site { border-left: 4px solid var(--blue); }
|
||||
.port-listener.role-xray { border-left: 4px solid var(--violet); }
|
||||
.port-listener.role-amneziawg { border-left: 4px solid var(--amber); }
|
||||
|
||||
.eyebrow {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -371,6 +447,30 @@ h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.with-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-hint {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 50%;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.info-hint:focus {
|
||||
outline: 3px solid color-mix(in srgb, var(--blue) 18%, transparent);
|
||||
}
|
||||
|
||||
.panel-actions,
|
||||
.inline-form {
|
||||
display: flex;
|
||||
@@ -553,6 +653,44 @@ h2 {
|
||||
.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-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-strong);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
min-height: 32px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
box-shadow: none;
|
||||
padding: 6px 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.segmented button:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, .08);
|
||||
}
|
||||
|
||||
.traffic-chart {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
@@ -562,6 +700,11 @@ h2 {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.traffic-chart.is-hidden,
|
||||
.table-wrap.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.traffic-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -645,6 +788,10 @@ tr:last-child td {
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.pending-row {
|
||||
outline: 2px solid color-mix(in srgb, var(--blue) 16%, transparent);
|
||||
}
|
||||
|
||||
td code {
|
||||
display: inline-block;
|
||||
max-width: 320px;
|
||||
@@ -934,6 +1081,24 @@ td small {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.traffic-controls,
|
||||
.segmented {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.traffic-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.inline-form input,
|
||||
.inline-form select,
|
||||
.inline-form button,
|
||||
@@ -1008,7 +1173,9 @@ td small {
|
||||
|
||||
.backup-item,
|
||||
.event,
|
||||
.settings-list > div {
|
||||
.settings-list > div,
|
||||
.port-listener,
|
||||
.port-empty {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user