v2.5.0: add local web admin dashboard

This commit is contained in:
Виталий Литвинов
2026-04-24 19:19:12 +03:00
parent ed9073f28f
commit 20103ccac8
15 changed files with 1668 additions and 12 deletions

533
admin-web/server.py Normal file
View File

@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""
GoTelegram local web admin.
The service is intentionally bound to 127.0.0.1:1984. Operators reach it
through an SSH tunnel; it must never be exposed directly on the public network.
"""
from __future__ import annotations
import csv
import hashlib
import http.cookies
import json
import mimetypes
import os
import re
import secrets
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
ADMIN_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_DIR", "/opt/gotelegram-admin"))
STATIC_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_STATIC", str(ADMIN_DIR / "static")))
TOKEN_FILE = Path(os.getenv("GOTELEGRAM_ADMIN_TOKEN_FILE", str(ADMIN_DIR / "token")))
GOTELEGRAM_CONFIG = Path(os.getenv("GOTELEGRAM_CONFIG", "/opt/gotelegram/config.json"))
TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml"))
HISTORY_FILE = Path(os.getenv("GOTELEGRAM_STATS_HISTORY", "/opt/gotelegram/stats_history.csv"))
CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json"))
BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups"))
INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram"))
HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
VERSION = "2.5.0"
USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]:
try:
proc = subprocess.run(
cmd,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
except Exception as exc: # pragma: no cover - system dependent
return 125, "", str(exc)
def ensure_token() -> str:
ADMIN_DIR.mkdir(parents=True, exist_ok=True)
if not TOKEN_FILE.exists() or TOKEN_FILE.stat().st_size < 24:
TOKEN_FILE.write_text(secrets.token_urlsafe(36) + "\n", encoding="utf-8")
os.chmod(TOKEN_FILE, 0o600)
return TOKEN_FILE.read_text(encoding="utf-8").strip()
def current_token() -> str:
try:
return ensure_token()
except OSError:
return ""
def load_json(path: Path, fallback: Any = None) -> Any:
try:
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
except Exception:
return fallback
def read_telemt_users() -> dict[str, str]:
if not TELEMT_CONFIG.exists():
return {}
users: dict[str, str] = {}
in_users = False
for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.strip()
if line == "[access.users]":
in_users = True
continue
if in_users and line.startswith("["):
break
if not in_users or not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
name = name.strip()
value = value.strip().split("#", 1)[0].strip()
if value.startswith('"') and '"' in value[1:]:
value = value[1:].split('"', 1)[0]
elif value.startswith("'") and "'" in value[1:]:
value = value[1:].split("'", 1)[0]
if USER_RE.match(name) and value:
users[name] = value
return users
def _ordered_user_lines(users: dict[str, str]) -> list[str]:
names = []
if "main" in users:
names.append("main")
names.extend(sorted(n for n in users if n != "main"))
return [f'{name} = "{users[name]}"' for name in names]
def write_telemt_users(users: dict[str, str]) -> None:
TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else []
rendered = _ordered_user_lines(users)
out: list[str] = []
in_users = False
found = False
for raw in lines:
if raw.strip() == "[access.users]":
found = True
in_users = True
out.append(raw)
out.extend(rendered)
continue
if in_users and raw.strip().startswith("["):
in_users = False
if in_users:
continue
out.append(raw)
if not found:
if out and out[-1].strip():
out.append("")
out.append("[access.users]")
out.extend(rendered)
TELEMT_CONFIG.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
os.chmod(TELEMT_CONFIG, 0o600)
def restart_service(name: str) -> bool:
code, _, _ = run(["systemctl", "restart", name], timeout=25)
return code == 0
def service_status(name: str) -> str:
code, stdout, _ = run(["systemctl", "is-active", name], timeout=3)
value = stdout.strip()
if code == 0 and value == "active":
return "running"
code, stdout, _ = run(["systemctl", "list-unit-files", f"{name}.service", "--no-legend"], timeout=3)
if code != 0 or not stdout.strip():
return "not_installed"
if value in {"failed", "inactive", "activating", "deactivating"}:
return value
return "stopped"
def public_ip() -> str:
code, stdout, _ = run(["curl", "-s", "-4", "--max-time", "3", "https://api.ipify.org"], timeout=5)
ip = stdout.strip()
if code == 0 and re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip):
return ip
code, stdout, _ = run(["hostname", "-I"], timeout=3)
return stdout.split()[0] if code == 0 and stdout.split() else "0.0.0.0"
def proxy_link(secret: str) -> str:
config = load_json(GOTELEGRAM_CONFIG, {}) or {}
mode = str(config.get("mode", "lite"))
port = int(config.get("port", 443) or 443)
domain = str(config.get("domain", "") or "")
mask_host = str(config.get("mask_host", "") or "")
if mode == "pro" and domain:
host_hex = domain.encode().hex()
return f"tg://proxy?server={domain}&port={port}&secret=ee{secret}{host_hex}"
server = public_ip()
if mask_host:
host_hex = mask_host.encode().hex()
return f"tg://proxy?server={server}&port={port}&secret=ee{secret}{host_hex}"
return f"tg://proxy?server={server}&port={port}&secret={secret}"
def telemt_api(path: str) -> Any:
try:
with urllib.request.urlopen(f"http://127.0.0.1:9091{path}", timeout=1.8) as resp:
payload = resp.read(256 * 1024)
return json.loads(payload.decode("utf-8"))
except Exception:
return None
def load_stats_history(limit: int = 240) -> list[dict[str, int]]:
if not HISTORY_FILE.exists():
return []
rows: list[dict[str, int]] = []
try:
with HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh:
for row in csv.DictReader(fh):
try:
rows.append({
"epoch": int(row.get("epoch") or 0),
"proxy_bytes": int(row.get("proxy_bytes") or 0),
"site_bytes": int(row.get("site_bytes") or 0),
})
except ValueError:
continue
except OSError:
return []
rows = rows[-limit:]
previous = None
enriched: list[dict[str, int]] = []
for row in rows:
item = dict(row)
if previous:
item["proxy_delta"] = max(0, row["proxy_bytes"] - previous["proxy_bytes"])
item["site_delta"] = max(0, row["site_bytes"] - previous["site_bytes"])
else:
item["proxy_delta"] = 0
item["site_delta"] = 0
enriched.append(item)
previous = row
return enriched
def list_backups() -> list[dict[str, Any]]:
if not BACKUP_DIR.exists():
return []
items = []
for path in sorted(BACKUP_DIR.glob("*.tar.gz*"), key=lambda p: p.stat().st_mtime, reverse=True):
if path.name.endswith(".sha256"):
continue
try:
st = path.stat()
except OSError:
continue
items.append({
"name": path.name,
"path": str(path),
"size": st.st_size,
"mtime": int(st.st_mtime),
"encrypted": path.name.endswith(".enc"),
})
return items[:30]
def create_backup() -> tuple[bool, str]:
script = (
"source /opt/gotelegram/lib/common.sh; "
"source /opt/gotelegram/lib/i18n.sh; "
"source /opt/gotelegram/lib/telemt.sh; "
"source /opt/gotelegram/lib/website.sh; "
"source /opt/gotelegram/lib/backup.sh; "
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
"create_backup \"\""
)
code, stdout, stderr = run(["bash", "-lc", script], timeout=180)
text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
return code == 0, text
def user_payload(name: str, secret: str, include_runtime: bool = False) -> dict[str, Any]:
item: dict[str, Any] = {
"name": name,
"secret": secret,
"link": proxy_link(secret),
"main": name == "main",
}
if include_runtime:
item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}")
return item
def overview_payload() -> dict[str, Any]:
config = load_json(GOTELEGRAM_CONFIG, {}) or {}
users = read_telemt_users()
current = load_json(CURRENT_STATS, {}) or {}
summary = telemt_api("/v1/stats/summary")
services = {
"telemt": service_status("telemt"),
"nginx": service_status("nginx"),
"bot": service_status("gotelegram-bot"),
"stats": service_status("gotelegram-stats"),
"admin": service_status("gotelegram-admin"),
}
return {
"version": VERSION,
"time": utc_now(),
"config": config,
"users_count": len(users),
"services": services,
"stats_current": current,
"stats_history": load_stats_history(),
"runtime_summary": summary,
"backups": list_backups(),
}
class AdminHandler(BaseHTTPRequestHandler):
server_version = "GoTelegramAdmin/2.5.0"
def log_message(self, fmt: str, *args: Any) -> None:
print("%s - %s" % (self.address_string(), fmt % args))
def token_from_request(self) -> str:
parsed = urllib.parse.urlparse(self.path)
qs = urllib.parse.parse_qs(parsed.query)
if qs.get("token", [""])[0]:
return qs["token"][0]
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[len("Bearer "):].strip()
cookie_header = self.headers.get("Cookie", "")
if cookie_header:
cookie = http.cookies.SimpleCookie()
cookie.load(cookie_header)
if "gtauth" in cookie:
return cookie["gtauth"].value
return ""
def is_authorized(self) -> bool:
token = current_token()
candidate = self.token_from_request()
return bool(token and candidate and secrets.compare_digest(token, candidate))
def send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_error_json(self, status: int, message: str) -> None:
self.send_json({"ok": False, "error": message}, status)
def read_json_body(self) -> Any:
length = int(self.headers.get("Content-Length", "0") or 0)
if length > 1024 * 1024:
raise ValueError("request body too large")
if length <= 0:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
def require_auth(self) -> bool:
if self.is_authorized():
return True
self.send_error_json(401, "unauthorized")
return False
def require_write_guard(self) -> bool:
if self.command in {"POST", "PUT", "PATCH", "DELETE"} and self.headers.get("X-GoTelegram-Admin") != "1":
self.send_error_json(403, "missing write guard")
return False
return True
def route_get_api(self, parsed: urllib.parse.ParseResult) -> None:
if not self.require_auth():
return
path = parsed.path
if path == "/api/overview":
self.send_json({"ok": True, "data": overview_payload()})
elif path == "/api/users":
users = read_telemt_users()
self.send_json({"ok": True, "data": [user_payload(k, v) for k, v in sorted(users.items())]})
elif path.startswith("/api/users/"):
name = urllib.parse.unquote(path[len("/api/users/"):])
users = read_telemt_users()
if name not in users:
self.send_error_json(404, "user not found")
return
self.send_json({"ok": True, "data": user_payload(name, users[name], include_runtime=True)})
elif path == "/api/backups":
self.send_json({"ok": True, "data": list_backups()})
elif path == "/api/logs":
qs = urllib.parse.parse_qs(parsed.query)
service = qs.get("service", ["telemt"])[0]
allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"}
if service not in allowed:
self.send_error_json(400, "unsupported service")
return
code, stdout, stderr = run(["journalctl", "-u", service, "-n", "120", "--no-pager"], timeout=8)
self.send_json({"ok": code == 0, "data": stdout if code == 0 else stderr})
else:
self.send_error_json(404, "not found")
def route_post_api(self, parsed: urllib.parse.ParseResult) -> None:
if not self.require_auth() or not self.require_write_guard():
return
path = parsed.path
try:
body = self.read_json_body()
except Exception as exc:
self.send_error_json(400, str(exc))
return
if path == "/api/users":
name = str(body.get("name", "")).strip()
if not USER_RE.match(name):
self.send_error_json(400, "invalid user name")
return
users = read_telemt_users()
if name in users:
self.send_error_json(409, "user already exists")
return
seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode()
secret = hashlib.sha256(seed).hexdigest()[:32]
users[name] = secret
try:
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), "restarted": restarted})
elif path == "/api/backups":
ok, result = create_backup()
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
elif path.startswith("/api/services/") and path.endswith("/restart"):
service = path[len("/api/services/"):-len("/restart")]
allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats"}
if service not in allowed:
self.send_error_json(400, "unsupported service")
return
ok = restart_service(service)
self.send_json({"ok": ok, "status": service_status(service)}, 200 if ok else 500)
else:
self.send_error_json(404, "not found")
def route_delete_api(self, parsed: urllib.parse.ParseResult) -> None:
if not self.require_auth() or not self.require_write_guard():
return
path = parsed.path
if not path.startswith("/api/users/"):
self.send_error_json(404, "not found")
return
name = urllib.parse.unquote(path[len("/api/users/"):])
if name == "main":
self.send_error_json(400, "main user cannot be deleted")
return
users = read_telemt_users()
if name not in users:
self.send_error_json(404, "user not found")
return
users.pop(name, None)
try:
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, "restarted": restarted})
def send_static(self, parsed: urllib.parse.ParseResult) -> None:
qs = urllib.parse.parse_qs(parsed.query)
if qs.get("token", [""])[0] and self.is_authorized():
self.send_response(302)
self.send_header("Location", "/")
self.send_header("Set-Cookie", "gtauth=%s; Path=/; HttpOnly; SameSite=Strict" % qs["token"][0])
self.send_header("Cache-Control", "no-store")
self.end_headers()
return
rel = parsed.path.lstrip("/") or "index.html"
if rel.startswith("api/") or ".." in rel.split("/"):
self.send_error(404)
return
path = STATIC_DIR / rel
if path.is_dir():
path = path / "index.html"
if not path.exists():
path = STATIC_DIR / "index.html"
try:
body = path.read_bytes()
except OSError:
self.send_error(404)
return
mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Cache-Control", "no-store" if path.name == "index.html" else "public, max-age=3600")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path.startswith("/api/"):
self.route_get_api(parsed)
else:
self.send_static(parsed)
def do_POST(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path.startswith("/api/"):
self.route_post_api(parsed)
else:
self.send_error(404)
def do_DELETE(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path.startswith("/api/"):
self.route_delete_api(parsed)
else:
self.send_error(404)
def main() -> None:
ensure_token()
if not STATIC_DIR.exists():
raise SystemExit(f"static dir not found: {STATIC_DIR}")
httpd = ThreadingHTTPServer((HOST, PORT), AdminHandler)
print(f"GoTelegram admin listening on http://{HOST}:{PORT}")
httpd.serve_forever()
if __name__ == "__main__":
main()

300
admin-web/static/app.js Normal file
View File

@@ -0,0 +1,300 @@
const $ = (sel) => document.querySelector(sel);
const state = { overview: null, users: [], events: [] };
const fmtBytes = (value = 0) => {
const units = ["B", "KB", "MB", "GB", "TB"];
let n = Number(value) || 0;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
const fmtDate = (epoch) => new Date(epoch * 1000).toLocaleString();
const toast = (message) => {
const el = $("#toast");
el.textContent = message;
el.classList.add("show");
clearTimeout(toast._timer);
toast._timer = setTimeout(() => el.classList.remove("show"), 2600);
};
const event = (title, detail = "") => {
state.events.unshift({ title, detail, time: new Date() });
state.events = state.events.slice(0, 8);
renderEvents();
};
async function api(path, options = {}) {
const headers = {
"Accept": "application/json",
"X-GoTelegram-Admin": "1",
...(options.headers || {}),
};
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const res = await fetch(path, { ...options, headers, credentials: "same-origin" });
if (res.status === 401) {
$("#authLock").classList.remove("hidden");
throw new Error("Unauthorized");
}
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`);
return data.data ?? data;
}
function renderServices(services = {}) {
const items = [
{ key: "telemt", label: "telemt", api: "telemt" },
{ key: "nginx", label: "nginx", api: "nginx" },
{ key: "bot", label: "bot", api: "gotelegram-bot" },
{ key: "stats", label: "stats", api: "gotelegram-stats" },
{ key: "admin", label: "admin", api: "gotelegram-admin" },
];
$("#services").innerHTML = items.map((item) => {
const status = services[item.key] || "unknown";
return `<article class="service ${status}">
<strong>${item.label}</strong>
<div class="status"><span class="dot"></span><span>${status}</span></div>
<button class="soft" data-restart="${item.api}" ${item.key === "admin" ? "disabled" : ""}>Restart</button>
</article>`;
}).join("");
}
function renderOverview() {
const data = state.overview;
if (!data) return;
const cfg = data.config || {};
const stats = data.stats_current || {};
$("#metricMode").textContent = cfg.mode || "--";
$("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--";
$("#metricUsers").textContent = data.users_count ?? 0;
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
$("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} packets`;
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
$("#metricSitePackets").textContent = `${stats.site_pkts || 0} packets`;
$("#runtimeBox").textContent = JSON.stringify(data.runtime_summary || {}, null, 2);
renderServices(data.services || {});
renderBackups(data.backups || []);
drawTrafficChart(data.stats_history || []);
}
function renderUsers() {
const tbody = $("#usersTable");
if (!state.users.length) {
tbody.innerHTML = `<tr><td colspan="4">No keys yet</td></tr>`;
return;
}
tbody.innerHTML = state.users.map((user) => `
<tr>
<td><strong>${escapeHtml(user.name)}</strong>${user.main ? " <small>main</small>" : ""}</td>
<td><code title="${escapeHtml(user.secret)}">${escapeHtml(user.secret)}</code></td>
<td><button class="soft" data-copy="${escapeAttr(user.link)}">Copy link</button></td>
<td class="actions">
<button class="soft" data-copy="${escapeAttr(user.secret)}">Copy secret</button>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>Delete</button>
</td>
</tr>
`).join("");
}
function renderBackups(backups) {
const box = $("#backupsList");
if (!backups.length) {
box.innerHTML = `<div class="backup-item"><strong>No backups</strong><span></span></div>`;
return;
}
box.innerHTML = backups.map((item) => `
<div class="backup-item">
<div>
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.path)} · ${fmtDate(item.mtime)}</span>
</div>
<div>${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}</div>
</div>
`).join("");
}
function renderEvents() {
$("#events").innerHTML = state.events.map((item) => `
<div class="event">
<strong>${escapeHtml(item.title)}</strong>
<small>${escapeHtml(item.detail || item.time.toLocaleTimeString())}</small>
</div>
`).join("");
}
function drawTrafficChart(rows) {
const canvas = $("#trafficChart");
const ctx = canvas.getContext("2d");
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(320, rect.width) * ratio;
canvas.height = 260 * ratio;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
const w = canvas.width / ratio;
const h = canvas.height / ratio;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
const pad = { l: 48, r: 18, t: 20, b: 34 };
const points = rows.length ? rows : [{ proxy_delta: 0, site_delta: 0 }, { proxy_delta: 0, site_delta: 0 }];
const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0)));
const plotW = w - pad.l - pad.r;
const plotH = h - pad.t - pad.b;
ctx.strokeStyle = "#dfe6f1";
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= 4; i += 1) {
const y = pad.t + (plotH / 4) * i;
ctx.moveTo(pad.l, y);
ctx.lineTo(w - pad.r, y);
}
ctx.stroke();
const line = (key, color) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2.4;
ctx.beginPath();
points.forEach((p, i) => {
const x = pad.l + (plotW * i) / Math.max(1, points.length - 1);
const y = pad.t + plotH - ((p[key] || 0) / max) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
};
line("proxy_delta", "#2563eb");
line("site_delta", "#0f9f6e");
ctx.fillStyle = "#647087";
ctx.font = "12px system-ui";
ctx.fillText(`max ${fmtBytes(max)}/min`, pad.l, 14);
ctx.fillStyle = "#2563eb";
ctx.fillText("proxy", pad.l, h - 10);
ctx.fillStyle = "#0f9f6e";
ctx.fillText("site", pad.l + 58, h - 10);
}
async function refreshAll() {
const btn = $("#refreshBtn");
btn.disabled = true;
try {
state.overview = await api("/api/overview");
state.users = await api("/api/users");
renderOverview();
renderUsers();
} catch (err) {
if (err.message !== "Unauthorized") toast(err.message);
} finally {
btn.disabled = false;
}
}
async function addUser(name) {
const data = await api("/api/users", {
method: "POST",
body: JSON.stringify({ name }),
});
event("Key created", data.name);
toast("Key created");
await refreshAll();
}
async function deleteUser(name) {
await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" });
event("Key deleted", name);
toast("Key deleted");
await refreshAll();
}
async function createBackup() {
const btn = $("#createBackupBtn");
btn.disabled = true;
try {
const data = await api("/api/backups", { method: "POST", body: "{}" });
event("Backup created", data.path || "");
toast("Backup created");
await refreshAll();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
}
async function loadLogs() {
const service = $("#logService").value;
$("#logsBox").textContent = "Loading...";
try {
$("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`);
} catch (err) {
$("#logsBox").textContent = err.message;
}
}
async function restartService(name) {
await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" });
event("Service restarted", name);
toast(`${name} restarted`);
await refreshAll();
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[ch]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
document.addEventListener("click", async (eventObj) => {
const target = eventObj.target.closest("button");
if (!target) return;
if (target.dataset.copy) {
await navigator.clipboard.writeText(target.dataset.copy);
toast("Copied");
}
if (target.dataset.delete) {
const name = target.dataset.delete;
if (confirm(`Delete key ${name}?`)) deleteUser(name).catch((err) => toast(err.message));
}
if (target.dataset.restart) {
const name = target.dataset.restart;
if (confirm(`Restart ${name}?`)) restartService(name).catch((err) => toast(err.message));
}
});
$("#addUserForm").addEventListener("submit", (eventObj) => {
eventObj.preventDefault();
const input = $("#userName");
const name = input.value.trim();
if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) {
toast("Use latin letters, digits, _, . or -");
return;
}
input.value = "";
addUser(name).catch((err) => toast(err.message));
});
$("#refreshBtn").addEventListener("click", refreshAll);
$("#createBackupBtn").addEventListener("click", createBackup);
$("#loadLogsBtn").addEventListener("click", loadLogs);
window.addEventListener("resize", () => state.overview && drawTrafficChart(state.overview.stats_history || []));
document.querySelectorAll("nav a").forEach((link) => {
link.addEventListener("click", () => {
document.querySelectorAll("nav a").forEach((item) => item.classList.remove("active"));
link.classList.add("active");
});
});
refreshAll();
loadLogs();

158
admin-web/static/index.html Normal file
View File

@@ -0,0 +1,158 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoTelegram Admin</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="authLock" class="auth-lock hidden">
<div class="auth-panel">
<div class="mark">GT</div>
<h1>GoTelegram Admin</h1>
<p>Откройте ссылку из Telegram-бота после запуска SSH-туннеля.</p>
</div>
</div>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">GT</div>
<div>
<strong>GoTelegram</strong>
<span>Local Admin</span>
</div>
</div>
<nav>
<a href="#dashboard" class="active">Dashboard</a>
<a href="#keys">Keys</a>
<a href="#traffic">Traffic</a>
<a href="#backups">Backups</a>
<a href="#logs">Logs</a>
</nav>
</aside>
<main>
<header class="topbar">
<div>
<p class="eyebrow">127.0.0.1:1984</p>
<h1>GoTelegram Admin</h1>
</div>
<button id="refreshBtn" class="ghost">Refresh</button>
</header>
<section id="dashboard" class="section">
<div class="metric-grid">
<article class="metric-card">
<span>Mode</span>
<strong id="metricMode">--</strong>
<small id="metricDomain">--</small>
</article>
<article class="metric-card">
<span>Keys</span>
<strong id="metricUsers">0</strong>
<small>configured users</small>
</article>
<article class="metric-card">
<span>Proxy Traffic</span>
<strong id="metricProxyTraffic">0 B</strong>
<small id="metricProxyPackets">0 packets</small>
</article>
<article class="metric-card">
<span>Site Traffic</span>
<strong id="metricSiteTraffic">0 B</strong>
<small id="metricSitePackets">0 packets</small>
</article>
</div>
<div class="service-grid" id="services"></div>
</section>
<section id="traffic" class="section split">
<div>
<div class="section-head">
<div>
<p class="eyebrow">Traffic</p>
<h2>History</h2>
</div>
</div>
<div class="chart-wrap">
<canvas id="trafficChart" height="260"></canvas>
</div>
</div>
<aside class="activity">
<p class="eyebrow">Runtime</p>
<pre id="runtimeBox">{}</pre>
</aside>
</section>
<section id="keys" class="section">
<div class="section-head">
<div>
<p class="eyebrow">Access</p>
<h2>User keys</h2>
</div>
<form id="addUserForm" class="inline-form">
<input id="userName" autocomplete="off" placeholder="client-name">
<button type="submit">Add key</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Secret</th>
<th>Link</th>
<th></th>
</tr>
</thead>
<tbody id="usersTable"></tbody>
</table>
</div>
</section>
<section id="backups" class="section split">
<div>
<div class="section-head">
<div>
<p class="eyebrow">Snapshots</p>
<h2>Backups</h2>
</div>
<button id="createBackupBtn">Create backup</button>
</div>
<div id="backupsList" class="backup-list"></div>
</div>
<aside class="activity">
<p class="eyebrow">Events</p>
<div id="events"></div>
</aside>
</section>
<section id="logs" class="section">
<div class="section-head">
<div>
<p class="eyebrow">Journal</p>
<h2>Logs</h2>
</div>
<div class="inline-form">
<select id="logService">
<option value="telemt">telemt</option>
<option value="nginx">nginx</option>
<option value="gotelegram-bot">bot</option>
<option value="gotelegram-stats">stats</option>
<option value="gotelegram-admin">admin</option>
</select>
<button id="loadLogsBtn" type="button">Load</button>
</div>
</div>
<pre id="logsBox" class="logs"></pre>
</section>
</main>
</div>
<div id="toast" class="toast"></div>
<script src="/app.js" type="module"></script>
</body>
</html>

410
admin-web/static/styles.css Normal file
View File

@@ -0,0 +1,410 @@
:root {
color-scheme: light;
--bg: #f5f7fb;
--panel: #ffffff;
--panel-2: #f9fbff;
--text: #172033;
--muted: #647087;
--line: #dfe6f1;
--blue: #2563eb;
--green: #0f9f6e;
--amber: #c77700;
--red: #d92d20;
--ink: #0d172a;
--shadow: 0 18px 55px rgba(16, 24, 40, 0.08);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select {
font: inherit;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 14px;
background: var(--ink);
color: #fff;
cursor: pointer;
transition: transform .18s ease, box-shadow .18s ease, background .18s ease;
}
button:hover { transform: translateY(-1px); box-shadow: 0 12px 25px rgba(16, 24, 40, .16); }
button:disabled { opacity: .55; cursor: wait; transform: none; box-shadow: none; }
button.ghost { background: #e8eef8; color: var(--ink); }
button.danger { background: #fee4e2; color: #b42318; }
button.soft { background: #eef4ff; color: #1d4ed8; }
input, select {
min-height: 42px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--text);
padding: 0 12px;
outline: none;
}
input:focus, select:focus {
border-color: #8bb4ff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .12);
}
.shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 24px 18px;
background: #0f172a;
color: #e5edf8;
}
.brand {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 30px;
}
.brand-mark, .mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 8px;
background: #29b57f;
color: white;
font-weight: 800;
}
.brand span {
display: block;
color: #93a4bc;
font-size: 12px;
}
nav {
display: grid;
gap: 6px;
}
nav a {
color: #b8c5d8;
text-decoration: none;
border-radius: 8px;
padding: 10px 12px;
transition: background .18s ease, color .18s ease;
}
nav a:hover, nav a.active {
background: rgba(255,255,255,.08);
color: #fff;
}
main {
width: min(1400px, 100%);
padding: 26px;
}
.topbar, .section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.topbar {
margin-bottom: 24px;
}
h1, h2, p {
margin: 0;
}
h1 {
font-size: 30px;
line-height: 1.15;
}
h2 {
font-size: 20px;
}
.eyebrow {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
font-weight: 700;
}
.section {
margin-bottom: 24px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card, .chart-wrap, .table-wrap, .activity, .backup-list, .logs {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.metric-card {
min-height: 124px;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.metric-card span, .metric-card small {
color: var(--muted);
}
.metric-card strong {
font-size: 28px;
line-height: 1.1;
}
.service-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.service {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-2);
padding: 14px;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
}
.running .dot { background: var(--green); }
.failed .dot, .not_installed .dot { background: var(--red); }
.inactive .dot, .stopped .dot { background: var(--amber); }
.split {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 14px;
}
.chart-wrap {
margin-top: 14px;
padding: 16px;
}
.activity {
padding: 16px;
overflow: hidden;
}
pre {
white-space: pre-wrap;
word-break: break-word;
}
#runtimeBox, .logs {
max-height: 340px;
overflow: auto;
color: #344054;
background: #f7f9fc;
border-radius: 8px;
padding: 12px;
}
.inline-form {
display: flex;
align-items: center;
gap: 8px;
}
.table-wrap {
margin-top: 14px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
th {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
}
td code {
display: inline-block;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.backup-list {
margin-top: 14px;
padding: 8px;
}
.backup-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
padding: 12px;
border-radius: 8px;
}
.backup-item + .backup-item {
border-top: 1px solid var(--line);
}
.backup-item span {
display: block;
color: var(--muted);
font-size: 12px;
}
#events {
display: grid;
gap: 10px;
}
.event {
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.event small {
display: block;
color: var(--muted);
}
.toast {
position: fixed;
right: 22px;
bottom: 22px;
min-width: 240px;
max-width: 420px;
padding: 13px 14px;
border-radius: 8px;
background: #0f172a;
color: white;
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity .18s ease, transform .18s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.auth-lock {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: rgba(245, 247, 251, .92);
backdrop-filter: blur(8px);
}
.auth-panel {
width: min(420px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: 8px;
background: white;
padding: 26px;
box-shadow: var(--shadow);
}
.auth-panel h1 {
margin-top: 16px;
}
.auth-panel p {
margin-top: 8px;
color: var(--muted);
}
.hidden {
display: none;
}
@media (max-width: 1040px) {
.shell { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
display: flex;
align-items: center;
justify-content: space-between;
}
nav { display: flex; flex-wrap: wrap; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.split { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
main { padding: 18px; }
.sidebar { padding: 18px; align-items: flex-start; flex-direction: column; }
.metric-grid, .service-grid { grid-template-columns: 1fr; }
.topbar, .section-head { align-items: flex-start; flex-direction: column; }
.inline-form { width: 100%; flex-wrap: wrap; }
.inline-form input, .inline-form select { flex: 1 1 180px; }
.inline-form button { flex: 0 0 auto; }
}