Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad839e4cad | ||
|
|
28986677eb |
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.10 — 2026-11-03
|
||||||
|
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||||
|
|
||||||
## Как обновиться после фиксов
|
## Как обновиться после фиксов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
91
app.py
91
app.py
@@ -802,6 +802,7 @@ _last_fanout = 0
|
|||||||
_last_peer_sync = 0
|
_last_peer_sync = 0
|
||||||
server_stats_cache = {}
|
server_stats_cache = {}
|
||||||
server_handshakes_cache = {}
|
server_handshakes_cache = {}
|
||||||
|
server_transfer_cache = {} # server ip -> {pub: [rx, tx]} (from secondary /api/health)
|
||||||
|
|
||||||
|
|
||||||
def count_client_peers():
|
def count_client_peers():
|
||||||
@@ -889,6 +890,7 @@ def get_remote_stats(server):
|
|||||||
raise Exception("no response")
|
raise Exception("no response")
|
||||||
# cache handshakes so page renders never block on remote HTTP
|
# cache handshakes so page renders never block on remote HTTP
|
||||||
server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {}
|
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
|
# Filter peers: only count known client public keys
|
||||||
clients = get_clients()
|
clients = get_clients()
|
||||||
client_pubs = {c.get("public_key", "") for c in clients}
|
client_pubs = {c.get("public_key", "") for c in clients}
|
||||||
@@ -1751,6 +1753,71 @@ def finish_legacy_update():
|
|||||||
finish_legacy_update()
|
finish_legacy_update()
|
||||||
threading.Thread(target=rate_sampler, daemon=True).start()
|
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>
|
PAGE = """<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
@@ -2294,6 +2361,27 @@ def _terminal_button(url, title):
|
|||||||
f'style="background:#334155" title="{_html_attr(title)}">🖥️</a>')
|
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"])
|
@app.route("/clients", methods=["GET", "POST"])
|
||||||
@auth_required
|
@auth_required
|
||||||
def clients_page():
|
def clients_page():
|
||||||
@@ -2601,6 +2689,7 @@ def clients_page():
|
|||||||
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
||||||
</form>
|
</form>
|
||||||
"""
|
"""
|
||||||
|
_tusage = _traffic_load().get("usage", {}) if "_tusage" not in dir() else _tusage
|
||||||
lim = str(limits.get(cid, "") or "")
|
lim = str(limits.get(cid, "") or "")
|
||||||
rate = client_rates.get(pub)
|
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 "")
|
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>
|
<button class="btn btn-primary btn-sm" type="submit">{set_lbl}</button>
|
||||||
</form>"""
|
</form>"""
|
||||||
rows += f"""<div class="ccard">
|
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="cctl">{server_cell}{expiry_cell}{limit_cell}{rename_box}</div>
|
||||||
<div class="cact">{actions_cell}</div>
|
<div class="cact">{actions_cell}</div>
|
||||||
</div>"""
|
</div>"""
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ deploy_health_monitor() {
|
|||||||
mkdir -p /opt/var/spool/cron/crontabs
|
mkdir -p /opt/var/spool/cron/crontabs
|
||||||
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
|
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
|
||||||
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
|
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
|
||||||
sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null
|
sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null || true
|
||||||
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
||||||
log " Cron добавлен через crontab (health + pull)"
|
log " Cron добавлен через crontab (health + pull)"
|
||||||
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
|
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -69,11 +69,16 @@ def health():
|
|||||||
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
transfer = {}
|
||||||
for line in out.strip().split("\n")[1:]:
|
for line in out.strip().split("\n")[1:]:
|
||||||
parts = line.split("\t")
|
parts = line.split("\t")
|
||||||
if len(parts) >= 4:
|
if len(parts) >= 4:
|
||||||
pub = parts[0]
|
pub = parts[0]
|
||||||
peer_keys.append(pub)
|
peer_keys.append(pub)
|
||||||
|
try:
|
||||||
|
transfer[pub] = [int(parts[5]), int(parts[6])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
handshakes[pub] = int(parts[4])
|
handshakes[pub] = int(parts[4])
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -83,6 +88,7 @@ def health():
|
|||||||
peers = 0
|
peers = 0
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
transfer = {}
|
||||||
cpu = cpu_percent()
|
cpu = cpu_percent()
|
||||||
try:
|
try:
|
||||||
parts = Path("/proc/meminfo").read_text().splitlines()
|
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||||
@@ -96,7 +102,7 @@ def health():
|
|||||||
mem_str = f"{used}/{total}MB"
|
mem_str = f"{used}/{total}MB"
|
||||||
except Exception:
|
except Exception:
|
||||||
mem_str = "?"
|
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")
|
@app.route("/api/obf-health")
|
||||||
|
|||||||
Reference in New Issue
Block a user