Initial commit.

This commit is contained in:
Kalzu Rekku
2026-04-12 23:12:41 +03:00
commit a235a8e646
4 changed files with 869 additions and 0 deletions

11
Pipfile Normal file
View File

@@ -0,0 +1,11 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
[dev-packages]
[requires]
python_version = "3.13"

275
agent.py Normal file
View File

@@ -0,0 +1,275 @@
"""
kattila Agent
- Reports node state to manager (direct or via WG peer relay)
- Runs a relay server for segregated peers
- Authenticates with a pre-shared key (file, env, or DNS TXT)
"""
import subprocess
import json
import requests
import socket
import time
import threading
import os
import logging
from flask import Flask, request as freq, jsonify
# ── CONFIG ────────────────────────────────────────────────────────────────────
MANAGER_URL = os.environ.get("MANAGER_URL", "http://10.37.11.2:5086/status/api/report")
RELAY_PORT = int(os.environ.get("RELAY_PORT", "5087"))
REPORT_INTERVAL = int(os.environ.get("REPORT_INTERVAL", "60"))
PSK_FILE = os.environ.get("PSK_FILE", "/etc/kattila.status/psk")
PSK_DNS_RECORD = os.environ.get("PSK_DNS_RECORD", "") # e.g. "_kattila.homelab.local"
# ── PSK LOADING ───────────────────────────────────────────────────────────────
def _load_psk_dns(record: str) -> str | None:
"""
Fetch PSK from a DNS TXT record.
Store the key in your local/private DNS zone as:
_kattila.homelab.local. TXT "psk=your-key-here"
This lets you rotate the key on all nodes without touching any config file —
just update the DNS record. Agents re-fetch on each startup (cached to disk
as fallback below). Only use a private/internal zone — never a public one.
"""
try:
out = subprocess.check_output(
["dig", "+short", "TXT", record],
stderr=subprocess.DEVNULL, timeout=5
).decode()
for line in out.splitlines():
clean = line.strip().strip('"')
if clean.startswith("psk="):
return clean[4:]
except Exception:
pass
return None
def load_psk() -> str:
key: str | None = None
# 1. DNS TXT (if configured) — good for zero-touch key rotation
if PSK_DNS_RECORD:
key = _load_psk_dns(PSK_DNS_RECORD)
if key:
# Cache to disk for offline starts
try:
os.makedirs(os.path.dirname(PSK_FILE), exist_ok=True)
with open(PSK_FILE, "w") as f:
f.write(key)
except Exception:
pass
# 2. File (also DNS cache fallback)
if not key and os.path.isfile(PSK_FILE):
with open(PSK_FILE) as f:
key = f.read().strip()
# 3. Environment variable
if not key:
key = os.environ.get("kattila_PSK", "")
if not key:
raise RuntimeError(
"No PSK found. Set kattila_PSK env var, write to "
f"{PSK_FILE}, or configure PSK_DNS_RECORD."
)
return key
PSK = load_psk()
AUTH_HEADERS = {"X-kattila-PSK": PSK, "Content-Type": "application/json"}
# ── RELAY SERVER ──────────────────────────────────────────────────────────────
relay_app = Flask("kattila-relay")
logging.getLogger("werkzeug").setLevel(logging.ERROR)
@relay_app.route("/relay", methods=["POST"])
def relay():
if freq.headers.get("X-kattila-PSK") != PSK:
return jsonify({"error": "unauthorized"}), 401
try:
resp = requests.post(
MANAGER_URL, json=freq.get_json(), headers=AUTH_HEADERS, timeout=10
)
return jsonify(resp.json()), resp.status_code
except Exception as e:
return jsonify({"error": str(e)}), 502
@relay_app.route("/health")
def health():
return jsonify({"status": "ok"})
def _run_relay():
relay_app.run(host="0.0.0.0", port=RELAY_PORT, use_reloader=False)
# ── WIREGUARD HELPERS ─────────────────────────────────────────────────────────
def get_wg_interface_names() -> set[str]:
"""Return the names of actual WireGuard interfaces on this host."""
try:
out = subprocess.check_output(
["wg", "show", "interfaces"], stderr=subprocess.DEVNULL
).decode().strip()
return set(out.split()) if out else set()
except Exception:
return set()
def parse_wg_dump() -> list[dict]:
"""
Parse 'wg show all dump'. Peer lines have 9 tab-separated fields:
ifname pubkey psk endpoint allowed_ips handshake rx tx keepalive
Interface lines have 5 fields (private_key, public_key, listen_port, fwmark)
and are skipped.
"""
peers = []
try:
raw = subprocess.check_output(
["wg", "show", "all", "dump"], stderr=subprocess.DEVNULL
).decode().strip()
for line in raw.splitlines():
parts = line.split("\t")
if len(parts) == 9: # peer line
ifname, pubkey, _psk, endpoint, allowed_ips, handshake_ts, *_ = parts
handshake_ts = int(handshake_ts)
idle = 0 if handshake_ts == 0 else int(time.time()) - handshake_ts
peers.append({
"ifname": ifname,
"pubkey": pubkey,
"allowed_ips": allowed_ips,
"handshake": idle,
"status": "ok" if 0 < idle < 120 else "stale",
})
except Exception as e:
print(f"[!] WireGuard dump error: {e}")
return peers
def get_peer_relay_ips() -> list[str]:
"""
Extract the /32 tunnel IPs of WireGuard peers — used to find relay candidates
when direct manager reporting fails.
"""
ips = []
for peer in parse_wg_dump():
for cidr in peer["allowed_ips"].split(","):
cidr = cidr.strip()
if "." in cidr and cidr.endswith("/32"):
ips.append(cidr[:-3])
return ips
# ── DATA COLLECTION ───────────────────────────────────────────────────────────
def get_data() -> dict:
hostname = socket.gethostname()
wg_ifaces = get_wg_interface_names()
ifaces = []
try:
ip_data = json.loads(
subprocess.check_output(
["ip", "-j", "addr"], stderr=subprocess.DEVNULL
).decode()
)
for item in ip_data:
name = item["ifname"]
if name == "lo" or name.startswith("br-") or name.startswith("docker"):
continue
addr = next(
(a["local"] for a in item.get("addr_info", []) if a["family"] == "inet"),
None,
)
if not addr:
continue
entry: dict = {"name": name, "ip": addr}
# Only query WireGuard for actual WG interfaces — avoids
# "Unable to access interface: Operation not supported" spam
if name in wg_ifaces:
try:
pubkey = subprocess.check_output(
["wg", "show", name, "public-key"], stderr=subprocess.DEVNULL
).decode().strip()
entry["public_key"] = pubkey
except Exception:
pass
ifaces.append(entry)
except Exception as e:
print(f"[!] Interface collection error: {e}")
return {
"hostname": hostname,
"interfaces": ifaces,
"wg_peers": parse_wg_dump(),
}
# ── REPORTING ─────────────────────────────────────────────────────────────────
def report(payload: dict) -> bool:
hostname = payload["hostname"]
# 1. Direct report to manager
try:
r = requests.post(MANAGER_URL, json=payload, headers=AUTH_HEADERS, timeout=10)
if r.status_code == 200:
print(f"[+] {hostname} → manager (direct)")
return True
print(f"[!] Manager returned {r.status_code}")
except Exception as e:
print(f"[!] Direct report failed: {e}")
# 2. Relay through reachable WireGuard peers
candidates = get_peer_relay_ips()
if not candidates:
print("[-] No relay candidates found")
return False
for ip in candidates:
url = f"http://{ip}:{RELAY_PORT}/relay"
try:
r = requests.post(url, json=payload, headers=AUTH_HEADERS, timeout=8)
if r.status_code == 200:
print(f"[+] {hostname} → relay {ip}")
return True
print(f"[!] Relay {ip} returned {r.status_code}")
except Exception as e:
print(f"[!] Relay {ip} unreachable: {e}")
print(f"[-] All reporting paths failed for {hostname}")
return False
# ── MAIN ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
t = threading.Thread(target=_run_relay, daemon=True)
t.start()
print(f"[*] Relay server listening on :{RELAY_PORT}")
print(f"[*] Reporting to {MANAGER_URL} every {REPORT_INTERVAL}s")
while True:
try:
payload = get_data()
report(payload)
except Exception as e:
print(f"[!] Unexpected error: {e}")
time.sleep(REPORT_INTERVAL)

295
kattila.py Normal file
View File

@@ -0,0 +1,295 @@
"""
kattila Manager
- Accepts reports from agents (direct or relayed)
- Requires pre-shared key authentication
- Anonymizes IPs in the public-facing graph
"""
import sqlite3
import hashlib
import time
import os
import ipaddress # Added for IP validation
from flask import Flask, render_template_string, jsonify, request, abort
app = Flask(__name__)
DB_PATH = "network.db"
# ── PSK AUTH ──────────────────────────────────────────────────────────────────
PSK_FILE = os.environ.get("PSK_FILE", "/etc/kattila/psk")
def load_psk() -> str:
if os.path.isfile(PSK_FILE):
with open(PSK_FILE) as f:
return f.read().strip()
key = os.environ.get("kattila_PSK", "")
if not key:
raise RuntimeError(f"No PSK found. Write to {PSK_FILE} or set kattila_PSK.")
return key
PSK = load_psk()
def require_psk():
if request.headers.get("X-kattila-PSK") != PSK:
abort(401)
# ── DATABASE ──────────────────────────────────────────────────────────────────
def init_db():
with sqlite3.connect(DB_PATH) as conn:
conn.execute("""CREATE TABLE IF NOT EXISTS servers
(name TEXT PRIMARY KEY, last_seen INTEGER, level INTEGER)""")
conn.execute("""CREATE TABLE IF NOT EXISTS interfaces
(server_name TEXT, ifname TEXT, local_ip TEXT, public_key TEXT)""")
conn.execute(
"""CREATE TABLE IF NOT EXISTS wg_peers
(server_name TEXT, ifname TEXT, peer_pubkey TEXT, handshake INTEGER, status TEXT)"""
)
init_db()
# ── IP LOGIC ──────────────────────────────────────────────────────────────────
def is_public_ip(ip_str: str) -> bool:
"""
Returns True if the IP is a global, public address.
Returns False for RFC1918, loopback, link-local, or invalid IPs.
"""
try:
ip = ipaddress.ip_address(ip_str)
# is_private covers RFC1918, shared address space, etc.
return ip.is_global and not ip.is_private and not ip.is_loopback and not ip.is_link_local
except ValueError:
return False
def anonymize_ip(ip: str) -> str:
"""
Private RFC1918 addresses: reveal subnet, hide host octet.
Public addresses: replace with a short stable hash.
"""
parts = ip.split(".")
if len(parts) != 4:
return "???"
first, second = parts[0], parts[1]
try:
f = int(first)
except ValueError:
return "???"
if f == 10:
return f"10.{second}.*.*"
if f == 172 and 16 <= int(second) <= 31:
return f"172.{second}.*.*"
if f == 192 and second == "168":
return f"192.168.*.*"
token = hashlib.sha256(ip.encode()).hexdigest()[:6]
return f"[pub:{token}]"
# ── API ───────────────────────────────────────────────────────────────────────
@app.route("/api/report", methods=["POST"])
def api_report():
require_psk()
data = request.json
hostname = data["hostname"].lower()
now = int(time.time())
# DYNAMIC LEVEL DETERMINATION:
# Default to level 1 (Spoke). If we find a public IP, upgrade to level 0 (Hub).
level = 1
interfaces_data = data.get("interfaces", [])
for iface in interfaces_data:
if is_public_ip(iface.get("ip", "")):
level = 0
break
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT OR REPLACE INTO servers VALUES (?, ?, ?)",
(hostname, now, level),
)
conn.execute("DELETE FROM interfaces WHERE server_name = ?", (hostname,))
for iface in interfaces_data:
conn.execute(
"INSERT INTO interfaces VALUES (?, ?, ?, ?)",
(hostname, iface["name"], iface["ip"], iface.get("public_key")),
)
conn.execute("DELETE FROM wg_peers WHERE server_name = ?", (hostname,))
for peer in data.get("wg_peers", []):
conn.execute(
"INSERT INTO wg_peers VALUES (?, ?, ?, ?, ?)",
(
hostname,
peer["ifname"],
peer["pubkey"],
peer["handshake"],
peer["status"],
),
)
return jsonify({"status": "ok", "detected_level": level})
@app.route("/status/data")
def get_graph_data():
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
servers = conn.execute("SELECT * FROM servers").fetchall()
interfaces = conn.execute("SELECT * FROM interfaces").fetchall()
peers = conn.execute("SELECT * FROM wg_peers").fetchall()
key_to_server = {
i["public_key"]: i["server_name"] for i in interfaces if i["public_key"]
}
now = time.time()
nodes = []
for s in servers:
anon_ips = [
anonymize_ip(i["local_ip"])
for i in interfaces
if i["server_name"] == s["name"]
]
age = int(now - s["last_seen"])
is_alive = age < 300
nodes.append(
{
"id": s["name"],
"label": f"<b>{s['name'].upper()}</b>\n{', '.join(anon_ips[:2])}",
"level": s["level"],
"color": "#2ecc71" if is_alive else "#e74c3c",
"title": f"Last seen {age}s ago",
}
)
edges = []
seen_pairs: set[tuple] = set()
for p in peers:
target = key_to_server.get(p["peer_pubkey"])
if not target:
continue
pair = tuple(sorted([p["server_name"], target]))
if pair in seen_pairs:
continue
seen_pairs.add(pair)
h = p["handshake"]
if h == 0:
color = "#95a5a6"
elif h <= 120:
color = "#2ecc71"
elif h <= 86400:
color = "#f1c40f"
else:
color = "#e74c3c"
edges.append(
{
"from": p["server_name"],
"to": target,
"label": p["ifname"],
"color": color,
"width": 3 if color == "#e74c3c" else 2,
"title": f"{h}s since handshake" if h else "No handshake yet",
}
)
return jsonify({"nodes": nodes, "edges": edges})
# ── FRONTEND ──────────────────────────────────────────────────────────────────
@app.route("/status")
def index():
return render_template_string(HTML_TEMPLATE)
HTML_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<title>kattila — Network Map</title>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Segoe UI', sans-serif; background: #0d1117; color: #e6edf3; margin: 0; }
#map { width: 100vw; height: 100vh; }
#hud {
position: absolute; top: 12px; left: 12px;
background: rgba(13,17,23,0.88); backdrop-filter: blur(6px);
border: 1px solid #30363d; border-radius: 8px;
padding: 12px 16px; font-size: 12px; z-index: 10; min-width: 140px;
}
#hud b { display: block; margin-bottom: 6px; font-size: 13px; color: #58a6ff; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
.row { margin: 3px 0; }
#clock { margin-top: 10px; font-size: 10px; color: #8b949e; border-top: 1px solid #30363d; padding-top: 6px; }
</style>
</head>
<body>
<div id="hud">
<b>Link status</b>
<div class="row"><span class="dot" style="background:#2ecc71"></span>Active (&lt; 2 min)</div>
<div class="row"><span class="dot" style="background:#f1c40f"></span>Stale (&gt; 2 min)</div>
<div class="row"><span class="dot" style="background:#e74c3c"></span>Broken (&gt; 24 h)</div>
<div class="row"><span class="dot" style="background:#95a5a6"></span>No handshake</div>
<div id="clock"></div>
</div>
<div id="map"></div>
<script>
const nodes = new vis.DataSet();
const edges = new vis.DataSet();
const net = new vis.Network(
document.getElementById('map'),
{ nodes, edges },
{
layout: { hierarchical: { direction: 'UD', nodeSpacing: 300, levelSeparation: 200 } },
physics: false,
nodes: {
shape: 'box',
font: { multi: 'html', color: '#e6edf3', size: 13 },
margin: 10,
color: { background: '#161b22', border: '#30363d' },
},
edges: {
arrows: 'to',
font: { size: 10, color: '#8b949e', strokeWidth: 0 },
smooth: { type: 'curvedCW', roundness: 0.15 },
},
}
);
async function refresh() {
const res = await fetch('/status/data');
const data = await res.json();
nodes.update(data.nodes);
edges.update(data.edges);
document.getElementById('clock').textContent =
'Updated ' + new Date().toLocaleTimeString();
}
setInterval(refresh, 10000);
refresh();
</script>
</body>
</html>"""
if __name__ == "__main__":
app.run(host="10.37.11.2", port=5086)

288
kattila01.py Normal file
View File

@@ -0,0 +1,288 @@
"""
kattila Manager
- Accepts reports from agents (direct or relayed)
- Requires pre-shared key authentication
- Anonymizes IPs in the public-facing graph
"""
import sqlite3
import hashlib
import time
import os
from flask import Flask, render_template_string, jsonify, request, abort
app = Flask(__name__)
DB_PATH = "network.db"
# ── PSK AUTH ──────────────────────────────────────────────────────────────────
PSK_FILE = os.environ.get("PSK_FILE", "/etc/kattila/psk")
def load_psk() -> str:
if os.path.isfile(PSK_FILE):
with open(PSK_FILE) as f:
return f.read().strip()
key = os.environ.get("kattila_PSK", "")
if not key:
raise RuntimeError(f"No PSK found. Write to {PSK_FILE} or set kattila_PSK.")
return key
PSK = load_psk()
def require_psk():
if request.headers.get("X-kattila-PSK") != PSK:
abort(401)
# ── DATABASE ──────────────────────────────────────────────────────────────────
def init_db():
with sqlite3.connect(DB_PATH) as conn:
conn.execute("""CREATE TABLE IF NOT EXISTS servers
(name TEXT PRIMARY KEY, last_seen INTEGER, level INTEGER)""")
conn.execute("""CREATE TABLE IF NOT EXISTS interfaces
(server_name TEXT, ifname TEXT, local_ip TEXT, public_key TEXT)""")
conn.execute(
"""CREATE TABLE IF NOT EXISTS wg_peers
(server_name TEXT, ifname TEXT, peer_pubkey TEXT, handshake INTEGER, status TEXT)"""
)
init_db()
# level 0 = public hub, level 1 = private spoke
SERVER_CONFIG = {
"pussi": 0,
"node03": 0,
"koti": 1,
"tuoppi2": 1,
"node04": 1,
}
# ── IP ANONYMIZATION ──────────────────────────────────────────────────────────
def anonymize_ip(ip: str) -> str:
"""
Private RFC1918 addresses: reveal subnet, hide host octet.
Public addresses: replace with a short stable hash (same IP always
produces the same token, so topology is still readable).
"""
parts = ip.split(".")
if len(parts) != 4:
return "???"
first, second = parts[0], parts[1]
try:
f = int(first)
except ValueError:
return "???"
if f == 10:
return f"10.{second}.*.*"
if f == 172 and 16 <= int(second) <= 31:
return f"172.{second}.*.*"
if f == 192 and second == "168":
return f"192.168.*.*"
# Public IP — stable 6-char token so the graph stays consistent across
# refreshes without leaking the actual address
token = hashlib.sha256(ip.encode()).hexdigest()[:6]
return f"[pub:{token}]"
# ── API ───────────────────────────────────────────────────────────────────────
@app.route("/api/report", methods=["POST"])
def api_report():
require_psk()
data = request.json
hostname = data["hostname"].lower()
now = int(time.time())
level = SERVER_CONFIG.get(hostname, 1)
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT OR REPLACE INTO servers VALUES (?, ?, ?)",
(hostname, now, level),
)
conn.execute("DELETE FROM interfaces WHERE server_name = ?", (hostname,))
for iface in data.get("interfaces", []):
conn.execute(
"INSERT INTO interfaces VALUES (?, ?, ?, ?)",
(hostname, iface["name"], iface["ip"], iface.get("public_key")),
)
conn.execute("DELETE FROM wg_peers WHERE server_name = ?", (hostname,))
for peer in data.get("wg_peers", []):
conn.execute(
"INSERT INTO wg_peers VALUES (?, ?, ?, ?, ?)",
(
hostname,
peer["ifname"],
peer["pubkey"],
peer["handshake"],
peer["status"],
),
)
return jsonify({"status": "ok"})
@app.route("/status/data")
def get_graph_data():
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
servers = conn.execute("SELECT * FROM servers").fetchall()
interfaces = conn.execute("SELECT * FROM interfaces").fetchall()
peers = conn.execute("SELECT * FROM wg_peers").fetchall()
key_to_server = {
i["public_key"]: i["server_name"] for i in interfaces if i["public_key"]
}
now = time.time()
nodes = []
for s in servers:
anon_ips = [
anonymize_ip(i["local_ip"])
for i in interfaces
if i["server_name"] == s["name"]
]
age = int(now - s["last_seen"])
is_alive = age < 300
nodes.append(
{
"id": s["name"],
"label": f"<b>{s['name'].upper()}</b>\n{', '.join(anon_ips[:2])}",
"level": s["level"],
"color": "#2ecc71" if is_alive else "#e74c3c",
"title": f"Last seen {age}s ago", # hover tooltip
}
)
edges = []
seen_pairs: set[tuple] = set()
for p in peers:
target = key_to_server.get(p["peer_pubkey"])
if not target:
continue
# De-duplicate bidirectional edges (A→B and B→A → one edge)
pair = tuple(sorted([p["server_name"], target]))
if pair in seen_pairs:
continue
seen_pairs.add(pair)
h = p["handshake"]
if h == 0:
color = "#95a5a6" # grey — no handshake ever
elif h <= 120:
color = "#2ecc71" # green — active
elif h <= 86400:
color = "#f1c40f" # yellow — stale
else:
color = "#e74c3c" # red — effectively broken
edges.append(
{
"from": p["server_name"],
"to": target,
"label": p["ifname"],
"color": color,
"width": 3 if color == "#e74c3c" else 2,
"title": f"{h}s since handshake" if h else "No handshake yet",
}
)
return jsonify({"nodes": nodes, "edges": edges})
# ── FRONTEND ──────────────────────────────────────────────────────────────────
@app.route("/status")
def index():
return render_template_string(HTML_TEMPLATE)
HTML_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<title>kattila — Network Map</title>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Segoe UI', sans-serif; background: #0d1117; color: #e6edf3; margin: 0; }
#map { width: 100vw; height: 100vh; }
#hud {
position: absolute; top: 12px; left: 12px;
background: rgba(13,17,23,0.88); backdrop-filter: blur(6px);
border: 1px solid #30363d; border-radius: 8px;
padding: 12px 16px; font-size: 12px; z-index: 10; min-width: 140px;
}
#hud b { display: block; margin-bottom: 6px; font-size: 13px; color: #58a6ff; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
.row { margin: 3px 0; }
#clock { margin-top: 10px; font-size: 10px; color: #8b949e; border-top: 1px solid #30363d; padding-top: 6px; }
</style>
</head>
<body>
<div id="hud">
<b>Link status</b>
<div class="row"><span class="dot" style="background:#2ecc71"></span>Active (&lt; 2 min)</div>
<div class="row"><span class="dot" style="background:#f1c40f"></span>Stale (&gt; 2 min)</div>
<div class="row"><span class="dot" style="background:#e74c3c"></span>Broken (&gt; 24 h)</div>
<div class="row"><span class="dot" style="background:#95a5a6"></span>No handshake</div>
<div id="clock"></div>
</div>
<div id="map"></div>
<script>
const nodes = new vis.DataSet();
const edges = new vis.DataSet();
const net = new vis.Network(
document.getElementById('map'),
{ nodes, edges },
{
layout: { hierarchical: { direction: 'UD', nodeSpacing: 300, levelSeparation: 200 } },
physics: false,
nodes: {
shape: 'box',
font: { multi: 'html', color: '#e6edf3', size: 13 },
margin: 10,
color: { background: '#161b22', border: '#30363d' },
},
edges: {
arrows: 'to',
font: { size: 10, color: '#8b949e', strokeWidth: 0 },
smooth: { type: 'curvedCW', roundness: 0.15 },
},
}
);
async function refresh() {
const res = await fetch('/status/data');
const data = await res.json();
nodes.update(data.nodes);
edges.update(data.edges);
document.getElementById('clock').textContent =
'Updated ' + new Date().toLocaleTimeString();
}
setInterval(refresh, 10000);
refresh();
</script>
</body>
</html>"""
if __name__ == "__main__":
app.run(host="10.37.11.2", port=5086)