#!/usr/bin/env python3
"""Data Flow — an animated map of how data moves through the NGON stack.

Read-only and explanatory: field hardware -> collectors -> VPN/HTTPS -> cloud
APIs -> stores -> pages. The topology below is hand-maintained (it describes
intent, which no amount of grepping recovers); only the site/pod layer and the
live overlay are generated at runtime.

Live overlay comes from dataflow_data_cgi.py (disk-cached, ~5 s).
"""

import cgi
import json
import sys

sys.path.insert(0, '/opt/ngon/apps')
sys.path.insert(0, '/var/www/html/ngon')
from managers.auth_manager import AuthManager, generate_login_page_html
from links import generate_dropdown_html, generate_dropdown_css, generate_dropdown_js

MASTER_CONFIG_FILE = "/opt/ngon/config/master_config.json"

form = cgi.FieldStorage()
auth = AuthManager('dataflow')
auth_required, should_exit, headers = auth.require_auth(form)

if should_exit:
    print("Content-Type: application/json")
    if headers:
        print(headers)
    print("")
    print('{"success": false, "error": "Authentication required"}' if auth_required
          else '{"success": true}')
    sys.exit(0)

if auth_required:
    print("Content-Type: text/html\n")
    print(generate_login_page_html("Data Flow"))
    sys.exit(0)

_user_access = AuthManager.get_user_access()

# ---------------------------------------------------------------------------
# Field layer: sites and pods, straight out of master_config
# ---------------------------------------------------------------------------
SKIP_SITES = {'Spares', 'Out of Service', 'West TX TBD'}


def build_sites():
    try:
        with open(MASTER_CONFIG_FILE) as f:
            cfg = json.load(f)
    except Exception:
        return []

    sites = []
    for site_name, site in (cfg.get('sites') or {}).items():
        if site_name in SKIP_SITES:
            continue
        pods = []
        for group_name, group in (site.get('generator_groups') or {}).items():
            gens = len(group.get('generators') or {})
            for pod_name, pod in (group.get('pods') or {}).items():
                pods.append({
                    'name': pod_name,
                    'net': pod.get('network') or group.get('network') or '',
                    'miners': pod.get('miner_count') or 0,
                    'group': group_name,
                    'gens': gens,
                })
        if not pods:
            continue
        pods.sort(key=lambda p: p['name'])
        sites.append({
            'name': site_name,
            'pods': pods,
            'miners': sum(p['miners'] for p in pods),
            'flow': bool(site.get('flow_meter')),
        })
    sites.sort(key=lambda s: -s['miners'])
    return sites


SITES = build_sites()

# ---------------------------------------------------------------------------
# Topology
# ---------------------------------------------------------------------------
LANES = [
    {'id': 'ext_in',  'title': 'Upstream sources',   'note': 'third-party APIs we pull from'},
    {'id': 'field',   'title': 'The field',          'note': 'metal in a pasture'},
    {'id': 'collect', 'title': 'Collectors',         'note': 'field servers that talk to the metal'},
    {'id': 'net',     'title': 'Transport',          'note': 'how bytes get to the cloud'},
    {'id': 'edge',    'title': 'Cloud edge',         'note': 'ngon.us — Apache + UFW'},
    {'id': 'ingest',  'title': 'Ingest APIs',        'note': 'field pushes land here'},
    {'id': 'brain',   'title': 'State & automation', 'note': 'the part that decides things'},
    {'id': 'store',   'title': 'Stores',             'note': 'where it persists'},
    {'id': 'read',    'title': 'Read / action APIs', 'note': 'what the pages call'},
    {'id': 'out',     'title': 'Consumers',          'note': 'humans and other boxes'},
    {'id': 'ext_out', 'title': 'Downstream',         'note': 'where output goes'},
]

# kind: ext | hw | agent | net | api | svc | cron | store | page
NODES = [
    # ---- upstream sources -------------------------------------------------
    {'id': 'mesa_api', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Mesa Provider API',
     'sub': 'HTTPS poll', 'flows': ['gen'],
     'desc': "Our generator vendor's cloud. Run status, kW, pressures, engine hours "
             "for every gen. Polled every 60 s. Can return HTTP 200 with frozen data — "
             "that's what the staleness watchdog exists for. Being retired in favour of "
             "our own Modbus feed."},
    {'id': 'peplink_ic', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Peplink InControl',
     'sub': 'OAuth', 'flows': ['net'],
     'desc': "Cloud manager for our 28 Peplink routers. Gives us WAN health plus the "
             "client list per pod — which is how agents find miner IPs fast instead of "
             "sweeping a whole /23."},
    {'id': 'blink_cloud', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Blink Cloud',
     'sub': 'Amazon', 'flows': ['cams'],
     'desc': "Site cameras. Cloudflare blocks our datacenter IP, so only the field "
             "server can talk to it — hence the outbound job-queue design."},
    {'id': 'weather_api', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Weather API',
     'sub': 'hourly', 'flows': ['misc'], 'desc': "Per-site conditions shown on the status page."},
    {'id': 'foundry', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Foundry Pool API',
     'sub': 'X-API-KEY', 'flows': ['report'],
     'desc': "The mining pool. Real earnings and pool-side hashrate per worker "
             "(ND and TX are separate workers). The honest number our own hashrate is "
             "checked against."},
    {'id': 'sms_people', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Texters',
     'sub': 'gas providers · field staff · haulers', 'flows': ['text'],
     'desc': "The people on the other end of a group text. They need no account and see "
             "nothing but an ordinary SMS thread from our company number — no app, no "
             "Google login, no idea a bridge exists."},
    {'id': 'gchat_spaces', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Google Chat Spaces',
     'sub': 'service account · domain-wide delegation', 'flows': ['text'],
     'desc': "The NGON-internal half of the same conversation. A Space is bridged to an SMS "
             "group: staff talk in Chat, the outside party texts, and neither sees the "
             "other's mechanism. Auth impersonates a real Workspace user (textbridge@) "
             "rather than running as a Marketplace bot — one admin screen instead of a "
             "publishing process, at the cost of posts appearing from a user."},
    {'id': 'gsheets_in', 'lane': 'ext_in', 'kind': 'ext', 'label': 'Google Sheets',
     'sub': 'inventory master', 'flows': ['inventory'],
     'desc': "The miner inventory master sheet — serial, MAC, pod, position. Pulled hourly."},

    # ---- the field --------------------------------------------------------
    {'id': 'pods', 'lane': 'field', 'kind': 'hw', 'label': 'Pods & miners', 'sub': 'Whatsminer M60',
     'flows': ['miner', 'control'], 'special': 'sites',
     'desc': "Every container of miners, grouped by site. Each pod is a /23. A pod chip "
             "lights up when a scanner is actually talking to it right now."},
    {'id': 'gens_hw', 'lane': 'field', 'kind': 'hw', 'label': 'Generators',
     'sub': 'DSE controllers · Modbus TCP', 'flows': ['gen', 'control'],
     'desc': "Deep Sea controllers on each genset, reached over MikroTik wifi bridges. "
             "Source of live kW, pressures, coolant temp, alarms — and the write path for "
             "remote clear-alarm / manual / start. A tech putting the panel into service "
             "mode shows up here too, as register 42944 — which is how a man with a wrench "
             "on a gen becomes something the automation can see."},
    {'id': 'flow_hw', 'lane': 'field', 'kind': 'hw', 'label': 'Gas flow meters',
     'sub': 'ABB Totalflow · GN, Will', 'flows': ['gas'],
     'desc': "Modbus 32-bit floats off the ABB Totalflow. How much gas the site is "
             "actually getting — the ceiling on how many miners can run."},
    {'id': 'cams_hw', 'lane': 'field', 'kind': 'hw', 'label': 'Blink cameras',
     'sub': 'sync modules', 'flows': ['cams'], 'desc': "Battery cameras and sync modules per site."},
    {'id': 'ptz_hw', 'lane': 'field', 'kind': 'hw', 'label': 'PTZ cameras',
     'sub': 'ONVIF / PoE · on the pod LAN', 'flows': ['cams'],
     'desc': "Wired cameras that sit on the pod network itself, so the cloud reaches them "
             "straight down the tunnel — no field agent, no vendor cloud, no rate limit. "
             "They run DHCP, so the lease moves and the MAC is the only stable handle; "
             "that's what master_config stores."},
    {'id': 'peps_hw', 'lane': 'field', 'kind': 'hw', 'label': 'Peplink + Starlink',
     'sub': '28 routers', 'flows': ['net'],
     'desc': "Starlink dish into a Peplink router at every pod. Everything below rides this."},

    # ---- collectors -------------------------------------------------------
    {'id': 'pod_agents', 'lane': 'collect', 'kind': 'agent', 'label': 'Pod agents',
     'sub': 'agent_launcher · 39 threads', 'flows': ['miner', 'control'],
     'file': '/home/ngon/backend_v2/pod_agent.py',
     'desc': "One thread per pod network on the field server (cherub). Scans its miners "
             "every ~30 s, does the hourly pool sweep, and runs the Emergency-Sleep hammer "
             "loop when EMS is armed. This is the 'scanner' you see on the status page."},
    {'id': 'field_api', 'lane': 'collect', 'kind': 'agent', 'label': 'field_miner_api',
     'sub': 'cherub :6060', 'flows': ['control'],
     'file': '/home/ngon/backend_v2/field_miner_api.py',
     'desc': "Executes commands against miners: sleep, wake, reboot, set power, fix pools. "
             "The cloud never talks to a miner directly — it asks this."},
    {'id': 'plucker_relay', 'lane': 'collect', 'kind': 'agent', 'label': 'plucker_relay',
     'sub': 'UDP 8888', 'flows': ['miner'],
     'desc': "Relays miner IPFOUND broadcasts up to the cloud so a newly-plugged M60 shows "
             "itself without a scan."},
    {'id': 'blink_agent', 'lane': 'collect', 'kind': 'agent', 'label': 'blink_agent',
     'sub': 'outbound job poll', 'flows': ['cams'],
     'desc': "Only process allowed to touch Blink. Polls the cloud for snapshot jobs, takes "
             "the picture, posts the JPG back. No inbound port needed."},
    {'id': 'gen_collector', 'lane': 'collect', 'kind': 'agent', 'label': 'gen_collector',
     'sub': 'VM · Modbus 1 Hz', 'flows': ['gen'],
     'desc': "Polls every DSE controller directly: a fast 1 Hz power/status read, plus a "
             "~5-minute full register block (~80 fields). Our own gen data, independent of Mesa."},
    {'id': 'flow_collector', 'lane': 'collect', 'kind': 'agent', 'label': 'flow_collector',
     'sub': 'VM · ~1/min', 'flows': ['gas'],
     'desc': "Reads the ABB Totalflow meters and pushes the readings to the cloud."},
    {'id': 'company_phone', 'lane': 'collect', 'kind': 'agent', 'label': 'Company phone',
     'sub': 'Android · holds the SIM', 'flows': ['text'],
     'desc': "One handset, two apps, one job each. Ours sends — it drives the radio directly "
             "and can do real group MMS — by polling /outbox, sending, then acking. The "
             "SMS Gateway app only receives, posting signed webhooks for inbound SMS/MMS. "
             "The phone is behind carrier CGNAT and can never be reached inbound, so every "
             "exchange is phone-initiated; nothing here pushes to it."},
    {'id': 'pickaxes', 'lane': 'collect', 'kind': 'agent', 'label': 'Foreman Pickaxes',
     'sub': 'office LAN · ×6', 'flows': ['miner'],
     'desc': "Foreman's own scanners, on the office network. They compete with our agents "
             "for the miners' limited connection slots — so we watch what they're scanning "
             "and stay out of the way."},

    # ---- transport --------------------------------------------------------
    {'id': 'push_path', 'lane': 'net', 'kind': 'net', 'label': 'Direct HTTPS push',
     'sub': 'field → ngon.us', 'flows': ['miner', 'gen', 'gas', 'cams'],
     'desc': "The load-bearing path. Field boxes push out to ngon.us over the public "
             "internet — deliberately NOT through the VPN tunnel, so tunnel trouble can't "
             "stop data arriving."},
    {'id': 'vpn', 'lane': 'net', 'kind': 'net', 'label': 'SpeedFusion VPN',
     'sub': 'FusionHub 10.116.0.3 → Hearne hub', 'flows': ['control', 'net'],
     'desc': "Cloud → field direction rides the tunnel: monitoring polls and anything that "
             "needs to reach into a pod network. One hop through the hub; a bad DO host "
             "shows up here as flapping."},

    # ---- edge -------------------------------------------------------------
    {'id': 'apache', 'lane': 'edge', 'kind': 'net', 'label': 'Apache', 'sub': ':443 + ProxyPass',
     'flows': ['miner', 'gen', 'gas', 'control', 'cams', 'config', 'report', 'inventory'],
     'file': '/etc/apache2/sites-available/000-default-le-ssl.conf',
     'desc': "TLS termination and the whole /api/* routing table. Every internal service "
             "hides behind this on localhost."},
    {'id': 'ufw', 'lane': 'edge', 'kind': 'net', 'label': 'UFW',
     'sub': '4999 · 80/443 · 5006 · 5014 · 5020', 'flows': ['net'],
     'desc': "What's actually reachable from outside: SSH on 4999, the web, the miner-push "
             "port 5006, and two VPC-scoped ports. Everything else is localhost-only."},

    # ---- ingest -----------------------------------------------------------
    {'id': 'listen_miner', 'lane': 'ingest', 'kind': 'api', 'label': 'listen_miner_data_api',
     'sub': ':5006', 'svc': 'listen_miner_data_api', 'flows': ['miner'],
     'file': '/opt/ngon/api/listen_miner_data_api.py',
     'desc': "Receives every pod scan. Writes miner_status.csv, computes the pod summary "
             "(online / hashing / sleeping) and pushes it to the status API. The busiest "
             "thing on the box."},
    {'id': 'gen_ingest', 'lane': 'ingest', 'kind': 'api', 'label': 'gen_ingest_api',
     'sub': ':5018', 'svc': 'gen_ingest_api', 'flows': ['gen'],
     'file': '/opt/ngon/api/gen_ingest_api.py',
     'desc': "Two doors: 1 Hz live power/status straight into live state, and the slow "
             "5-minute register block into gen_telemetry.db. Also evaluates warning "
             "thresholds, recomputes PM due-dates as engine hours arrive, and mirrors the "
             "panel service-mode flag so the control side knows a tech is on the gen."},
    {'id': 'flow_api', 'lane': 'ingest', 'kind': 'api', 'label': 'flow_api', 'sub': ':5017',
     'svc': 'flow_api', 'flows': ['gas'], 'file': '/opt/ngon/api/flow_api.py',
     'desc': "Gas readings in, gas_flow.db out, latest value mirrored to live state."},
    {'id': 'agent_monitor', 'lane': 'ingest', 'kind': 'api', 'label': 'agent_monitor_api',
     'sub': ':5013', 'svc': 'agent_monitor_api', 'flows': ['miner'],
     'file': '/opt/ngon/api/agent_monitor_api.py',
     'desc': "Scanner health. The field launcher posts a full snapshot every 5 s — which "
             "pod is scanning, how many miners it saw, Peplink stats. Also holds the "
             "Pickaxe watcher state. This page's live overlay is mostly this."},
    {'id': 'blink_api', 'lane': 'ingest', 'kind': 'api', 'label': 'blink_api', 'sub': ':5016',
     'svc': 'blink_api', 'flows': ['cams'], 'file': '/opt/ngon/api/blink_api.py',
     'desc': "Job queue + snapshot cache. Browsers ask it for a picture; the field agent "
             "picks the job up on its next outbound poll."},
    {'id': 'inv_webhook', 'lane': 'ingest', 'kind': 'api', 'label': 'inventory_webhook_api',
     'sub': ':5014 · VPC only', 'svc': 'inventory_webhook_api', 'flows': ['inventory'],
     'desc': "inventory.ngon.us tells us when someone puts or pulls a miner, and validates "
             "login cookies for the other subdomains (single sign-on)."},

    # ---- brain ------------------------------------------------------------
    {'id': 'status_api', 'lane': 'brain', 'kind': 'api', 'label': 'status_api', 'sub': ':5050 + WebSocket',
     'svc': 'status_api', 'flows': ['miner', 'gen', 'gas', 'control', 'config'],
     'file': '/opt/ngon/api/status_api.py',
     'desc': "The hub. Holds the entire live picture in memory — config merged with live "
             "stats — and streams every change to open dashboards over WebSocket. If a "
             "number moves on the status page, it moved through here."},
    {'id': 'gen_monitor', 'lane': 'brain', 'kind': 'svc', 'label': 'generator_monitor',
     'sub': 'continuous', 'svc': 'generator_monitor', 'flows': ['gen', 'control'],
     'file': '/opt/ngon/services/generator_monitor.py',
     'desc': "The power brain. Reconciles miner load against running generator capacity "
             "every ~15 s: arms Emergency-Sleep when a gen trips, wakes miners back in "
             "batches when capacity returns, and watches for a frozen Mesa feed."},
    {'id': 'pep_monitor', 'lane': 'brain', 'kind': 'svc', 'label': 'peplink_monitor',
     'sub': 'continuous', 'svc': 'peplink_monitor', 'flows': ['net'],
     'desc': "Router and WAN connectivity per pod."},
    {'id': 'weather_svc', 'lane': 'brain', 'kind': 'svc', 'label': 'weather_service',
     'sub': 'hourly', 'flows': ['misc'], 'desc': "Site weather onto the status page."},
    {'id': 'gen_autostart', 'lane': 'brain', 'kind': 'svc', 'label': 'gen_autostart',
     'sub': ':5021 · default OFF', 'svc': 'gen_autostart', 'flows': ['gen', 'control'],
     'file': '/opt/ngon/services/gen_autostart.py',
     'desc': "Auto-restart for a group that goes fully down: clear alarms, start the healthy "
             "gens, warm up, hand them to AUTO so they load-share. Three independent safety "
             "switches, all off by default — plus a servicing hold: one gen being worked on "
             "freezes the whole group, because the man under it may be under any of them."},
    {'id': 'tunnel_mon', 'lane': 'brain', 'kind': 'svc', 'label': 'tunnel_flap_monitor',
     'sub': 'continuous', 'flows': ['net'], 'desc': "Watches the VPN tunnel for flapping."},
    {'id': 'cron', 'lane': 'brain', 'kind': 'cron', 'label': 'Scheduled jobs',
     'sub': 'cron (user ngon)', 'flows': ['miner', 'report', 'inventory'],
     'desc': "get_hashrate_and_status every 5 min (InfluxDB + history snapshots), "
             "reboot_zero at :17, fetch_inventory hourly, the 06:00 daily chat report, "
             "nightly gen mapping and uptime rollups."},

    # ---- stores -----------------------------------------------------------
    {'id': 'master_config', 'lane': 'store', 'kind': 'store', 'label': 'master_config.json',
     'sub': 'single source of truth', 'flows': ['config'],
     'file': '/opt/ngon/config/master_config.json',
     'desc': "Sites, groups, pods, generators, networks, users, every automation knob. "
             "Edited by Site Manager and the automations themselves; never committed to git."},
    {'id': 'live_csv', 'lane': 'store', 'kind': 'store', 'label': 'Live CSVs',
     'sub': '100 MB ramdisk', 'flows': ['miner'],
     'file': '/opt/ngon/data/live/',
     'desc': "miner_status.csv and friends. Current state of all ~10,000 miners, on a "
             "ramdisk because it's rewritten constantly."},
    {'id': 'influx', 'lane': 'store', 'kind': 'store', 'label': 'InfluxDB',
     'sub': 'time series', 'flows': ['miner', 'gen', 'report'],
     'desc': "Long-run history for charts: pod hashrate, gen power, per-state miner counts."},
    {'id': 'miner_hist_db', 'lane': 'store', 'kind': 'store', 'label': 'miner_history.db',
     'sub': 'SQLite · per-miner', 'flows': ['miner'],
     'desc': "Half-hourly snapshot of every miner, rolled up hourly then daily. ~5 GB steady "
             "state. Backs the trends and dead-miner work."},
    {'id': 'gen_tel_db', 'lane': 'store', 'kind': 'store', 'label': 'gen_telemetry.db',
     'sub': 'SQLite · ~80 fields', 'flows': ['gen'],
     'desc': "The 5-minute register blocks, plus the gen down-event log with causes."},
    {'id': 'gas_db', 'lane': 'store', 'kind': 'store', 'label': 'gas_flow.db',
     'sub': 'SQLite', 'flows': ['gas'], 'desc': "Meter readings; daily-usage reporting reads it directly."},
    {'id': 'gen_dbs', 'lane': 'store', 'kind': 'store', 'label': 'gen_history / gen_service / gen_status',
     'sub': 'SQLite ×3', 'flows': ['gen', 'report'],
     'desc': "Maintenance notes, service invoices and costs, and one row per gen per day of "
             "where it was and what state it was in."},
    {'id': 'textbridge_db', 'lane': 'store', 'kind': 'store', 'label': 'textbridge.db',
     'sub': 'SQLite', 'flows': ['text'],
     'desc': "Contacts, groups, membership with an audit log of who added whom, messages in "
             "and out, and send jobs. Jobs are claimed rather than just read, so a phone that "
             "dies mid-send retries instead of stranding the message."},
    {'id': 'app_dbs', 'lane': 'store', 'kind': 'store', 'label': 'todo.db / connex.db',
     'sub': 'SQLite ×2', 'flows': ['misc'], 'desc': "Shared todo list and the asset/container inventory."},
    {'id': 'logs', 'lane': 'store', 'kind': 'store', 'label': 'Logs',
     'sub': '/opt/ngon/logs', 'flows': ['control'],
     'desc': "powercontrol.log is the audit trail for every sleep, wake and reboot — who, "
             "what pod, how many actually answered."},

    # ---- read / action APIs ----------------------------------------------
    {'id': 'miner_api', 'lane': 'read', 'kind': 'api', 'label': 'miner_api', 'sub': ':5004',
     'svc': 'miner_api', 'flows': ['control', 'miner'], 'file': '/opt/ngon/api/miner_api.py',
     'desc': "Sleep, wake, reboot, single-miner diagnostics. Every action is logged with the "
             "count that actually succeeded, not the count requested."},
    {'id': 'gen_data_api', 'lane': 'read', 'kind': 'api', 'label': 'gen_data_api', 'sub': ':5001',
     'svc': 'gen_data_api', 'flows': ['gen'], 'desc': "Historical generator series out of InfluxDB."},
    {'id': 'pod_hash_api', 'lane': 'read', 'kind': 'api', 'label': 'pod_hashrate_api', 'sub': ':5002',
     'svc': 'pod_hashrate_api', 'flows': ['miner'], 'desc': "Pod and fleet hashrate history."},
    {'id': 'miner_hist_api', 'lane': 'read', 'kind': 'api', 'label': 'miner_history_api', 'sub': ':5008',
     'svc': 'miner_history_api', 'flows': ['miner'], 'desc': "Per-miner and per-state history for charts."},
    {'id': 'gen_hist_api', 'lane': 'read', 'kind': 'api', 'label': 'gen_history_api', 'sub': ':5009',
     'svc': 'gen_history_api', 'flows': ['gen'],
     'desc': "Gen notes, moves, maintenance history, PM schedule — and the whole Mesa loop. "
             "The chat bridge runs both ways now: what Mesa and we write on the tracker page "
             "goes out to ntfy and into the region's Google Space, and replies typed in that "
             "Space are polled back (every 20 s) into the same feed. Notes, PM completions and "
             "a tech's power request all ride it, so a conversation is one thread no matter "
             "which end it was typed at. Also owns the servicing flag a tech sets by hand."},
    {'id': 'gen_svc_api', 'lane': 'read', 'kind': 'api', 'label': 'gen_service_api', 'sub': ':5010',
     'svc': 'gen_service_api', 'flows': ['gen', 'report'], 'desc': "Service records, invoices, cost analysis."},
    {'id': 'gen_ctrl_api', 'lane': 'read', 'kind': 'api', 'label': 'gen_control_api', 'sub': ':5019',
     'svc': 'gen_control_api', 'flows': ['gen', 'control'],
     'desc': "Remote DSE control from the cloud: clear alarms, manual mode, start. Guarded by "
             "its own permission key and a master engine-action flag — and locked out entirely "
             "while a gen reads as being serviced, from either the tech's own flag or Mesa's "
             "control panel."},
    {'id': 'peplink_api', 'lane': 'read', 'kind': 'api', 'label': 'peplink_api', 'sub': ':5007',
     'svc': 'peplink_api', 'flows': ['net'], 'desc': "Cached Peplink client lists — MAC/IP/name per pod."},
    {'id': 'plucker_api', 'lane': 'read', 'kind': 'api', 'label': 'plucker_api', 'sub': ':5012',
     'svc': 'plucker_api', 'flows': ['miner'], 'desc': "Ad-hoc miner scanning and the normalized collectors "
             "behind every miner detail view."},
    {'id': 'ptz_api', 'lane': 'read', 'kind': 'api', 'label': 'ptz_api', 'sub': ':5024',
     'svc': 'ptz_api', 'flows': ['cams', 'control'], 'file': '/opt/ngon/api/ptz_api.py',
     'desc': "Snapshots and PTZ control for the wired cameras. Two jobs worth knowing: it "
             "resolves each camera's current IP from its MAC via the Peplink client list, "
             "and it proxies every image — the camera's snapshot URL carries credentials, so "
             "the browser never sees it and only ever gets bytes from us. Caches a frame for "
             "8 s (0.35 s in live mode) so ten operators watching cost the camera one fetch. "
             "Kept out of blink_api so a wedged camera can't stall the Blink job queue."},
    {'id': 'go2rtc', 'lane': 'read', 'kind': 'svc', 'label': 'go2rtc', 'sub': ':1984 · live video',
     'svc': 'go2rtc', 'flows': ['cams'], 'file': '/opt/go2rtc/go2rtc.yaml',
     'desc': "Pulls RTSP off the cameras and republishes it to browsers as MSE over a "
             "WebSocket — actual live video rather than a snapshot loop. A Go binary run "
             "under the same screen-session pattern as everything else. Its WebSocket proxy "
             "rule must precede the HTTP one in Apache, or mod_proxy_http swallows the "
             "upgrade and playback dies."},
    {'id': 'notif_api', 'lane': 'read', 'kind': 'api', 'label': 'notifications_api', 'sub': ':5005',
     'svc': 'notifications_api', 'flows': ['alert'], 'desc': "One place that formats and routes alerts."},
    {'id': 'svc_ctrl', 'lane': 'read', 'kind': 'api', 'label': 'service_control_api', 'sub': ':5015',
     'svc': 'service_control_api', 'flows': ['misc'],
     'desc': "Which services are up, and restart buttons for them. This page's green dots come from here."},
    {'id': 'misc_apis', 'lane': 'read', 'kind': 'api', 'label': 'miner_status / miner_moves / reporting',
     'sub': ':5011 · :5000 · :5003', 'flows': ['miner', 'report'],
     'desc': "Smaller endpoints: status lookups, miner movement records, and a legacy reporting "
             "port an old spreadsheet may still be pulling."},
    {'id': 'textbridge_api', 'lane': 'brain', 'kind': 'api', 'label': 'textbridge_api', 'sub': ':5023',
     'svc': 'textbridge_api', 'flows': ['text'], 'file': '/opt/ngon/api/textbridge_api.py',
     'desc': "The bridge itself, and the system of record for something SMS has no concept "
             "of: a group. Every outbound text carries its full recipient list, so 'the "
             "group' only exists here. Holds membership (soft-deleted, so history stays "
             "truthful), message history, and the queue the phone drains. Polls Chat every "
             "5 s in one direction and posts into the Space in the other. Unlike the older "
             "UI APIs it authenticates every call — it can text third parties."},
    {'id': 'research_api', 'lane': 'read', 'kind': 'api', 'label': 'research_sync_api', 'sub': ':5020 · VPC only',
     'svc': 'research_sync_api', 'flows': ['report'],
     'desc': "Strictly read-only bulk egress to the research box: incremental snapshots, rollups, "
             "sanitized config. Opens every source read-only and never touches control."},

    # ---- consumers --------------------------------------------------------
    {'id': 'page_status', 'lane': 'out', 'kind': 'page', 'label': 'Status page',
     'sub': '/status/', 'flows': ['miner', 'gen', 'gas', 'control', 'config'],
     'desc': "The main dashboard. WebSocket-driven, ten-odd modals deep, and the place most "
             "control actions actually get taken."},
    {'id': 'page_nmt', 'lane': 'out', 'kind': 'page', 'label': 'NMT + miner tools',
     'sub': '/nmm/', 'flows': ['miner', 'control'],
     'desc': "Miner tool, zero-hash analysis, dead-miner hunter."},
    {'id': 'page_sitemgr', 'lane': 'out', 'kind': 'page', 'label': 'Site Manager',
     'sub': '/nmm/site_manager.py', 'flows': ['config'],
     'desc': "Where the config itself gets edited: sites, pods, gens, users, automation gates."},
    {'id': 'page_reports', 'lane': 'out', 'kind': 'page', 'label': 'Reports & trends',
     'sub': 'performance · trends · uptime', 'flows': ['report', 'miner', 'gen'],
     'desc': "Performance, MARA report, miner and gen trends, site uptime, report builder."},
    {'id': 'page_cams', 'lane': 'out', 'kind': 'page', 'label': 'Cams', 'sub': '/cams/',
     'flows': ['cams'], 'desc': "Camera viewer with on-demand fresh snapshots."},
    {'id': 'page_misc', 'lane': 'out', 'kind': 'page', 'label': 'Todo · Assets · Map',
     'sub': '/todo/ · /connex/ · map', 'flows': ['misc'], 'desc': "The supporting pages."},
    {'id': 'page_group_text', 'lane': 'out', 'kind': 'page', 'label': 'Group Text',
     'sub': '/status/group_text.py', 'flows': ['text'],
     'desc': "Where staff run the threads: groups, membership, history, and sending. A thin "
             "shell — everything goes through textbridge_api, gated on the group_text page key."},
    {'id': 'page_mesa', 'lane': 'out', 'kind': 'page', 'label': 'Mesa pages',
     'sub': 'field app · gen tracker · service plan', 'flows': ['gen', 'text'],
     'desc': "The vendor's own end of the system: a phone app for the tech standing at the "
             "gen, the gen tracker with its chat feed, and the PM service plan. They log in "
             "as Mesa and see their gens only. Everything they can do here is a note, a flag "
             "or a request — no page of theirs reaches our control surface."},
    {'id': 'research_box', 'lane': 'out', 'kind': 'page', 'label': 'Research droplet',
     'sub': '10.116.0.4', 'flows': ['report'],
     'desc': "Separate box with read-only replicas, so questions can be asked of the data "
             "without any path to the control plane."},
    {'id': 'inv_site', 'lane': 'out', 'kind': 'page', 'label': 'inventory.ngon.us',
     'sub': 'separate server', 'flows': ['inventory'],
     'desc': "Put/pull inventory app, and now Site Development — planning a site build or a "
             "move. Shares our login cookie via the verify endpoint, which also says which "
             "restricted apps the user holds."},

    # ---- downstream -------------------------------------------------------
    {'id': 'gchat', 'lane': 'ext_out', 'kind': 'ext', 'label': 'Google Chat',
     'sub': 'webhooks', 'flows': ['alert'], 'desc': "Alerts and the 06:00 daily report, routed per site."},
    {'id': 'ntfy', 'lane': 'ext_out', 'kind': 'ext', 'label': 'ntfy',
     'sub': 'push · TX + ND topics', 'flows': ['alert'],
     'desc': "Push notifications to Mesa's phones — data push, no carrier fees."},
    {'id': 'gsheets_out', 'lane': 'ext_out', 'kind': 'ext', 'label': 'Google Sheets',
     'sub': 'reporting exports', 'flows': ['report'], 'desc': "Engine hours and reporting exports."},
]

EDGES = [
    # field -> collectors
    {'f': 'pods', 't': 'pod_agents', 'l': 'scan · cgminer/btminer', 'n': 'every ~30 s per pod',
     'fl': ['miner'], 'sp': 'fast'},
    {'f': 'pods', 't': 'plucker_relay', 'l': 'UDP broadcast', 'n': 'miner announces itself',
     'fl': ['miner'], 'sp': 'slow'},
    {'f': 'pods', 't': 'pickaxes', 'l': 'Foreman scan', 'n': 'competes for connection slots',
     'fl': ['miner'], 'sp': 'med'},
    {'f': 'gens_hw', 't': 'gen_collector', 'l': 'Modbus TCP', 'n': '1 Hz live + 5 min block',
     'fl': ['gen'], 'sp': 'fast'},
    {'f': 'flow_hw', 't': 'flow_collector', 'l': 'Modbus', 'n': '~1/min', 'fl': ['gas'], 'sp': 'med'},
    {'f': 'cams_hw', 't': 'blink_agent', 'l': 'snapshot', 'n': 'on demand + daily', 'fl': ['cams'], 'sp': 'slow'},
    {'f': 'peps_hw', 't': 'pod_agents', 'l': 'client list', 'n': 'finds miner IPs fast', 'fl': ['net'], 'sp': 'slow'},
    {'f': 'blink_cloud', 't': 'blink_agent', 'l': 'Blink API', 'n': 'field-only path', 'fl': ['cams'], 'sp': 'slow'},
    {'f': 'peplink_ic', 't': 'peps_hw', 'l': 'manages', 'n': '', 'fl': ['net'], 'sp': 'slow'},

    # collectors -> transport
    {'f': 'pod_agents', 't': 'push_path', 'l': 'POST /listen_pod_data', 'n': 'per pod, per scan',
     'fl': ['miner'], 'sp': 'fast'},
    {'f': 'pod_agents', 't': 'push_path', 'l': 'agent snapshot', 'n': 'every 5 s', 'fl': ['miner'], 'sp': 'fast'},
    {'f': 'plucker_relay', 't': 'push_path', 'l': 'relayed broadcast', 'n': '', 'fl': ['miner'], 'sp': 'slow'},
    {'f': 'gen_collector', 't': 'push_path', 'l': 'live + telemetry', 'n': '1 Hz / 5 min', 'fl': ['gen'], 'sp': 'fast'},
    {'f': 'flow_collector', 't': 'push_path', 'l': 'flow readings', 'n': '~1/min', 'fl': ['gas'], 'sp': 'med'},
    {'f': 'blink_agent', 't': 'push_path', 'l': 'job poll + JPG', 'n': 'outbound only', 'fl': ['cams'], 'sp': 'slow'},
    {'f': 'pickaxes', 't': 'push_path', 'l': 'watcher state', 'n': '', 'fl': ['miner'], 'sp': 'slow'},

    # transport -> edge
    {'f': 'push_path', 't': 'ufw', 'l': ':5006 direct', 'n': 'the one raw-open ingest port',
     'fl': ['miner'], 'sp': 'fast'},
    {'f': 'push_path', 't': 'apache', 'l': 'HTTPS :443', 'n': '/api/gen_ingest, /api/flow, /api/blink',
     'fl': ['gen', 'gas', 'cams'], 'sp': 'fast'},

    # edge -> ingest
    {'f': 'ufw', 't': 'listen_miner', 'l': '', 'n': '', 'fl': ['miner'], 'sp': 'fast'},
    {'f': 'apache', 't': 'gen_ingest', 'l': '/api/gen_ingest/', 'n': '', 'fl': ['gen'], 'sp': 'fast'},
    {'f': 'apache', 't': 'flow_api', 'l': '/api/flow/', 'n': '', 'fl': ['gas'], 'sp': 'med'},
    {'f': 'apache', 't': 'agent_monitor', 'l': '/api/agent/', 'n': '', 'fl': ['miner'], 'sp': 'fast'},
    {'f': 'apache', 't': 'blink_api', 'l': '/api/blink', 'n': '', 'fl': ['cams'], 'sp': 'slow'},
    {'f': 'apache', 't': 'inv_webhook', 'l': ':5014 VPC', 'n': 'put/pull webhook + SSO', 'fl': ['inventory'], 'sp': 'slow'},

    # ingest -> brain / store
    {'f': 'listen_miner', 't': 'live_csv', 'l': 'write miner_status.csv', 'n': '', 'fl': ['miner'], 'sp': 'fast'},
    {'f': 'listen_miner', 't': 'status_api', 'l': 'pod summaries', 'n': 'bulk_update_miners', 'fl': ['miner'], 'sp': 'fast'},
    {'f': 'gen_ingest', 't': 'status_api', 'l': 'live gen fields', 'n': 'kW, pressure, warnings', 'fl': ['gen'], 'sp': 'fast'},
    {'f': 'gen_ingest', 't': 'gen_tel_db', 'l': 'register block', 'n': 'every ~5 min', 'fl': ['gen'], 'sp': 'med'},
    {'f': 'flow_api', 't': 'gas_db', 'l': 'readings', 'n': '', 'fl': ['gas'], 'sp': 'med'},
    {'f': 'flow_api', 't': 'status_api', 'l': 'live_gas_*', 'n': '', 'fl': ['gas'], 'sp': 'med'},
    {'f': 'blink_api', 't': 'app_dbs', 'l': 'snapshot cache', 'n': 'files on disk', 'fl': ['cams'], 'sp': 'slow'},
    {'f': 'inv_webhook', 't': 'cron', 'l': 'triggers refresh', 'n': 'fetch_inventory', 'fl': ['inventory'], 'sp': 'slow'},

    # brain internals
    {'f': 'mesa_api', 't': 'gen_monitor', 'l': 'provider poll', 'n': 'every 60 s', 'fl': ['gen'], 'sp': 'med'},
    {'f': 'status_api', 't': 'gen_monitor', 'l': 'live state', 'n': 'capacity vs load', 'fl': ['gen', 'control'], 'sp': 'fast'},
    {'f': 'gen_monitor', 't': 'status_api', 'l': 'gen state + control', 'n': '', 'fl': ['gen'], 'sp': 'fast'},
    {'f': 'gen_monitor', 't': 'master_config', 'l': 'arms EMS / AW flags', 'n': 'per pod', 'fl': ['control'], 'sp': 'med'},
    {'f': 'gen_monitor', 't': 'miner_api', 'l': 'sleep / wake batches', 'n': 'one gen worth at a time', 'fl': ['control'], 'sp': 'med'},
    {'f': 'gen_monitor', 't': 'notif_api', 'l': 'gen alerts', 'n': '', 'fl': ['alert'], 'sp': 'slow'},
    {'f': 'gen_monitor', 't': 'influx', 'l': 'gen history', 'n': '', 'fl': ['gen'], 'sp': 'med'},
    {'f': 'gen_autostart', 't': 'gen_ctrl_api', 'l': 'reset · start · AUTO', 'n': 'three safety gates', 'fl': ['control'], 'sp': 'slow'},
    {'f': 'status_api', 't': 'gen_autostart', 'l': 'group down?', 'n': '', 'fl': ['gen'], 'sp': 'med'},
    {'f': 'gen_tel_db', 't': 'gen_autostart', 'l': 'down causes', 'n': 'culprit exclusion', 'fl': ['gen'], 'sp': 'slow'},
    {'f': 'peplink_ic', 't': 'pep_monitor', 'l': 'WAN health', 'n': '', 'fl': ['net'], 'sp': 'med'},
    {'f': 'vpn', 't': 'tunnel_mon', 'l': 'tunnel health', 'n': 'flap detection', 'fl': ['net'], 'sp': 'med'},
    {'f': 'tunnel_mon', 't': 'notif_api', 'l': 'flap alert', 'n': '', 'fl': ['alert'], 'sp': 'slow'},
    {'f': 'pep_monitor', 't': 'status_api', 'l': 'pod connectivity', 'n': '', 'fl': ['net'], 'sp': 'med'},
    {'f': 'weather_api', 't': 'weather_svc', 'l': 'conditions', 'n': 'hourly', 'fl': ['misc'], 'sp': 'slow'},
    {'f': 'weather_svc', 't': 'status_api', 'l': 'site weather', 'n': '', 'fl': ['misc'], 'sp': 'slow'},
    {'f': 'gsheets_in', 't': 'cron', 'l': 'inventory pull', 'n': 'hourly', 'fl': ['inventory'], 'sp': 'slow'},
    {'f': 'master_config', 't': 'status_api', 'l': 'config load + broadcast', 'n': '', 'fl': ['config'], 'sp': 'med'},

    # cron -> stores
    {'f': 'live_csv', 't': 'cron', 'l': 'read current state', 'n': 'every 5 min', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'cron', 't': 'influx', 'l': 'hashrate + power', 'n': 'every 5 min', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'cron', 't': 'miner_hist_db', 'l': 'snapshots', 'n': 'every 30 min, rolled up', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'cron', 't': 'miner_api', 'l': 'reboot zero-hash', 'n': 'hourly at :17', 'fl': ['control'], 'sp': 'slow'},
    {'f': 'cron', 't': 'gen_dbs', 'l': 'daily gen mapping', 'n': '00:05', 'fl': ['gen'], 'sp': 'slow'},
    {'f': 'cron', 't': 'gchat', 'l': 'daily report', 'n': '06:00', 'fl': ['alert', 'report'], 'sp': 'slow'},
    {'f': 'foundry', 't': 'cron', 'l': 'pool earnings', 'n': '', 'fl': ['report'], 'sp': 'slow'},
    {'f': 'cron', 't': 'gsheets_out', 'l': 'engine hours export', 'n': '', 'fl': ['report'], 'sp': 'slow'},

    # stores -> read APIs
    {'f': 'influx', 't': 'gen_data_api', 'l': 'query', 'n': '', 'fl': ['gen'], 'sp': 'med'},
    {'f': 'influx', 't': 'pod_hash_api', 'l': 'query', 'n': '', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'influx', 't': 'miner_hist_api', 'l': 'query', 'n': '', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'miner_hist_db', 't': 'miner_hist_api', 'l': 'per-miner history', 'n': '', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'gen_dbs', 't': 'gen_hist_api', 'l': 'notes + moves', 'n': '', 'fl': ['gen'], 'sp': 'slow'},
    {'f': 'gen_dbs', 't': 'gen_svc_api', 'l': 'service records', 'n': '', 'fl': ['gen'], 'sp': 'slow'},
    {'f': 'live_csv', 't': 'miner_api', 'l': 'who is asleep', 'n': 'targets sleep/wake', 'fl': ['control'], 'sp': 'med'},
    {'f': 'miner_api', 't': 'logs', 'l': 'powercontrol.log', 'n': 'who did what, and how many answered',
     'fl': ['control'], 'sp': 'slow'},
    {'f': 'master_config', 't': 'research_api', 'l': 'sanitized config', 'n': 'read-only', 'fl': ['report'], 'sp': 'slow'},
    {'f': 'miner_hist_db', 't': 'research_api', 'l': 'incremental snapshots', 'n': 'rowid cursor', 'fl': ['report'], 'sp': 'slow'},

    # control loop back to the field
    {'f': 'miner_api', 't': 'vpn', 'l': 'sleep / wake / reboot', 'n': 'cloud → field', 'fl': ['control'], 'sp': 'med'},
    {'f': 'gen_ctrl_api', 't': 'vpn', 'l': 'DSE writes', 'n': 'clear alarm · manual · start', 'fl': ['control'], 'sp': 'slow'},
    {'f': 'vpn', 't': 'field_api', 'l': ':6060 command', 'n': '', 'fl': ['control'], 'sp': 'med'},
    {'f': 'field_api', 't': 'pods', 'l': 'btminer command', 'n': 'the only thing that touches a miner',
     'fl': ['control'], 'sp': 'med'},
    {'f': 'vpn', 't': 'gens_hw', 'l': 'Modbus write', 'n': 'guarded, off by default', 'fl': ['control'], 'sp': 'slow'},
    {'f': 'master_config', 't': 'pod_agents', 'l': 'pod config + EMS flags', 'n': 'launcher polls /api/status',
     'fl': ['config', 'control'], 'sp': 'med'},

    # read APIs / status -> pages
    {'f': 'status_api', 't': 'page_status', 'l': 'WebSocket', 'n': 'live, no refresh', 'fl': ['miner', 'gen'], 'sp': 'fast'},
    {'f': 'status_api', 't': 'page_nmt', 'l': '/api/status', 'n': '', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'status_api', 't': 'page_sitemgr', 'l': '/api/status', 'n': '', 'fl': ['config'], 'sp': 'med'},
    {'f': 'status_api', 't': 'page_reports', 'l': '/api/status', 'n': '', 'fl': ['report'], 'sp': 'slow'},
    {'f': 'miner_api', 't': 'page_status', 'l': 'actions + miner detail', 'n': '', 'fl': ['control'], 'sp': 'med'},
    {'f': 'miner_api', 't': 'page_nmt', 'l': 'actions', 'n': '', 'fl': ['control'], 'sp': 'med'},
    {'f': 'gen_ctrl_api', 't': 'page_status', 'l': 'gen control modal', 'n': 'permission-gated', 'fl': ['control'], 'sp': 'slow'},
    {'f': 'gen_hist_api', 't': 'page_status', 'l': 'notes + Mesa chat', 'n': '', 'fl': ['gen'], 'sp': 'slow'},
    {'f': 'gen_data_api', 't': 'page_status', 'l': 'gen charts', 'n': '', 'fl': ['gen'], 'sp': 'med'},
    {'f': 'pod_hash_api', 't': 'page_reports', 'l': 'hashrate history', 'n': '', 'fl': ['report'], 'sp': 'med'},
    {'f': 'miner_hist_api', 't': 'page_reports', 'l': 'miner trends', 'n': '', 'fl': ['report'], 'sp': 'med'},
    {'f': 'gen_svc_api', 't': 'page_reports', 'l': 'service costs', 'n': '', 'fl': ['report'], 'sp': 'slow'},
    {'f': 'agent_monitor', 't': 'page_status', 'l': 'scanner + watcher modals', 'n': 'and this page',
     'fl': ['miner'], 'sp': 'fast'},
    {'f': 'svc_ctrl', 't': 'page_status', 'l': 'service health', 'n': '', 'fl': ['misc'], 'sp': 'slow'},
    {'f': 'plucker_api', 't': 'page_nmt', 'l': 'scan results', 'n': '', 'fl': ['miner'], 'sp': 'med'},
    {'f': 'peplink_api', 't': 'page_nmt', 'l': 'client lists', 'n': '', 'fl': ['net'], 'sp': 'slow'},
    {'f': 'blink_api', 't': 'page_cams', 'l': 'thumbnails + snaps', 'n': '', 'fl': ['cams'], 'sp': 'slow'},

    # wired PTZ cameras: straight down the tunnel, no agent in the middle
    {'f': 'ptz_hw', 't': 'vpn', 'l': 'snapshot + RTSP', 'n': 'cloud reaches the pod LAN directly',
     'fl': ['cams'], 'sp': 'med'},
    {'f': 'vpn', 't': 'ptz_api', 'l': 'authenticated snapshot', 'n': 'credentials stay server-side',
     'fl': ['cams'], 'sp': 'med'},
    {'f': 'vpn', 't': 'go2rtc', 'l': 'RTSP pull', 'n': 'continuous while watched', 'fl': ['cams'], 'sp': 'fast'},
    {'f': 'peplink_api', 't': 'ptz_api', 'l': 'MAC → current IP', 'n': 'DHCP lease moves; MAC does not',
     'fl': ['net'], 'sp': 'slow'},
    {'f': 'ptz_api', 't': 'page_cams', 'l': 'proxied frames', 'n': 'cached ~8 s, shared across viewers',
     'fl': ['cams'], 'sp': 'med'},
    {'f': 'go2rtc', 't': 'page_cams', 'l': 'MSE over WebSocket', 'n': 'live video', 'fl': ['cams'], 'sp': 'fast'},
    {'f': 'ptz_api', 't': 'ptz_hw', 'l': 'ONVIF move · preset', 'n': 'operator driving the camera',
     'fl': ['control', 'cams'], 'sp': 'slow'},
    {'f': 'app_dbs', 't': 'page_misc', 'l': 'todo + assets', 'n': '', 'fl': ['misc'], 'sp': 'slow'},
    {'f': 'misc_apis', 't': 'page_reports', 'l': 'lookups', 'n': '', 'fl': ['report'], 'sp': 'slow'},
    {'f': 'research_api', 't': 'research_box', 'l': 'bulk read-only sync', 'n': 'VPC only', 'fl': ['report'], 'sp': 'slow'},
    {'f': 'inv_webhook', 't': 'inv_site', 'l': 'cookie verify (SSO)', 'n': '', 'fl': ['inventory'], 'sp': 'slow'},
    {'f': 'page_sitemgr', 't': 'master_config', 'l': 'saves config', 'n': 'then broadcasts', 'fl': ['config'], 'sp': 'slow'},

    # group text bridge: SMS <-> Google Chat Space
    {'f': 'sms_people', 't': 'company_phone', 'l': 'SMS / MMS', 'n': 'over cellular', 'fl': ['text'], 'sp': 'slow'},
    {'f': 'company_phone', 't': 'apache', 'l': 'signed webhook · ack · heartbeat',
     'n': 'phone-initiated only (CGNAT)', 'fl': ['text'], 'sp': 'med'},
    {'f': 'apache', 't': 'textbridge_api', 'l': '/api/textbridge/', 'n': '', 'fl': ['text'], 'sp': 'med'},
    {'f': 'gchat_spaces', 't': 'textbridge_api', 'l': 'poll Space', 'n': 'every 10 s', 'fl': ['text'], 'sp': 'med'},
    {'f': 'textbridge_api', 't': 'textbridge_db', 'l': 'messages · groups · send jobs', 'n': '',
     'fl': ['text'], 'sp': 'med'},
    {'f': 'textbridge_api', 't': 'page_group_text', 'l': 'threads + membership', 'n': 'cookie-authed',
     'fl': ['text'], 'sp': 'med'},
    {'f': 'textbridge_api', 't': 'gchat_spaces', 'l': 'post inbound text', 'n': 'as textbridge@',
     'fl': ['text'], 'sp': 'med'},
    {'f': 'textbridge_api', 't': 'company_phone', 'l': '/outbox job claim', 'n': 'phone polls; we never push',
     'fl': ['text'], 'sp': 'med'},
    {'f': 'company_phone', 't': 'sms_people', 'l': 'group MMS / SMS', 'n': 'one thread, N recipients',
     'fl': ['text'], 'sp': 'slow'},

    # downstream
    {'f': 'notif_api', 't': 'gchat', 'l': 'alerts', 'n': 'routed per site', 'fl': ['alert'], 'sp': 'slow'},
    {'f': 'gen_hist_api', 't': 'ntfy', 'l': 'Mesa push', 'n': 'TX / ND topics', 'fl': ['alert'], 'sp': 'slow'},
    {'f': 'gen_hist_api', 't': 'gchat_spaces', 'l': 'notes · PMs · power requests',
     'n': 'to the region\'s Space, TX or ND', 'fl': ['gen', 'text'], 'sp': 'slow'},
    {'f': 'gchat_spaces', 't': 'gen_hist_api', 'l': 'poll replies', 'n': 'every 20 s, back into the feed',
     'fl': ['gen', 'text'], 'sp': 'med'},
    {'f': 'gen_hist_api', 't': 'page_mesa', 'l': 'gen feed · chat · PM plan', 'n': '', 'fl': ['gen'], 'sp': 'med'},
    {'f': 'page_mesa', 't': 'gen_hist_api', 'l': 'notes · servicing · power request',
     'n': 'a request, never an action', 'fl': ['gen', 'text'], 'sp': 'slow'},
    {'f': 'gen_ingest', 't': 'gen_ctrl_api', 'l': 'service mode', 'n': 'reg 42944 — locks out engine actions',
     'fl': ['control'], 'sp': 'med'},
]

FLOWS = [
    {'id': 'miner',     'label': 'Miner data',    'color': '#00ff00'},
    {'id': 'gen',       'label': 'Generators',    'color': '#ffaa00'},
    {'id': 'gas',       'label': 'Gas',           'color': '#4a9eff'},
    {'id': 'control',   'label': 'Control',       'color': '#ff4444'},
    {'id': 'cams',      'label': 'Cameras',       'color': '#aa44ff'},
    {'id': 'config',    'label': 'Config',        'color': '#44ffaa'},
    {'id': 'report',    'label': 'Reporting',     'color': '#ff44aa'},
    {'id': 'alert',     'label': 'Alerts',        'color': '#ff6600'},
    {'id': 'text',      'label': 'Group text',    'color': '#00e5ff'},
    {'id': 'net',       'label': 'Network',       'color': '#888888'},
    {'id': 'inventory', 'label': 'Inventory',     'color': '#c0c0c0'},
    {'id': 'misc',      'label': 'Other',         'color': '#666666'},
]

# Which nodes a given page actually talks to (derived from the source, then
# curated — a page lens for "what does this page depend on?")
LENSES = [
    {'id': 'page_status', 'label': 'Status page',
     'nodes': ['page_status', 'status_api', 'miner_api', 'gen_data_api', 'pod_hash_api',
               'miner_hist_api', 'gen_hist_api', 'gen_ctrl_api', 'gen_ingest', 'gen_autostart',
               'agent_monitor', 'peplink_api', 'notif_api', 'svc_ctrl', 'misc_apis',
               'master_config', 'live_csv', 'influx', 'apache']},
    {'id': 'page_nmt', 'label': 'NMT',
     'nodes': ['page_nmt', 'status_api', 'miner_api', 'plucker_api', 'peplink_api',
               'miner_hist_api', 'live_csv', 'apache']},
    {'id': 'page_sitemgr', 'label': 'Site Manager',
     'nodes': ['page_sitemgr', 'status_api', 'master_config', 'svc_ctrl', 'apache']},
    {'id': 'page_cams', 'label': 'Cams',
     'nodes': ['page_cams', 'blink_api', 'blink_agent', 'blink_cloud', 'cams_hw', 'push_path',
               'apache', 'ptz_api', 'go2rtc', 'ptz_hw', 'vpn', 'peplink_api']},
    {'id': 'page_group_text', 'label': 'Group Text',
     'nodes': ['page_group_text', 'textbridge_api', 'textbridge_db', 'company_phone',
               'gchat_spaces', 'sms_people', 'apache']},
    {'id': 'page_mesa', 'label': 'Mesa pages',
     'nodes': ['page_mesa', 'gen_hist_api', 'gen_dbs', 'status_api', 'gchat_spaces', 'ntfy',
               'gen_ingest', 'master_config', 'apache']},
    {'id': 'page_reports', 'label': 'Reports',
     'nodes': ['page_reports', 'status_api', 'pod_hash_api', 'gen_data_api', 'miner_hist_api',
               'gen_svc_api', 'misc_apis', 'influx', 'miner_hist_db', 'gen_dbs', 'apache']},
]

TOPO = {
    'lanes': LANES, 'nodes': NODES, 'edges': EDGES, 'flows': FLOWS,
    'lenses': LENSES, 'sites': SITES,
}

# ---------------------------------------------------------------------------
# Page
# ---------------------------------------------------------------------------
print("Content-Type: text/html\n")
print(f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NGON — Data Flow</title>
<style>
{generate_dropdown_css()}
  * {{ box-sizing: border-box; }}
  body {{
      margin: 0; background: #0a0a0a; color: #e0e0e0;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      overflow: hidden;
  }}
  .header {{
      background-color: rgba(255,255,255,0.05) !important;
      border-radius: 8px !important;
      padding: 14px 20px !important;
      margin: 12px !important;
      border: 1px solid rgba(255,255,255,0.1) !important;
      display: flex !important; justify-content: space-between !important;
      align-items: center !important; gap: 16px; flex-wrap: wrap;
  }}
  .header h1 {{ font-size: 20px; margin: 0; }}
  .hdr-right {{ display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }}
  .kpi {{ font-size: 12px; color: #888; white-space: nowrap; }}
  .kpi b {{ color: #00ff00; font-weight: 600; }}
  .kpi.warn b {{ color: #ffaa00; }}

  .toolbar {{
      display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
      margin: 0 12px 8px; font-size: 12px;
  }}
  .chip {{
      background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
      color: #bbb; border-radius: 999px; padding: 4px 11px; cursor: pointer;
      user-select: none; transition: all .12s;
  }}
  .chip:hover {{ background: rgba(255,255,255,0.12); color: #fff; }}
  .chip.on {{ background: rgba(0,255,0,0.13); border-color: #00ff00; color: #eaffea; }}
  .chip .dot {{
      display: inline-block; width: 8px; height: 8px; border-radius: 50%;
      margin-right: 6px; vertical-align: middle;
  }}
  .sep {{ width: 1px; height: 20px; background: rgba(255,255,255,0.12); margin: 0 4px; }}
  .tb-label {{
      color: #666; font-size: 10.5px; text-transform: uppercase; letter-spacing: .08em;
      align-self: center;
  }}
  .chip.lens.on {{ background: rgba(74,158,255,0.16); border-color: #4a9eff; color: #dbecff; }}

  #stage {{
      position: absolute; inset: 118px 0 0 0; overflow: hidden; cursor: grab;
  }}
  #stage.drag {{ cursor: grabbing; }}
  #canvas {{ position: absolute; transform-origin: 0 0; }}
  #wires {{ position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }}

  .lane {{ position: absolute; top: 0; }}
  .lane-head {{
      font-size: 11px; text-transform: uppercase; letter-spacing: .09em;
      color: #666; padding-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.08);
      margin-bottom: 12px;
  }}
  .lane-head span {{ display: block; text-transform: none; letter-spacing: 0; color: #444; font-size: 10px; }}

  .node {{
      position: absolute; border-radius: 8px; padding: 9px 11px; cursor: pointer;
      background: #141414; border: 1px solid rgba(255,255,255,0.14);
      transition: border-color .15s, box-shadow .15s, opacity .15s, background .15s;
  }}
  .node:hover {{ border-color: rgba(255,255,255,0.45); background: #1b1b1b; }}
  .node .n-label {{ font-size: 13px; font-weight: 600; color: #f0f0f0; }}
  .node .n-sub {{ font-size: 10.5px; color: #7c7c7c; margin-top: 2px; }}
  .node.sel {{ border-color: #fff; box-shadow: 0 0 0 1px #fff, 0 0 22px rgba(255,255,255,0.16); }}
  .node.dim {{ opacity: .13; }}
  .node.hl {{ border-color: #00ff00; box-shadow: 0 0 16px rgba(0,255,0,0.18); }}

  .node.k-ext   {{ background: #10131a; border-color: rgba(120,160,255,0.35); }}
  .node.k-hw    {{ background: #16120c; border-color: rgba(255,170,0,0.35); }}
  .node.k-agent {{ background: #0c1610; border-color: rgba(0,255,0,0.28); }}
  .node.k-net   {{ background: #111; border-color: rgba(255,255,255,0.3); border-style: dashed; }}
  .node.k-api   {{ background: #0d1418; border-color: rgba(74,158,255,0.4); }}
  .node.k-svc   {{ background: #17100f; border-color: rgba(255,68,68,0.35); }}
  .node.k-cron  {{ background: #17100f; border-color: rgba(255,102,0,0.4); border-style: dashed; }}
  .node.k-store {{ background: #14100f; border-color: rgba(255,68,170,0.32); }}
  .node.k-page  {{ background: #0f0f14; border-color: rgba(170,68,255,0.38); }}

  .svc-dot {{
      position: absolute; top: 9px; right: 9px; width: 7px; height: 7px; border-radius: 50%;
      background: #333;
  }}
  .svc-dot.up {{ background: #00ff00; box-shadow: 0 0 6px rgba(0,255,0,.7); }}
  .svc-dot.down {{ background: #ff4444; box-shadow: 0 0 6px rgba(255,68,68,.8); }}

  /* sites panel */
  .site-row {{ margin-top: 7px; }}
  .site-name {{ font-size: 10.5px; color: #888; margin-bottom: 3px; }}
  .pod-chips {{ display: flex; flex-wrap: wrap; gap: 3px; }}
  .pod {{
      font-size: 9.5px; padding: 2px 5px; border-radius: 4px; background: #1e1e1e;
      border: 1px solid rgba(255,255,255,0.09); color: #999; white-space: nowrap;
  }}
  .pod.scanning {{
      background: rgba(0,255,0,0.22); border-color: #00ff00; color: #d8ffd8;
      animation: pulse 1.1s ease-in-out infinite;
  }}
  .pod.foreman {{ background: rgba(170,68,255,0.22); border-color: #aa44ff; color: #eddcff; }}
  .pod.ems {{ background: rgba(74,158,255,0.22); border-color: #4a9eff; color: #dbecff; }}
  .pod.offline {{ background: rgba(255,68,68,0.2); border-color: #ff4444; color: #ffdada; }}
  @keyframes pulse {{ 0%,100% {{ opacity: 1; }} 50% {{ opacity: .45; }} }}

  /* stroke, stroke-width and opacity are set per-edge as SVG attributes from
     dataflow.js — don't declare them here, CSS would win and flatten them. */
  .wire {{ fill: none; }}
  .wire-label {{
      font-size: 9px; fill: #777; pointer-events: none;
      paint-order: stroke; stroke: #0a0a0a; stroke-width: 3px;
  }}

  #panel {{
      position: absolute; right: 12px; top: 130px; width: 330px; max-height: calc(100vh - 160px);
      overflow-y: auto; background: rgba(17,17,17,0.97); border: 1px solid rgba(255,255,255,0.16);
      border-radius: 10px; padding: 16px; display: none; backdrop-filter: blur(4px);
      box-shadow: 0 10px 40px rgba(0,0,0,0.6);
  }}
  #panel.open {{ display: block; }}
  #panel h3 {{ margin: 0 0 2px; font-size: 15px; }}
  #panel .p-sub {{ font-size: 11px; color: #4a9eff; margin-bottom: 10px; }}
  #panel .p-desc {{ font-size: 12.5px; line-height: 1.55; color: #ccc; }}
  #panel .p-file {{
      font-size: 10.5px; color: #666; margin-top: 10px; word-break: break-all;
      font-family: ui-monospace, Menlo, monospace;
  }}
  #panel h4 {{
      font-size: 10.5px; text-transform: uppercase; letter-spacing: .08em; color: #666;
      margin: 14px 0 5px;
  }}
  #panel .conn {{ font-size: 11.5px; color: #aaa; padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }}
  #panel .conn b {{ color: #ddd; font-weight: 600; }}
  #panel .conn i {{ color: #666; font-style: normal; }}
  #panel .close {{ position: absolute; right: 12px; top: 10px; color: #666; cursor: pointer; font-size: 18px; }}
  #panel .close:hover {{ color: #fff; }}
  #panel .live-box {{
      margin-top: 12px; padding: 9px; border-radius: 6px; background: rgba(0,255,0,0.05);
      border: 1px solid rgba(0,255,0,0.18); font-size: 11.5px; color: #bfb;
  }}
  .hint {{
      position: absolute; left: 16px; bottom: 12px; font-size: 11px; color: #555;
      pointer-events: none;
  }}
</style>
</head>
<body>
<div class="header">
  <div class="dropdown">
    <h1 class="dropdown-title">NGON — Data Flow</h1>
    <div class="dropdown-content">{generate_dropdown_html(_user_access)}</div>
  </div>
  <div class="hdr-right">
    <div class="kpi" id="kpiScan">scanner —</div>
    <div class="kpi" id="kpiMiners">miners —</div>
    <div class="kpi" id="kpiSvc">services —</div>
    <div class="kpi" id="kpiFeeds">feeds —</div>
  </div>
</div>

<div class="toolbar">
  <span class="chip on" id="btnLive">● Live</span>
  <span class="chip on" id="btnAnim">Animate</span>
  <span class="chip" id="btnFit">Fit</span>
  <div class="sep"></div>
  <span class="tb-label">Page lens</span>
  <span id="lensChips" style="display:flex;gap:6px;flex-wrap:wrap"></span>
  <div class="sep"></div>
  <span class="tb-label">Flows</span>
  <span id="flowChips" style="display:flex;gap:6px;flex-wrap:wrap"></span>
</div>

<div id="stage">
  <div id="canvas">
    <svg id="wires"></svg>
  </div>
</div>
<div id="panel"><span class="close" onclick="DF.closePanel()">&times;</span><div id="panelBody"></div></div>
<div class="hint">drag to pan · scroll to zoom · click a box for detail</div>

<script>window.TOPO = {json.dumps(TOPO)};</script>
<script src="/status/dataflow.js?v=4"></script>
<script>
{generate_dropdown_js()}
</script>
</body>
</html>""")
