diff --git a/.env.example b/.env.example index 3cae941..0d522f7 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ PANEL_PASSWORD=Woaini521@ # 可选:仅允许指定 IP 访问面板(留空则不限制) # PANEL_ALLOW_IP=1.2.3.4 +# sing-box Clash API 密钥(安装时自动生成,供面板读取连接状态) +# CLASH_API_SECRET= + # 以下由 scripts/generate-keys.sh 自动生成,也可手动填写 # REALITY_PRIVATE_KEY= # REALITY_PUBLIC_KEY= diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index fbf5b4b..23c5536 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -89,6 +89,8 @@ bash scripts/install.sh | 添加节点 | 自动生成 UUID + Hy2 密码,更新 sing-box | | 复制链接 | VLESS Reality + Hysteria2 分享链接 | | 删除节点 | 至少保留 1 个节点 | +| 连接状态 | 在线/离线、当前连接数(Clash API) | +| 流量统计 | 实时速率 + 累计上下行(V2Ray 统计,重启 sing-box 后继续累计) | --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9e1ee3f..2151667 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -37,10 +37,31 @@ curl -I "http://66.hyf2.cc/$(grep ^PANEL_PATH= /opt/jiedian/.env | cut -d= -f2)/ > 443 端口已被 sing-box Reality 占用,面板走 80 端口子路径。请妥善保管 `PANEL_PATH`,相当于隐藏入口。 +面板可查看每个节点的 **在线状态、连接数、实时速率、累计流量**(数据来自 sing-box Clash API + V2Ray 统计,仅监听 127.0.0.1)。 + --- ## 常见问题 +### 面板流量/在线状态显示「不可用」 + +sing-box 统计 API 未就绪,按顺序检查: + +```bash +systemctl is-active sing-box +ss -tlnp | grep -E '9090|9091' # Clash API / V2Ray gRPC +grep CLASH_API_SECRET /opt/jiedian/.env +python3 /opt/jiedian/scripts/render-server.py +systemctl restart sing-box jiedian-panel +``` + +升级后若未重装,需重新生成 sing-box 配置并重启服务,才会启用 `experimental.clash_api` 与 `v2ray_api`。 + +### 在线状态始终离线但客户端能连 + +- 有流量时面板会按速率推断在线;若仍显示离线,执行 `curl -s -H "Authorization: Bearer $(grep CLASH_API_SECRET /opt/jiedian/.env | cut -d= -f2)" http://127.0.0.1:9090/connections | head` 查看连接 metadata 是否含 user 字段 +- 修改节点或升级后需重新渲染配置:`python3 /opt/jiedian/scripts/render-server.py && systemctl restart sing-box` + ### 面板打不开 / 404 1. **路径不对**:必须用完整路径,末尾带 `/`,例如 `http://域名/jiedian-xxxx/login` diff --git a/panel/app.py b/panel/app.py index e4b9942..0f13f1c 100644 --- a/panel/app.py +++ b/panel/app.py @@ -21,6 +21,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix from db import add_node, delete_node, list_nodes, node_count, verify_admin from links import build_links, load_env +from stats import collect_node_stats ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1])) SECRET_FILE = ROOT / "data" / ".panel_secret" @@ -119,6 +120,12 @@ def dashboard(): ) +@app.route("/api/stats", methods=["GET"]) +@login_required +def api_stats(): + return jsonify(collect_node_stats()) + + @app.route("/api/nodes", methods=["GET"]) @login_required def api_list_nodes(): diff --git a/panel/db.py b/panel/db.py index a34bbc7..6bf51e3 100644 --- a/panel/db.py +++ b/panel/db.py @@ -58,6 +58,15 @@ def init_db(env: dict[str, str]) -> None: enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS traffic_counters ( + node_id INTEGER PRIMARY KEY, + upload_total INTEGER NOT NULL DEFAULT 0, + download_total INTEGER NOT NULL DEFAULT 0, + snapshot_upload INTEGER NOT NULL DEFAULT 0, + snapshot_download INTEGER NOT NULL DEFAULT 0, + updated_at TEXT, + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); """ ) @@ -80,6 +89,12 @@ def init_db(env: dict[str, str]) -> None: ("默认节点", uuid, hy2), ) + for row in conn.execute("SELECT id FROM nodes").fetchall(): + conn.execute( + "INSERT OR IGNORE INTO traffic_counters (node_id) VALUES (?)", + (row["id"],), + ) + conn.commit() conn.close() @@ -115,6 +130,10 @@ def add_node(name: str) -> dict: ) node_id = cur.lastrowid row = conn.execute("SELECT * FROM nodes WHERE id = ?", (node_id,)).fetchone() + conn.execute( + "INSERT OR IGNORE INTO traffic_counters (node_id) VALUES (?)", + (node_id,), + ) conn.commit() conn.close() return dict(row) diff --git a/panel/requirements.txt b/panel/requirements.txt index 06440c7..643fbb3 100644 --- a/panel/requirements.txt +++ b/panel/requirements.txt @@ -1,2 +1,3 @@ flask>=3.0,<4 werkzeug>=3.0,<4 +grpcio>=1.60,<2 diff --git a/panel/static/app.js b/panel/static/app.js index fb5c64b..afabe2e 100644 --- a/panel/static/app.js +++ b/panel/static/app.js @@ -72,3 +72,71 @@ document.querySelectorAll(".delete-btn").forEach((btn) => { } }); }); + +function setStatusBadge(el, online) { + el.textContent = online ? "在线" : "离线"; + el.classList.toggle("online", online); + el.classList.toggle("offline", !online); +} + +function updateStats(data) { + const summary = data.summary || {}; + const onlineEl = document.getElementById("summaryOnline"); + const upEl = document.getElementById("summaryUp"); + const downEl = document.getElementById("summaryDown"); + const statusEl = document.getElementById("summaryStatus"); + + if (onlineEl) { + onlineEl.textContent = `${summary.online || 0} / ${summary.total_nodes || 0}`; + } + if (upEl) upEl.textContent = summary.upload_speed_human || "0 B/s"; + if (downEl) downEl.textContent = summary.download_speed_human || "0 B/s"; + if (statusEl) { + if (data.singbox) { + statusEl.textContent = "正常"; + statusEl.className = "status-text ok"; + } else { + statusEl.textContent = "不可用"; + statusEl.className = "status-text err"; + } + } + + document.querySelectorAll(".node-card[data-id]").forEach((card) => { + const node = (data.nodes || {})[card.dataset.id]; + if (!node) return; + + const status = card.querySelector('[data-role="status"]'); + if (status) setStatusBadge(status, node.online); + + const setText = (role, value) => { + const el = card.querySelector(`[data-role="${role}"]`); + if (el) el.textContent = value; + }; + + setText("connections", String(node.connections ?? 0)); + setText("speed-up", node.upload_speed_human || "0 B/s"); + setText("speed-down", node.download_speed_human || "0 B/s"); + setText("total-up", node.upload_total_human || "0 B"); + setText("total-down", node.download_total_human || "0 B"); + }); +} + +async function refreshStats() { + try { + const res = await fetch("/api/stats"); + if (!res.ok) return; + const data = await res.json(); + updateStats(data); + } catch { + const statusEl = document.getElementById("summaryStatus"); + if (statusEl) { + statusEl.textContent = "不可用"; + statusEl.className = "status-text err"; + } + } +} + +if (document.getElementById("summaryBar")) { + refreshStats(); + setInterval(refreshStats, 5000); +} diff --git a/panel/static/style.css b/panel/static/style.css index ea9003d..5d4af75 100644 --- a/panel/static/style.css +++ b/panel/static/style.css @@ -110,6 +110,76 @@ input[readonly] { margin-bottom: 12px; } .node-head h2 { margin: 0; font-size: 1.1rem; } +.node-title { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.status-badge { + font-size: 0.75rem; + padding: 3px 10px; + border-radius: 999px; + border: 1px solid var(--border); +} +.status-badge.online { + color: #34d399; + border-color: rgba(52, 211, 153, 0.45); + background: rgba(52, 211, 153, 0.12); +} +.status-badge.offline { + color: var(--muted); + background: #111827; +} +.status-text.ok { color: #34d399; } +.status-text.err { color: #f87171; } + +.summary-bar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 24px; +} +.summary-item { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; +} +.summary-label { + display: block; + color: var(--muted); + font-size: 0.85rem; + margin-bottom: 6px; +} + +.node-stats { + margin-bottom: 16px; + padding: 12px; + border-radius: 10px; + background: rgba(17, 24, 39, 0.55); + border: 1px solid var(--border); +} +.stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} +.stat-box { + display: flex; + flex-direction: column; + gap: 4px; +} +.stat-label { + color: var(--muted); + font-size: 0.78rem; +} +.stat-value { + font-size: 0.95rem; + font-variant-numeric: tabular-nums; +} + .tag { font-size: 0.8rem; color: var(--muted); @@ -168,4 +238,6 @@ input[readonly] { @media (max-width: 640px) { .hero { flex-direction: column; align-items: flex-start; } .copy-row { grid-template-columns: 1fr; } + .summary-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/panel/stats.py b/panel/stats.py new file mode 100644 index 0000000..6792c45 --- /dev/null +++ b/panel/stats.py @@ -0,0 +1,320 @@ +"""从 sing-box Clash API / V2Ray gRPC 采集节点连接与流量。""" +from __future__ import annotations + +import json +import os +import sqlite3 +import time +import urllib.error +import urllib.request +from pathlib import Path + +import grpc + +from db import connect, list_nodes + +ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1])) +ENV_FILE = ROOT / ".env" + +CLASH_ADDR = "127.0.0.1:9090" +V2RAY_ADDR = "127.0.0.1:9091" +GRPC_METHOD = "/v2ray.core.app.stats.command.StatsService/QueryStats" + +_grpc_channel: grpc.Channel | None = None +_speed_cache: dict[int, tuple[float, int, int]] = {} + + +def _load_env() -> dict[str, str]: + env: dict[str, str] = {} + if not ENV_FILE.exists(): + return env + for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + env[key.strip()] = value.strip() + return env + + +def format_bytes(num: int | float) -> str: + n = float(num) + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + if unit == "B": + return f"{int(n)} B" + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} PB" + + +def format_speed(num: float) -> str: + return f"{format_bytes(num)}/s" + + +def _varint_encode(n: int) -> bytes: + out = bytearray() + while n > 0x7F: + out.append((n & 0x7F) | 0x80) + n >>= 7 + out.append(n) + return bytes(out) + + +def _varint_decode(data: bytes, i: int) -> tuple[int, int]: + shift = 0 + result = 0 + while i < len(data): + b = data[i] + i += 1 + result |= (b & 0x7F) << shift + if not (b & 0x80): + return result, i + shift += 7 + raise ValueError("truncated varint") + + +def _skip_field(data: bytes, i: int, wire_type: int) -> int: + if wire_type == 0: + _, i = _varint_decode(data, i) + elif wire_type == 1: + i += 8 + elif wire_type == 2: + length, i = _varint_decode(data, i) + i += length + elif wire_type == 5: + i += 4 + else: + raise ValueError(f"unsupported wire type {wire_type}") + return i + + +def _encode_query_stats(name: str) -> bytes: + if not name: + return b"" + payload = name.encode("utf-8") + return bytes([0x0A]) + _varint_encode(len(payload)) + payload + + +def _decode_stat_message(data: bytes) -> tuple[str, int | None]: + name: str | None = None + value: int | None = None + i = 0 + while i < len(data): + tag = data[i] + i += 1 + field = tag >> 3 + wire = tag & 0x07 + if field == 1 and wire == 2: + length, i = _varint_decode(data, i) + name = data[i : i + length].decode("utf-8") + i += length + elif field == 2 and wire == 0: + value, i = _varint_decode(data, i) + else: + i = _skip_field(data, i, wire) + return name or "", value + + +def _decode_query_stats_response(data: bytes) -> dict[str, int]: + stats: dict[str, int] = {} + i = 0 + while i < len(data): + tag = data[i] + i += 1 + field = tag >> 3 + wire = tag & 0x07 + if field == 1 and wire == 2: + length, i = _varint_decode(data, i) + name, value = _decode_stat_message(data[i : i + length]) + i += length + if name and value is not None: + stats[name] = value + else: + i = _skip_field(data, i, wire) + return stats + + +def _grpc_channel_get() -> grpc.Channel: + global _grpc_channel + if _grpc_channel is None: + _grpc_channel = grpc.insecure_channel(V2RAY_ADDR) + return _grpc_channel + + +def fetch_v2ray_user_stats() -> tuple[dict[str, tuple[int, int]], bool]: + """返回 ({uuid: (upload_bytes, download_bytes)}, ok)。""" + channel = _grpc_channel_get() + method = channel.unary_unary( + GRPC_METHOD, + request_serializer=_encode_query_stats, + response_deserializer=_decode_query_stats_response, + ) + try: + raw = method(b"user>>>") + except grpc.RpcError: + return {}, False + + users: dict[str, tuple[int, int]] = {} + for name, value in raw.items(): + parts = name.split(">>>") + if len(parts) != 4 or parts[0] != "user" or parts[2] != "traffic": + continue + uid, direction = parts[1], parts[3] + up, down = users.get(uid, (0, 0)) + if direction == "uplink": + users[uid] = (value, down) + elif direction == "downlink": + users[uid] = (up, value) + return users, True + + +def fetch_clash_connections() -> tuple[list[dict], bool]: + env = _load_env() + secret = env.get("CLASH_API_SECRET", "") + url = f"http://{CLASH_ADDR}/connections" + req = urllib.request.Request(url) + if secret: + req.add_header("Authorization", f"Bearer {secret}") + try: + with urllib.request.urlopen(req, timeout=3) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError): + return [], False + return payload.get("connections") or [], True + + +def _match_connection(conn: dict, uuid: str) -> bool: + meta = conn.get("metadata") or {} + user = str(meta.get("user") or meta.get("uid") or "") + return user == uuid + + +def _ensure_traffic_schema(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS traffic_counters ( + node_id INTEGER PRIMARY KEY, + upload_total INTEGER NOT NULL DEFAULT 0, + download_total INTEGER NOT NULL DEFAULT 0, + snapshot_upload INTEGER NOT NULL DEFAULT 0, + snapshot_download INTEGER NOT NULL DEFAULT 0, + updated_at TEXT, + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + """ + ) + for row in conn.execute("SELECT id FROM nodes").fetchall(): + conn.execute( + "INSERT OR IGNORE INTO traffic_counters (node_id) VALUES (?)", + (row["id"],), + ) + + +def _update_traffic_totals(node_id: int, raw_up: int, raw_down: int) -> tuple[int, int]: + conn = connect() + _ensure_traffic_schema(conn) + row = conn.execute( + "SELECT upload_total, download_total, snapshot_upload, snapshot_download " + "FROM traffic_counters WHERE node_id = ?", + (node_id,), + ).fetchone() + if row is None: + conn.execute("INSERT INTO traffic_counters (node_id) VALUES (?)", (node_id,)) + conn.commit() + total_up, total_down, snap_up, snap_down = 0, 0, 0, 0 + else: + total_up = int(row["upload_total"]) + total_down = int(row["download_total"]) + snap_up = int(row["snapshot_upload"]) + snap_down = int(row["snapshot_download"]) + + if raw_up < snap_up or raw_down < snap_down: + total_up += snap_up + total_down += snap_down + snap_up = 0 + snap_down = 0 + + total_up += max(0, raw_up - snap_up) + total_down += max(0, raw_down - snap_down) + + conn.execute( + """ + UPDATE traffic_counters + SET upload_total = ?, download_total = ?, + snapshot_upload = ?, snapshot_download = ?, + updated_at = datetime('now') + WHERE node_id = ? + """, + (total_up, total_down, raw_up, raw_down, node_id), + ) + conn.commit() + conn.close() + return total_up, total_down + + +def _calc_speed(node_id: int, up: int, down: int) -> tuple[float, float]: + now = time.time() + prev = _speed_cache.get(node_id) + _speed_cache[node_id] = (now, up, down) + if not prev: + return 0.0, 0.0 + t0, u0, d0 = prev + dt = now - t0 + if dt <= 0: + return 0.0, 0.0 + return max(0.0, (up - u0) / dt), max(0.0, (down - d0) / dt) + + +def collect_node_stats() -> dict: + nodes = list_nodes() + v2ray, v2ray_ok = fetch_v2ray_user_stats() + connections, clash_ok = fetch_clash_connections() + singbox_ok = v2ray_ok or clash_ok + + result_nodes: dict[str, dict] = {} + summary_online = 0 + summary_up_speed = 0.0 + summary_down_speed = 0.0 + + for node in nodes: + uid = node["uuid"] + node_id = int(node["id"]) + raw_up, raw_down = v2ray.get(uid, (0, 0)) + total_up, total_down = _update_traffic_totals(node_id, raw_up, raw_down) + up_speed, down_speed = _calc_speed(node_id, raw_up, raw_down) + + matched = [c for c in connections if _match_connection(c, uid)] + online = len(matched) > 0 or (up_speed + down_speed) > 512 + + if online: + summary_online += 1 + summary_up_speed += up_speed + summary_down_speed += down_speed + + result_nodes[str(node_id)] = { + "online": online, + "connections": len(matched), + "upload_speed": round(up_speed), + "download_speed": round(down_speed), + "upload_total": total_up, + "download_total": total_down, + "upload_speed_human": format_speed(up_speed), + "download_speed_human": format_speed(down_speed), + "upload_total_human": format_bytes(total_up), + "download_total_human": format_bytes(total_down), + } + + return { + "ok": True, + "singbox": singbox_ok, + "nodes": result_nodes, + "summary": { + "online": summary_online, + "total_nodes": len(nodes), + "upload_speed": round(summary_up_speed), + "download_speed": round(summary_down_speed), + "upload_speed_human": format_speed(summary_up_speed), + "download_speed_human": format_speed(summary_down_speed), + }, + } diff --git a/panel/templates/dashboard.html b/panel/templates/dashboard.html index 89ce685..9dec458 100644 --- a/panel/templates/dashboard.html +++ b/panel/templates/dashboard.html @@ -18,14 +18,60 @@ + +