mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 11:26:03 +00:00
v2.5.0: add shared 443 and per-user traffic
This commit is contained in:
@@ -34,12 +34,14 @@ STATIC_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_STATIC", str(ADMIN_DIR / "static")
|
||||
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"))
|
||||
USER_HISTORY_FILE = Path(os.getenv("GOTELEGRAM_USER_STATS_HISTORY", "/opt/gotelegram/user_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"))
|
||||
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"))
|
||||
SHARED_443_CONFIG = Path(os.getenv("GOTELEGRAM_SHARED_443", "/opt/gotelegram/shared-443.json"))
|
||||
|
||||
HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
|
||||
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
|
||||
@@ -421,14 +423,81 @@ def read_telemt_edge_settings() -> dict[str, Any]:
|
||||
return settings
|
||||
|
||||
|
||||
def load_shared443_config() -> dict[str, Any]:
|
||||
raw = load_json(SHARED_443_CONFIG, {}) or {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
routes = raw.get("xray_routes") if isinstance(raw.get("xray_routes"), list) else []
|
||||
clean_routes = []
|
||||
for item in routes:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
public = str(item.get("public") or item.get("domain") or "").strip()
|
||||
target = str(item.get("target") or "").strip()
|
||||
if public and target:
|
||||
clean_routes.append({"public": public, "target": target})
|
||||
return {
|
||||
"enabled": bool(raw.get("enabled")),
|
||||
"dispatcher": str(raw.get("dispatcher") or "nginx-stream"),
|
||||
"public_port": _int_value(raw.get("public_port") or 443) or 443,
|
||||
"telemt_target": str(raw.get("telemt_target") or "127.0.0.1:7443"),
|
||||
"site_target": str(raw.get("site_target") or ""),
|
||||
"xray_routes": clean_routes,
|
||||
"updated_at": str(raw.get("updated_at") or ""),
|
||||
}
|
||||
|
||||
|
||||
def listener_for_target(target: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
port = int(target.rsplit(":", 1)[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
listeners, _ = collect_port_listeners(port)
|
||||
return listeners[0] if listeners else None
|
||||
|
||||
|
||||
def routed_behind_443() -> list[dict[str, Any]]:
|
||||
config = load_json(GOTELEGRAM_CONFIG, {}) or {}
|
||||
mode = str(config.get("mode") or "")
|
||||
domain = str(config.get("domain") or "")
|
||||
settings = read_telemt_edge_settings()
|
||||
shared = load_shared443_config()
|
||||
mask_port = int(settings.get("mask_port") or 0)
|
||||
tls_domain = str(settings.get("tls_domain") or domain)
|
||||
routes: list[dict[str, Any]] = []
|
||||
if shared.get("enabled"):
|
||||
telemt_target = str(shared.get("telemt_target") or "127.0.0.1:7443")
|
||||
telemt_listener = listener_for_target(telemt_target)
|
||||
routes.append({
|
||||
"role": "mtproxy",
|
||||
"proto": "MTProxy",
|
||||
"public": f"{domain or tls_domain or 'default'}:443",
|
||||
"target": telemt_target,
|
||||
"process": (telemt_listener or {}).get("process") or "telemt",
|
||||
"pid": (telemt_listener or {}).get("pid") or "",
|
||||
"status": service_status("telemt"),
|
||||
"via": "nginx stream ssl_preread",
|
||||
"tls_domain": tls_domain,
|
||||
"details": ["default -> telemt"] if not shared.get("xray_routes") else [],
|
||||
})
|
||||
for item in shared.get("xray_routes", []):
|
||||
target = item.get("target", "")
|
||||
listener = listener_for_target(target)
|
||||
public = item.get("public", "")
|
||||
if public and ":" not in public:
|
||||
public = f"{public}:443"
|
||||
routes.append({
|
||||
"role": "xray",
|
||||
"proto": "VLESS",
|
||||
"public": public or "xray:443",
|
||||
"target": target,
|
||||
"process": (listener or {}).get("process") or "xray",
|
||||
"pid": (listener or {}).get("pid") or "",
|
||||
"status": "running" if listener else "not_installed",
|
||||
"via": "nginx stream ssl_preread",
|
||||
"tls_domain": public.split(":", 1)[0] if public else "",
|
||||
"details": [],
|
||||
})
|
||||
if mode == "pro" and domain and mask_port and mask_port != 443:
|
||||
internal, _ = collect_port_listeners(mask_port)
|
||||
site_listener = next((item for item in internal if item.get("role") == "site"), None)
|
||||
@@ -449,11 +518,18 @@ def routed_behind_443() -> list[dict[str, Any]]:
|
||||
|
||||
def port_443_status() -> dict[str, Any]:
|
||||
listeners, errors = collect_port_listeners(443)
|
||||
shared = load_shared443_config()
|
||||
if shared.get("enabled"):
|
||||
for item in listeners:
|
||||
if item.get("role") == "site" and "nginx" in str(item.get("process", "")).lower():
|
||||
item["role"] = "edge"
|
||||
item["details"] = "nginx stream ssl_preread"
|
||||
return {
|
||||
"checked_at": int(time.time()),
|
||||
"configured_port": read_telemt_port(),
|
||||
"listeners": listeners,
|
||||
"routes": routed_behind_443(),
|
||||
"shared_443": shared,
|
||||
"ok": not errors,
|
||||
"error": "; ".join(errors[:2]),
|
||||
}
|
||||
@@ -567,6 +643,78 @@ def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]:
|
||||
return enriched
|
||||
|
||||
|
||||
def _int_value(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def load_user_stats_history(name: str | None = None, limit: int | None = 240) -> list[dict[str, Any]]:
|
||||
if not USER_HISTORY_FILE.exists():
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
with USER_HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
user = str(row.get("user") or "").strip()
|
||||
if name is not None and user != name:
|
||||
continue
|
||||
if not USER_RE.match(user):
|
||||
continue
|
||||
rows.append({
|
||||
"epoch": _int_value(row.get("epoch")),
|
||||
"user": user,
|
||||
"total_octets": _int_value(row.get("total_octets")),
|
||||
"current_connections": _int_value(row.get("current_connections")),
|
||||
"active_unique_ips": _int_value(row.get("active_unique_ips")),
|
||||
"recent_unique_ips": _int_value(row.get("recent_unique_ips")),
|
||||
})
|
||||
except OSError:
|
||||
return []
|
||||
rows.sort(key=lambda item: (item["user"], item["epoch"]))
|
||||
if limit and name is not None:
|
||||
rows = rows[-limit:]
|
||||
|
||||
previous_by_user: dict[str, dict[str, Any]] = {}
|
||||
enriched: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
previous = previous_by_user.get(row["user"])
|
||||
item["total_delta"] = max(0, row["total_octets"] - previous["total_octets"]) if previous else 0
|
||||
enriched.append(item)
|
||||
previous_by_user[row["user"]] = row
|
||||
if limit and name is None:
|
||||
enriched = enriched[-limit:]
|
||||
return enriched
|
||||
|
||||
|
||||
def latest_user_stats() -> dict[str, dict[str, Any]]:
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for row in load_user_stats_history(limit=None):
|
||||
if row["epoch"] >= latest.get(row["user"], {}).get("epoch", 0):
|
||||
latest[row["user"]] = row
|
||||
return latest
|
||||
|
||||
|
||||
def runtime_user_traffic(name: str, enabled: bool = True) -> dict[str, Any]:
|
||||
if not enabled:
|
||||
return {"ok": False, "enabled": False, "total_octets": 0, "current_connections": 0, "active_unique_ips": 0, "recent_unique_ips": 0}
|
||||
payload = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}")
|
||||
data = payload.get("data", payload) if isinstance(payload, dict) else {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
return {
|
||||
"ok": bool(payload),
|
||||
"enabled": True,
|
||||
"total_octets": _int_value(data.get("total_octets")),
|
||||
"current_connections": _int_value(data.get("current_connections")),
|
||||
"active_unique_ips": _int_value(data.get("active_unique_ips")),
|
||||
"recent_unique_ips": _int_value(data.get("recent_unique_ips")),
|
||||
"in_runtime": bool(data.get("in_runtime")) if data else False,
|
||||
}
|
||||
|
||||
|
||||
def history_limit_for_range(range_key: str) -> int:
|
||||
return {
|
||||
"15m": 180,
|
||||
@@ -617,6 +765,32 @@ def traffic_interval_summaries(rows: list[dict[str, int]]) -> list[dict[str, Any
|
||||
return summaries
|
||||
|
||||
|
||||
def user_traffic_interval_summaries(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not rows:
|
||||
return [
|
||||
{"range": key, "points": 0, "from": 0, "to": 0, "total_delta": 0, "total_octets": 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, "total_delta": 0, "total_octets": 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),
|
||||
"total_delta": sum(max(0, int(item.get("total_delta", 0))) for item in window),
|
||||
"total_octets": int(last.get("total_octets", 0)),
|
||||
})
|
||||
return summaries
|
||||
|
||||
|
||||
def count_history_rows() -> int:
|
||||
if not HISTORY_FILE.exists():
|
||||
return 0
|
||||
@@ -627,6 +801,18 @@ def count_history_rows() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def count_user_history_rows(name: str | None = None) -> int:
|
||||
if not USER_HISTORY_FILE.exists():
|
||||
return 0
|
||||
try:
|
||||
with USER_HISTORY_FILE.open("r", encoding="utf-8", errors="ignore") as fh:
|
||||
if name is None:
|
||||
return sum(1 for line in fh if line and line[0].isdigit())
|
||||
return sum(1 for line in fh if line.startswith(tuple(str(d) for d in range(10))) and f",{name}," in line)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def stats_status(current: dict[str, Any] | None = None, history: list[dict[str, int]] | None = None) -> dict[str, Any]:
|
||||
current = current if current is not None else (load_json(CURRENT_STATS, {}) or {})
|
||||
history = history if history is not None else load_stats_history(limit=2)
|
||||
@@ -740,7 +926,13 @@ def read_log_payload(service: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def user_payload(name: str, secret: str, enabled: bool = True, include_runtime: bool = False) -> dict[str, Any]:
|
||||
def user_payload(
|
||||
name: str,
|
||||
secret: str,
|
||||
enabled: bool = True,
|
||||
include_runtime: bool = False,
|
||||
traffic_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
item: dict[str, Any] = {
|
||||
"name": name,
|
||||
"secret": secret,
|
||||
@@ -748,6 +940,14 @@ def user_payload(name: str, secret: str, enabled: bool = True, include_runtime:
|
||||
"main": name == "main",
|
||||
"enabled": bool(enabled),
|
||||
}
|
||||
if traffic_snapshot:
|
||||
item["traffic"] = {
|
||||
"epoch": traffic_snapshot.get("epoch", 0),
|
||||
"total_octets": traffic_snapshot.get("total_octets", 0),
|
||||
"current_connections": traffic_snapshot.get("current_connections", 0),
|
||||
"active_unique_ips": traffic_snapshot.get("active_unique_ips", 0),
|
||||
"recent_unique_ips": traffic_snapshot.get("recent_unique_ips", 0),
|
||||
}
|
||||
if include_runtime and enabled:
|
||||
item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}")
|
||||
return item
|
||||
@@ -823,11 +1023,40 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_json({"ok": True, "data": overview_payload()})
|
||||
elif path == "/api/users":
|
||||
users = read_user_records()
|
||||
latest = latest_user_stats()
|
||||
items = []
|
||||
for name in sorted(users, key=lambda item: (item != "main", item)):
|
||||
record = users[name]
|
||||
items.append(user_payload(name, record["secret"], record["enabled"]))
|
||||
items.append(user_payload(name, record["secret"], record["enabled"], traffic_snapshot=latest.get(name)))
|
||||
self.send_json({"ok": True, "data": items})
|
||||
elif path.startswith("/api/users/") and path.endswith("/traffic"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/traffic")])
|
||||
users = read_user_records()
|
||||
if name not in users:
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
range_key = normalize_range(qs.get("range", ["1h"])[0])
|
||||
all_history = load_user_stats_history(name, limit=history_limit_for_range("month"))
|
||||
history = filter_history_by_range(all_history[-history_limit_for_range(range_key):], range_key)
|
||||
current = runtime_user_traffic(name, bool(users[name].get("enabled")))
|
||||
self.send_json({
|
||||
"ok": True,
|
||||
"data": {
|
||||
"name": name,
|
||||
"range": range_key,
|
||||
"current": current,
|
||||
"history": history,
|
||||
"summary_rows": user_traffic_interval_summaries(all_history),
|
||||
"status": {
|
||||
"history_exists": USER_HISTORY_FILE.exists(),
|
||||
"history_rows": count_user_history_rows(name),
|
||||
"history_points": len(history),
|
||||
"last_ts": history[-1]["epoch"] if history else 0,
|
||||
"runtime_ok": current.get("ok", False),
|
||||
},
|
||||
},
|
||||
})
|
||||
elif path.startswith("/api/users/"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):])
|
||||
users = read_user_records()
|
||||
@@ -835,7 +1064,7 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
record = users[name]
|
||||
self.send_json({"ok": True, "data": user_payload(name, record["secret"], record["enabled"], include_runtime=True)})
|
||||
self.send_json({"ok": True, "data": user_payload(name, record["secret"], record["enabled"], include_runtime=True, traffic_snapshot=latest_user_stats().get(name))})
|
||||
elif path == "/api/backups":
|
||||
self.send_json({"ok": True, "data": list_backups()})
|
||||
elif path == "/api/stats":
|
||||
|
||||
@@ -56,6 +56,9 @@ const i18n = {
|
||||
tableUser: "User",
|
||||
tableSecret: "Secret",
|
||||
tableLink: "Link",
|
||||
tableTraffic: "Traffic",
|
||||
tableTrafficDelta: "Traffic delta",
|
||||
tableTrafficTotal: "Total",
|
||||
tableActions: "Actions",
|
||||
userPlaceholder: "client-name",
|
||||
addKey: "Add key",
|
||||
@@ -81,6 +84,15 @@ const i18n = {
|
||||
noHistory: "No traffic history yet",
|
||||
noTrafficForRange: "No data for this range yet",
|
||||
noRuntime: "Runtime data is not available",
|
||||
userTrafficEyebrow: "Per user",
|
||||
userTrafficTitle: "User traffic",
|
||||
selectUserTraffic: "Select a key to see its traffic history",
|
||||
openStats: "Stats",
|
||||
trafficTotal: "Total",
|
||||
currentConnections: "Connections",
|
||||
activeIps: "Active IPs",
|
||||
recentIps: "Recent IPs",
|
||||
trafficRuntimeUnavailable: "Runtime unavailable",
|
||||
badConnections: "Bad connections",
|
||||
connections: "Connections",
|
||||
uptime: "Uptime",
|
||||
@@ -150,6 +162,7 @@ const i18n = {
|
||||
port443NoRoutes: "No routed services detected",
|
||||
port443Via: "via {value}",
|
||||
roleMtproxy: "MTProxy",
|
||||
roleEdge: "443 Edge",
|
||||
roleSite: "Website",
|
||||
roleXray: "Xray / 3x-ui",
|
||||
roleAmneziawg: "AmneziaWG",
|
||||
@@ -243,6 +256,9 @@ const i18n = {
|
||||
tableUser: "Пользователь",
|
||||
tableSecret: "Секрет",
|
||||
tableLink: "Ссылка",
|
||||
tableTraffic: "Трафик",
|
||||
tableTrafficDelta: "Прирост трафика",
|
||||
tableTrafficTotal: "Всего",
|
||||
tableActions: "Действия",
|
||||
userPlaceholder: "client-name",
|
||||
addKey: "Добавить ключ",
|
||||
@@ -268,6 +284,15 @@ const i18n = {
|
||||
noHistory: "Истории трафика пока нет",
|
||||
noTrafficForRange: "За этот период данных пока нет",
|
||||
noRuntime: "Данные среды выполнения недоступны",
|
||||
userTrafficEyebrow: "По пользователю",
|
||||
userTrafficTitle: "Трафик ключа",
|
||||
selectUserTraffic: "Выберите ключ, чтобы увидеть историю трафика",
|
||||
openStats: "Статистика",
|
||||
trafficTotal: "Всего",
|
||||
currentConnections: "Подключения",
|
||||
activeIps: "Активные IP",
|
||||
recentIps: "Недавние IP",
|
||||
trafficRuntimeUnavailable: "Runtime недоступен",
|
||||
badConnections: "Ошибочные подключения",
|
||||
connections: "Подключения",
|
||||
uptime: "Аптайм",
|
||||
@@ -337,6 +362,7 @@ const i18n = {
|
||||
port443NoRoutes: "Маршрутизируемых сервисов не найдено",
|
||||
port443Via: "через {value}",
|
||||
roleMtproxy: "MTProxy",
|
||||
roleEdge: "443 Edge",
|
||||
roleSite: "Сайт",
|
||||
roleXray: "Xray / 3x-ui",
|
||||
roleAmneziawg: "AmneziaWG",
|
||||
@@ -389,6 +415,11 @@ const state = {
|
||||
trafficRange: "1h",
|
||||
trafficView: "chart",
|
||||
trafficLoading: false,
|
||||
userTrafficUser: "",
|
||||
userTrafficRange: "1h",
|
||||
userTrafficView: "chart",
|
||||
userTraffic: null,
|
||||
userTrafficLoading: false,
|
||||
pendingUsers: new Set(),
|
||||
};
|
||||
|
||||
@@ -482,6 +513,7 @@ function applyI18n() {
|
||||
$("#visualTitle").textContent = t("visualTitle");
|
||||
$("#visualText").textContent = t("visualText");
|
||||
updateTrafficControls();
|
||||
updateUserTrafficControls();
|
||||
updatePageTitle();
|
||||
}
|
||||
|
||||
@@ -491,6 +523,7 @@ function setTheme(theme) {
|
||||
localStorage.setItem("gotelegram-theme", state.theme);
|
||||
applyI18n();
|
||||
if (state.overview) renderStats();
|
||||
if (state.userTraffic) renderUserTraffic();
|
||||
}
|
||||
|
||||
async function setLanguage(lang) {
|
||||
@@ -525,6 +558,10 @@ function setPage(page, push = true) {
|
||||
}
|
||||
if (next === "traffic") {
|
||||
refreshStats().catch((err) => toast(err.message));
|
||||
} else if (next === "keys") {
|
||||
ensureUserTrafficSelection();
|
||||
renderUserTraffic();
|
||||
if (state.userTrafficUser) refreshUserTraffic().catch((err) => toast(err.message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,6 +773,15 @@ function updateTrafficControls() {
|
||||
});
|
||||
}
|
||||
|
||||
function updateUserTrafficControls() {
|
||||
$$("[data-user-traffic-range]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.userTrafficRange === state.userTrafficRange);
|
||||
});
|
||||
$$("[data-user-traffic-view]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.userTrafficView === state.userTrafficView);
|
||||
});
|
||||
}
|
||||
|
||||
function trafficRangeLabel(range) {
|
||||
const labels = {
|
||||
"15m": t("range15m"),
|
||||
@@ -886,14 +932,150 @@ function renderHistoryTable(rows) {
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function ensureUserTrafficSelection() {
|
||||
if (state.userTrafficUser && state.users.some((user) => user.name === state.userTrafficUser)) return;
|
||||
state.userTrafficUser = state.users[0]?.name || "";
|
||||
}
|
||||
|
||||
function userTrafficRows() {
|
||||
return state.userTraffic?.history || [];
|
||||
}
|
||||
|
||||
function bucketUserTrafficRows(rows) {
|
||||
const filtered = filterTrafficRows(rows, state.userTrafficRange);
|
||||
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,
|
||||
total_delta: slice.reduce((sum, item) => sum + (Number(item.total_delta) || 0), 0),
|
||||
total_octets: last.total_octets,
|
||||
current_connections: last.current_connections,
|
||||
active_unique_ips: last.active_unique_ips,
|
||||
});
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function fallbackUserTrafficSummaries(rows) {
|
||||
return trafficRanges.map((range) => {
|
||||
const windowRows = filterTrafficRows(rows, range);
|
||||
if (!windowRows.length) {
|
||||
return { range, points: 0, total_delta: 0, total_octets: 0 };
|
||||
}
|
||||
const last = windowRows[windowRows.length - 1];
|
||||
return {
|
||||
range,
|
||||
points: windowRows.length,
|
||||
total_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.total_delta) || 0), 0),
|
||||
total_octets: Number(last.total_octets) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderUserTrafficLoading() {
|
||||
$("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart");
|
||||
$("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table");
|
||||
$("#userTrafficChart").innerHTML = `<div class="empty-chart"><strong>${escapeHtml(t("loading"))}</strong></div>`;
|
||||
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("loading"))}</td></tr>`;
|
||||
}
|
||||
|
||||
function drawUserTrafficChart(rows) {
|
||||
const el = $("#userTrafficChart");
|
||||
const points = bucketUserTrafficRows(rows);
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb";
|
||||
if (points.length < 2) {
|
||||
el.innerHTML = `<div class="empty-chart">
|
||||
<strong>${escapeHtml(state.userTrafficUser ? t("noTrafficForRange") : t("selectUserTraffic"))}</strong>
|
||||
<span>${escapeHtml(state.userTraffic?.status?.runtime_ok ? t("statsOk") : t("trafficRuntimeUnavailable"))}</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const width = 900;
|
||||
const height = 260;
|
||||
const pad = { l: 54, r: 22, t: 24, b: 42 };
|
||||
const max = Math.max(1, ...points.map((p) => Number(p.total_delta) || 0));
|
||||
const plotW = width - pad.l - pad.r;
|
||||
const plotH = height - pad.t - pad.b;
|
||||
const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1);
|
||||
const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH;
|
||||
const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p.total_delta).toFixed(1)}`).join(" ");
|
||||
const grid = Array.from({ length: 5 }, (_, i) => {
|
||||
const y = pad.t + (plotH / 4) * i;
|
||||
return `<line x1="${pad.l}" y1="${y}" x2="${width - pad.r}" y2="${y}"></line>`;
|
||||
}).join("");
|
||||
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="${path} L${width - pad.r},${height - pad.b} L${pad.l},${height - pad.b} Z"></path>
|
||||
<path class="line proxy-line" d="${path}"></path>
|
||||
<text x="${pad.l}" y="17" class="axis">${escapeHtml(axis)}</text>
|
||||
<text x="${pad.l}" y="${height - 12}" class="legend" fill="${color}">${escapeHtml(state.userTrafficUser || t("users"))}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function renderUserTrafficTable(rows) {
|
||||
if (!rows.length) {
|
||||
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
$("#userTrafficTable").innerHTML = rows.map((row) => `
|
||||
<tr>
|
||||
<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("tableTrafficDelta"))}">${escapeHtml(fmtBytes(row.total_delta))}</td>
|
||||
<td data-label="${escapeAttr(t("tableTrafficTotal"))}">${escapeHtml(fmtBytes(row.total_octets))}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderUserTraffic() {
|
||||
updateUserTrafficControls();
|
||||
if (!state.userTrafficUser) {
|
||||
$("#userTrafficTitle").textContent = t("userTrafficTitle");
|
||||
$("#userTrafficHealth").className = "status-pill health-unknown";
|
||||
$("#userTrafficHealth").textContent = "--";
|
||||
$("#userTrafficTotal").textContent = "--";
|
||||
$("#userTrafficConnections").textContent = "--";
|
||||
$("#userTrafficIps").textContent = "--";
|
||||
$("#userTrafficChart").innerHTML = `<div class="empty-chart"><strong>${escapeHtml(t("selectUserTraffic"))}</strong></div>`;
|
||||
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("selectUserTraffic"))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
$("#userTrafficTitle").textContent = `${t("userTrafficTitle")}: ${state.userTrafficUser}`;
|
||||
if (state.userTrafficLoading) {
|
||||
renderUserTrafficLoading();
|
||||
return;
|
||||
}
|
||||
const payload = state.userTraffic || {};
|
||||
const current = payload.current || {};
|
||||
const rows = userTrafficRows();
|
||||
const last = rows[rows.length - 1] || {};
|
||||
const total = Number(current.total_octets) || Number(last.total_octets) || 0;
|
||||
$("#userTrafficHealth").className = `status-pill ${current.enabled === false ? "health-stopped" : (current.ok ? "health-ok" : "health-stale")}`;
|
||||
$("#userTrafficHealth").textContent = current.enabled === false ? t("disabled") : (current.ok ? t("healthOk") : t("trafficRuntimeUnavailable"));
|
||||
$("#userTrafficTotal").textContent = fmtBytes(total);
|
||||
$("#userTrafficConnections").textContent = current.current_connections ?? last.current_connections ?? 0;
|
||||
$("#userTrafficIps").textContent = current.active_unique_ips ?? last.active_unique_ips ?? 0;
|
||||
$("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart");
|
||||
$("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table");
|
||||
drawUserTrafficChart(rows);
|
||||
renderUserTrafficTable(payload.summary_rows?.length ? payload.summary_rows : fallbackUserTrafficSummaries(rows));
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const tbody = $("#usersTable");
|
||||
if (!state.users.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.users.map((user) => {
|
||||
const pending = state.pendingUsers.has(user.name);
|
||||
const traffic = user.traffic || {};
|
||||
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
|
||||
const activeIps = Number(traffic.active_unique_ips) || 0;
|
||||
return `
|
||||
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""}">
|
||||
<td data-label="${escapeAttr(t("tableUser"))}">
|
||||
@@ -910,6 +1092,13 @@ function renderUsers() {
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
|
||||
<td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button></td>
|
||||
<td data-label="${escapeAttr(t("tableTraffic"))}">
|
||||
<div class="traffic-cell">
|
||||
<strong>${escapeHtml(trafficTotal)}</strong>
|
||||
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
|
||||
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableActions"))}" class="actions">
|
||||
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
|
||||
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
|
||||
@@ -993,6 +1182,9 @@ async function refreshAll() {
|
||||
renderUsers();
|
||||
if (state.page === "traffic") {
|
||||
await refreshStats();
|
||||
} else if (state.page === "keys") {
|
||||
ensureUserTrafficSelection();
|
||||
await refreshUserTraffic();
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
@@ -1021,6 +1213,26 @@ async function refreshStats(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUserTraffic(options = {}) {
|
||||
ensureUserTrafficSelection();
|
||||
if (!state.userTrafficUser) {
|
||||
renderUserTraffic();
|
||||
return null;
|
||||
}
|
||||
if (options.showLoading) {
|
||||
state.userTrafficLoading = true;
|
||||
renderUserTraffic();
|
||||
}
|
||||
try {
|
||||
const data = await api(`/api/users/${encodeURIComponent(state.userTrafficUser)}/traffic?range=${encodeURIComponent(state.userTrafficRange)}`);
|
||||
state.userTraffic = data;
|
||||
return data;
|
||||
} finally {
|
||||
state.userTrafficLoading = false;
|
||||
renderUserTraffic();
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTrafficRange(range) {
|
||||
const next = trafficRanges.includes(range) ? range : "1h";
|
||||
if (next === state.trafficRange && state.stats?.range === next) return;
|
||||
@@ -1036,6 +1248,21 @@ async function changeTrafficRange(range) {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeUserTrafficRange(range) {
|
||||
const next = trafficRanges.includes(range) ? range : "1h";
|
||||
if (next === state.userTrafficRange && state.userTraffic?.range === next) return;
|
||||
const previous = state.userTrafficRange;
|
||||
state.userTrafficRange = next;
|
||||
try {
|
||||
await refreshUserTraffic({ showLoading: true });
|
||||
} catch (err) {
|
||||
state.userTrafficRange = previous;
|
||||
state.userTrafficLoading = false;
|
||||
renderUserTraffic();
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser(name) {
|
||||
const data = await api("/api/users", {
|
||||
method: "POST",
|
||||
@@ -1206,6 +1433,14 @@ document.addEventListener("click", async (eventObj) => {
|
||||
} else if (button.dataset.trafficView) {
|
||||
state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart";
|
||||
renderStats();
|
||||
} else if (button.dataset.userTraffic) {
|
||||
state.userTrafficUser = button.dataset.userTraffic;
|
||||
refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message));
|
||||
} else if (button.dataset.userTrafficRange) {
|
||||
changeUserTrafficRange(button.dataset.userTrafficRange);
|
||||
} else if (button.dataset.userTrafficView) {
|
||||
state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart";
|
||||
renderUserTraffic();
|
||||
} 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-admin7">
|
||||
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin8">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -202,6 +202,7 @@
|
||||
<th data-i18n="tableStatus">Status</th>
|
||||
<th data-i18n="tableSecret">Secret</th>
|
||||
<th data-i18n="tableLink">Link</th>
|
||||
<th data-i18n="tableTraffic">Traffic</th>
|
||||
<th data-i18n="tableActions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -209,6 +210,56 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel user-traffic-panel" id="userTrafficPanel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n="userTrafficEyebrow">Per user</p>
|
||||
<h2 id="userTrafficTitle" data-i18n="userTrafficTitle">User traffic</h2>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<span id="userTrafficHealth" class="status-pill">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-summary compact">
|
||||
<article>
|
||||
<span data-i18n="trafficTotal">Total</span>
|
||||
<strong id="userTrafficTotal">--</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span data-i18n="currentConnections">Connections</span>
|
||||
<strong id="userTrafficConnections">--</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span data-i18n="activeIps">Active IPs</span>
|
||||
<strong id="userTrafficIps">--</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div class="traffic-controls">
|
||||
<div class="segmented" id="userTrafficRange" aria-label="User traffic range" data-i18n-aria-label="ariaTrafficRange">
|
||||
<button type="button" data-user-traffic-range="15m" data-i18n="range15m">15 min</button>
|
||||
<button type="button" data-user-traffic-range="1h" data-i18n="range1h">1 hour</button>
|
||||
<button type="button" data-user-traffic-range="24h" data-i18n="range24h">24 hours</button>
|
||||
<button type="button" data-user-traffic-range="month" data-i18n="rangeMonth">Month</button>
|
||||
</div>
|
||||
<div class="segmented" id="userTrafficView" aria-label="User traffic view" data-i18n-aria-label="ariaTrafficView">
|
||||
<button type="button" data-user-traffic-view="chart" data-i18n="viewChart">Chart</button>
|
||||
<button type="button" data-user-traffic-view="table" data-i18n="viewRows">Rows</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="userTrafficChart" class="traffic-chart"></div>
|
||||
<div class="table-wrap" id="userTrafficTableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="tablePeriod">Period</th>
|
||||
<th data-i18n="tableTrafficDelta">Traffic delta</th>
|
||||
<th data-i18n="tableTrafficTotal">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTrafficTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-panel" data-page="backups">
|
||||
@@ -321,6 +372,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js?v=2.5.0-admin7" type="module"></script>
|
||||
<script src="/app.js?v=2.5.0-admin8" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -667,6 +667,10 @@ h2 {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.traffic-summary.compact {
|
||||
grid-template-columns: repeat(3, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.health-ok { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
|
||||
.health-error { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); }
|
||||
.health-stale,
|
||||
@@ -830,6 +834,24 @@ td small {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.traffic-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.traffic-cell strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.traffic-cell .soft {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.user-traffic-panel {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.status-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user