release 1.4.10: накопительный счётчик трафика за месяц на клиента
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@
|
||||
|
||||
---
|
||||
|
||||
## 1.4.10 — 2026-11-03
|
||||
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||
|
||||
## Как обновиться после фиксов
|
||||
|
||||
```bash
|
||||
|
||||
91
app.py
91
app.py
@@ -802,6 +802,7 @@ _last_fanout = 0
|
||||
_last_peer_sync = 0
|
||||
server_stats_cache = {}
|
||||
server_handshakes_cache = {}
|
||||
server_transfer_cache = {} # server ip -> {pub: [rx, tx]} (from secondary /api/health)
|
||||
|
||||
|
||||
def count_client_peers():
|
||||
@@ -889,6 +890,7 @@ def get_remote_stats(server):
|
||||
raise Exception("no response")
|
||||
# cache handshakes so page renders never block on remote HTTP
|
||||
server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {}
|
||||
server_transfer_cache[server.get("ip", "")] = data.get("transfer", {}) or {}
|
||||
# Filter peers: only count known client public keys
|
||||
clients = get_clients()
|
||||
client_pubs = {c.get("public_key", "") for c in clients}
|
||||
@@ -1751,6 +1753,71 @@ def finish_legacy_update():
|
||||
finish_legacy_update()
|
||||
threading.Thread(target=rate_sampler, daemon=True).start()
|
||||
|
||||
# ---- Monthly traffic per client, summed over all servers; resets on the 1st ----
|
||||
# WireGuard counters live per server and drop to 0 on wg restart/reboot, so we add deltas.
|
||||
TRAFFIC_FILE = f"{PANEL_DIR}/traffic.json"
|
||||
_traffic_lock = threading.Lock()
|
||||
|
||||
|
||||
def _traffic_load():
|
||||
try:
|
||||
with open(TRAFFIC_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _local_transfer():
|
||||
out = {}
|
||||
try:
|
||||
for line in subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5).strip().split("\n")[1:]:
|
||||
p = line.split("\t")
|
||||
if len(p) >= 7:
|
||||
out[p[0]] = [int(p[5]), int(p[6])]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def traffic_sample():
|
||||
month = datetime.now().strftime("%Y-%m")
|
||||
with _traffic_lock:
|
||||
d = _traffic_load()
|
||||
if d.get("month") != month:
|
||||
if d.get("usage"):
|
||||
d.setdefault("history", {})[d.get("month", "?")] = d["usage"]
|
||||
d["month"], d["usage"] = month, {}
|
||||
prev, usage = d.setdefault("prev", {}), d.setdefault("usage", {})
|
||||
pub2cid = {c.get("public_key"): c.get("client_id") for c in get_clients() if c.get("public_key")}
|
||||
srcs = {SERVER_IP: _local_transfer()}
|
||||
srcs.update(server_transfer_cache)
|
||||
for ip, counters in srcs.items():
|
||||
for pub, (rx, tx) in counters.items():
|
||||
key, old, cid = ip + "|" + pub, prev.get(ip + "|" + pub), pub2cid.get(pub)
|
||||
prev[key] = [rx, tx]
|
||||
if old is None or not cid: # first sight = baseline only
|
||||
continue
|
||||
u = usage.setdefault(cid, [0, 0])
|
||||
u[0] += rx - old[0] if rx >= old[0] else rx # counter went down = wg restarted
|
||||
u[1] += tx - old[1] if tx >= old[1] else tx
|
||||
tmp = TRAFFIC_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(d, f)
|
||||
os.replace(tmp, TRAFFIC_FILE)
|
||||
|
||||
|
||||
def traffic_sampler():
|
||||
time.sleep(20)
|
||||
while True:
|
||||
try:
|
||||
traffic_sample()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
threading.Thread(target=traffic_sampler, daemon=True).start()
|
||||
|
||||
|
||||
PAGE = """<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
@@ -2294,6 +2361,27 @@ def _terminal_button(url, title):
|
||||
f'style="background:#334155" title="{_html_attr(title)}">🖥️</a>')
|
||||
|
||||
|
||||
def _fmt_bytes(n):
|
||||
for u in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if n < 1024 or u == "TiB":
|
||||
return f"{n:.2f} {u}" if u != "B" else f"{int(n)} B"
|
||||
n /= 1024
|
||||
|
||||
|
||||
def _traffic_cell(u):
|
||||
if not u:
|
||||
return "—"
|
||||
return f"↓ {_fmt_bytes(u[1])} · ↑ {_fmt_bytes(u[0])}" # server tx = client download
|
||||
|
||||
|
||||
@app.route("/api/traffic")
|
||||
@auth_required
|
||||
def api_traffic():
|
||||
"""Monthly traffic per client: current month + history ({client: [rx, tx]}, server-side bytes)."""
|
||||
d = _traffic_load()
|
||||
return jsonify({"month": d.get("month"), "usage": d.get("usage", {}), "history": d.get("history", {})})
|
||||
|
||||
|
||||
@app.route("/clients", methods=["GET", "POST"])
|
||||
@auth_required
|
||||
def clients_page():
|
||||
@@ -2601,6 +2689,7 @@ def clients_page():
|
||||
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
||||
</form>
|
||||
"""
|
||||
_tusage = _traffic_load().get("usage", {}) if "_tusage" not in dir() else _tusage
|
||||
lim = str(limits.get(cid, "") or "")
|
||||
rate = client_rates.get(pub)
|
||||
speed = (f'<span class="cspeed" title="{tr("Скорость сейчас","Current speed")}">↓{rate[0]:.1f} ↑{rate[1]:.1f} {tr("Мбит/с","Mbit/s")}</span>' if rate else "")
|
||||
@@ -2613,7 +2702,7 @@ def clients_page():
|
||||
<button class="btn btn-primary btn-sm" type="submit">{set_lbl}</button>
|
||||
</form>"""
|
||||
rows += f"""<div class="ccard">
|
||||
<div class="ctop"><span class="cname">{client_cell}</span> <span class="cip">{ip}</span> {status} {expiry_badge} {f'<span class="badge badge-lock">≤{_html_attr(lim)} Mbit</span>' if lim and lim != "0" else ""} {speed}</div>
|
||||
<div class="ctop"><span class="cname">{client_cell}</span> <span class="cip">{ip}</span> {status} {expiry_badge} <span class="cspeed" title="{tr('Трафик за месяц (суммарно по всем серверам)','Traffic this month (all servers)')}">{_traffic_cell(_tusage.get(cid))}</span> {f'<span class="badge badge-lock">≤{_html_attr(lim)} Mbit</span>' if lim and lim != "0" else ""} {speed}</div>
|
||||
<div class="cctl">{server_cell}{expiry_cell}{limit_cell}{rename_box}</div>
|
||||
<div class="cact">{actions_cell}</div>
|
||||
</div>"""
|
||||
|
||||
@@ -69,11 +69,16 @@ def health():
|
||||
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
||||
peer_keys = []
|
||||
handshakes = {}
|
||||
transfer = {}
|
||||
for line in out.strip().split("\n")[1:]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 4:
|
||||
pub = parts[0]
|
||||
peer_keys.append(pub)
|
||||
try:
|
||||
transfer[pub] = [int(parts[5]), int(parts[6])]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
handshakes[pub] = int(parts[4])
|
||||
except Exception:
|
||||
@@ -83,6 +88,7 @@ def health():
|
||||
peers = 0
|
||||
peer_keys = []
|
||||
handshakes = {}
|
||||
transfer = {}
|
||||
cpu = cpu_percent()
|
||||
try:
|
||||
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||
@@ -96,7 +102,7 @@ def health():
|
||||
mem_str = f"{used}/{total}MB"
|
||||
except Exception:
|
||||
mem_str = "?"
|
||||
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
|
||||
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "transfer": transfer, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
|
||||
|
||||
|
||||
@app.route("/api/obf-health")
|
||||
|
||||
Reference in New Issue
Block a user