Files
2026-10-04 19:21:50 +02:00

124 lines
4.3 KiB
Python

"""JSON API used by the patch editor, services and stats pages."""
from flask import Blueprint, jsonify, request
from . import daemon, db, validate
from .auth import login_required
bp = Blueprint("api", __name__, url_prefix="/api")
RANGES = { # range -> (seconds, tier, bucket seconds)
"1h": (3600, "m", 60),
"24h": (86400, "m", 60),
"7d": (7 * 86400, "h", 3600),
"30d": (30 * 86400, "h", 3600),
"1y": (365 * 86400, "d", 86400),
"all": (None, "d", 86400),
}
def _clients():
rows = db.get().execute("SELECT id, name, hostname, last_seen, last_addr FROM clients ORDER BY name")
return [dict(r) for r in rows]
@bp.get("/graph")
@login_required
def graph_get():
conn = db.get()
nodes = [dict(r) for r in conn.execute(
"SELECT id, type, client_id, host, port, proto, label, iface, origin, x, y FROM nodes ORDER BY id")]
links = [{"from": r["from_node"], "to": r["to_node"]} for r in conn.execute(
"SELECT from_node, to_node FROM links")]
return jsonify(nodes=nodes, links=links, clients=_clients(), interfaces=_interfaces())
def _interfaces():
"""{host id: [{name, addr}]}, host id 0 = target."""
out = {}
for r in db.get().execute("SELECT client_id, name, addr FROM interfaces ORDER BY client_id, name"):
out.setdefault(str(r["client_id"]), []).append({"name": r["name"], "addr": r["addr"]})
return out
@bp.put("/graph")
@login_required
def graph_put():
conn = db.get()
client_ids = {r["id"] for r in conn.execute("SELECT id FROM clients")}
try:
nodes, links = validate.graph(request.get_json(silent=True), client_ids)
except (validate.Invalid, TypeError, ValueError) as e:
return jsonify(ok=False, error=str(e)), 400
idmap = {}
conn.execute("BEGIN IMMEDIATE")
try:
existing = {r["id"] for r in conn.execute("SELECT id FROM nodes")}
keep = {n["id"] for n in nodes if n["id"] > 0}
for nid in existing - keep:
conn.execute("DELETE FROM nodes WHERE id = ?", (nid,))
cols = ("type", "client_id", "host", "port", "proto", "label", "iface", "origin", "x", "y")
for n in nodes:
vals = [n[c] for c in cols]
if n["id"] > 0 and n["id"] in existing:
conn.execute(f"UPDATE nodes SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
vals + [n["id"]])
idmap[n["id"]] = n["id"]
else:
cur = conn.execute(f"INSERT INTO nodes ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})",
vals)
idmap[n["id"]] = cur.lastrowid
conn.execute("DELETE FROM links")
for a, b in links:
conn.execute("INSERT INTO links (from_node, to_node) VALUES (?, ?)", (idmap[a], idmap[b]))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
res = daemon.reload()
return jsonify(ok=True, ids={str(k): v for k, v in idmap.items()}, daemon=res.get("ok", False),
daemon_error=res.get("error"))
@bp.get("/status")
@login_required
def status():
st = daemon.request("STATUS")
return jsonify(daemon=st.get("ok", False), error=st.get("error"), clients=st.get("clients", {}),
sinks=st.get("sinks", {}))
@bp.get("/live")
@login_required
def live():
st = daemon.request("LIVE")
return jsonify(daemon=st.get("ok", False), sinks=st.get("sinks", {}))
@bp.post("/services/refresh")
@login_required
def services_refresh():
res = daemon.request("SERVICES")
return jsonify(ok=res.get("ok", False), error=res.get("error"))
@bp.get("/stats")
@login_required
def stats():
rng = request.args.get("range", "24h")
if rng not in RANGES:
return jsonify(error="bad range"), 400
span, tier, step = RANGES[rng]
try:
sink = int(request.args.get("sink", "0"))
except ValueError:
return jsonify(error="bad sink"), 400
since = db.now() - span if span else 0
rows = db.get().execute(
"SELECT ts, bytes_in, bytes_out, conns FROM stats WHERE sink_id = ? AND tier = ? AND ts >= ? ORDER BY ts",
(sink, tier, since)).fetchall()
return jsonify(range=rng, step=step, since=since, now=db.now(),
points=[[r["ts"], r["bytes_in"], r["bytes_out"], r["conns"]] for r in rows])