#!/usr/bin/env python3

import cgi
import cgitb
import json
import os
import sys

# Add the apps directory to Python path for imports
sys.path.insert(0, '/opt/ngon/apps')
from managers.auth_manager import AuthManager, generate_login_modal_html, generate_access_denied_html

# File paths
MASTER_CONFIG_FILE = "/opt/ngon/config/master_config.json"

# Non-geographic holding sites. Site order everywhere (Site Manager, Generator
# Manager, map) is just the key order in master_config.json, so these are kept
# pinned to the bottom on every save — the browser appends a newly added site to
# the end of window.siteConfig.sites, which would otherwise drop it below them.
TRAILING_SITES = ("Spares", "Out of Service", "Out of Service - TX")

# Handle form requests
form = cgi.FieldStorage()

# Initialize authentication
auth = AuthManager('site_manager')
auth_required, should_exit, headers = auth.require_auth(form)

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

# Handle logout request
if "logout" in form and form.getvalue("logout"):
    print("Content-Type: text/html")
    # Clear the authentication cookie
    import http.cookies
    cookie = http.cookies.SimpleCookie()
    cookie['ngon_auth'] = ''
    cookie['ngon_auth']['path'] = '/'
    cookie['ngon_auth']['max-age'] = 0
    print(cookie.output())
    print("")  # End headers
    print("""
    <html><body style="background: #1a1a1a; color: #fff; font-family: Arial, sans-serif; text-align: center; padding: 50px;">
        <h1 style="color: #00ff00;">Logged Out Successfully</h1>
        <p>Your authentication session has been cleared.</p>
        <a href="site_manager.py" style="color: #4a9eff;">Return to Site Manager</a>
    </body></html>
    """)
    sys.exit(0)

# Handle AJAX requests IMMEDIATELY - before any HTML headers

# Handle miner types save
if "ajax_miner_types" in form and form.getvalue("ajax_miner_types"):
    miner_types_data = form.getvalue("ajax_miner_types")
    
    # Send headers immediately
    print("Content-Type: application/json\n")
    
    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)
        
        # Load existing config
        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)
        
        # Parse miner types data
        miner_types = json.loads(miner_types_data)
        
        # Update misc section
        if "misc" not in config:
            config["misc"] = {}
        config["misc"]["miner_types"] = miner_types
        
        # Update timestamp
        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()
        
        # Save updated config
        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)
        
        print('{"success": true}')
        
    except Exception as e:
        print(f'{{"success": false, "error": "Error saving miner types: {str(e)}"}}')
    
    sys.exit(0)

# Handle employees save
if "ajax_employees" in form and form.getvalue("ajax_employees"):
    employees_data = form.getvalue("ajax_employees")
    
    # Send headers immediately
    print("Content-Type: application/json\n")
    
    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)
        
        # Load existing config
        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)
        
        # Parse employees data
        employees = json.loads(employees_data)
        
        # Update misc section
        if "misc" not in config:
            config["misc"] = {}
        config["misc"]["employees"] = employees
        
        # Update timestamp
        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()
        
        # Save updated config
        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)
        
        print('{"success": true}')
        
    except Exception as e:
        print(f'{{"success": false, "error": "Error saving employees: {str(e)}"}}')
    
    sys.exit(0)

# Handle generator warning settings save (misc.gen_warnings)
# Direct file write (mirrors miner_types/employees). No status_api broadcast
# needed: gen_ingest_api re-reads master_config fresh each 5-min sweep via
# gen_warnings.load_rules(), so the next sweep picks up the new thresholds.
if "ajax_gen_warnings" in form and form.getvalue("ajax_gen_warnings"):
    gen_warnings_data = form.getvalue("ajax_gen_warnings")

    print("Content-Type: application/json\n")

    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        gen_warnings_cfg = json.loads(gen_warnings_data)

        if "misc" not in config:
            config["misc"] = {}
        config["misc"]["gen_warnings"] = gen_warnings_cfg

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        print('{"success": true}')

    except Exception as e:
        print(f'{{"success": false, "error": "Error saving gen warnings: {str(e)}"}}')

    sys.exit(0)

# Handle gen-control engine-action safety toggle (misc.gen_control.enable_engine_actions).
# Direct file write (mirrors ajax_gen_warnings). gen_control_api reads master_config
# fresh on every request, so this takes effect immediately — no restart/broadcast.
# Opening the existing file "w" truncates in place, preserving its ngon:ngon 664 perms.
if "ajax_gen_control" in form and form.getvalue("ajax_gen_control"):
    gc_data = form.getvalue("ajax_gen_control")

    print("Content-Type: application/json\n")

    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(gc_data)
        config.setdefault("misc", {}).setdefault("gen_control", {})["enable_engine_actions"] = bool(
            payload.get("enable_engine_actions"))

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        print('{"success": true}')

    except Exception as e:
        print(f'{{"success": false, "error": "Error saving gen control: {str(e)}"}}')

    sys.exit(0)

# Handle live power-management settings save (misc.power_mgmt).
# Direct file write (mirrors ajax_gen_control). generator_monitor calls
# config.refresh() every ~5 s cycle, so changes take effect within one cycle
# with no restart — including flipping live_control_enabled off (instant kill
# switch back to Mesa). Values are clamped to sane ranges server-side.
if "ajax_power_mgmt" in form and form.getvalue("ajax_power_mgmt"):
    pm_data = form.getvalue("ajax_power_mgmt")

    print("Content-Type: application/json\n")

    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(pm_data)

        def _clamp(v, dflt, lo, hi):
            try:
                n = float(v)
            except (TypeError, ValueError):
                return dflt
            return max(lo, min(hi, n))

        groups = payload.get("live_control_groups") or []
        if not isinstance(groups, list):
            groups = []

        pm = {
            "live_control_enabled": bool(payload.get("live_control_enabled")),
            "live_control_groups": [str(g).strip() for g in groups if str(g).strip()],
            "live_fresh_sec": _clamp(payload.get("live_fresh_sec"), 60, 5, 600),
            "live_boot_wake_sec": _clamp(payload.get("live_boot_wake_sec"), 120, 0, 3600),
            "live_wake_lockout_sec": _clamp(payload.get("live_wake_lockout_sec"), 300, 0, 3600),
            "live_wake_lockout_highload_sec": _clamp(payload.get("live_wake_lockout_highload_sec"), 450, 0, 3600),
            "live_shed_lockout_sec": _clamp(payload.get("live_shed_lockout_sec"), 15, 0, 600),
            "deadband_miners": int(_clamp(payload.get("deadband_miners"), 2, 1, 20)),
        }
        config.setdefault("misc", {})["power_mgmt"] = pm

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        print('{"success": true}')

    except Exception as e:
        print(f'{{"success": false, "error": "Error saving power mgmt: {str(e)}"}}')

    sys.exit(0)

# Handle auto-restart gate settings save (misc.auto_restart). Direct file write
# (mirrors ajax_power_mgmt). The gen_autostart service calls config.refresh()
# every loop (~15 s), so changes take effect within one cycle — no restart.
# Values are clamped/validated server-side.
if "ajax_auto_restart" in form and form.getvalue("ajax_auto_restart"):
    ar_data = form.getvalue("ajax_auto_restart")

    print("Content-Type: application/json\n")

    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(ar_data)

        def _clamp(v, dflt, lo, hi):
            try:
                n = float(v)
            except (TypeError, ValueError):
                return dflt
            return max(lo, min(hi, n))

        def _hhmm(v, dflt):
            try:
                h, m = str(v).split(":")
                h, m = int(h), int(m)
                if 0 <= h <= 23 and 0 <= m <= 59:
                    return f"{h:02d}:{m:02d}"
            except Exception:
                pass
            return dflt

        win = payload.get("window") or {}
        wu = payload.get("warmup") or {}
        gas = payload.get("gas") or {}
        cb = payload.get("circuit_breaker") or {}

        ex = payload.get("exclude_categories") or []
        if not isinstance(ex, list):
            ex = []
        # 'overload' is a cascade victim, never a culprit — refuse to store it.
        ex = [str(c).strip().lower() for c in ex if str(c).strip() and str(c).strip().lower() != "overload"]

        flow_min = gas.get("flow_min_mcfd")
        try:
            flow_min = float(flow_min) if flow_min not in (None, "", "null") else None
        except (TypeError, ValueError):
            flow_min = None

        ar = {
            "system_enabled": bool(payload.get("system_enabled")),
            "window": {"start": _hhmm(win.get("start"), "21:00"),
                       "end": _hhmm(win.get("end"), "05:00")},
            "just_ran_minutes": _clamp(payload.get("just_ran_minutes"), 5, 0, 120),
            "confirm_seconds": _clamp(payload.get("confirm_seconds"), 45, 10, 600),
            "warmup": {
                "mode": "temp" if str(wu.get("mode")) == "temp" else "minutes",
                "minutes": _clamp(wu.get("minutes"), 5, 0, 60),
                "target_f": _clamp(wu.get("target_f"), 160, 100, 220),
                "max_minutes": _clamp(wu.get("max_minutes"), 15, 1, 60),
            },
            "exclude_categories": ex,
            "gas": {"block_on_category_gas": bool(gas.get("block_on_category_gas")),
                    "flow_min_mcfd": flow_min},
            "retrip": {"window_minutes": int(_clamp((payload.get("retrip") or {}).get("window_minutes"), 15, 1, 120))},
            "circuit_breaker": {
                "max_attempts": int(_clamp(cb.get("max_attempts"), 3, 1, 20)),
                "window_minutes": _clamp(cb.get("window_minutes"), 120, 1, 1440),
                "cooldown_minutes": _clamp(cb.get("cooldown_minutes"), 120, 0, 1440),
            },
        }
        config.setdefault("misc", {})["auto_restart"] = ar

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        print('{"success": true}')

    except Exception as e:
        print(f'{{"success": false, "error": "Error saving auto-restart: {str(e)}"}}')

    sys.exit(0)

# Handle chat-command settings (misc.chat_commands). Direct file write, mirroring
# ajax_auto_restart. The chat_commands service re-reads these every poll cycle and
# gen_history_api reads them per request, so changes take effect without a restart.
if "ajax_chat_commands" in form and form.getvalue("ajax_chat_commands"):
    cc_data = form.getvalue("ajax_chat_commands")

    print("Content-Type: application/json\n")

    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(cc_data)

        def _cc_clamp(v, dflt, lo, hi):
            try:
                n = int(float(v))
            except (TypeError, ValueError):
                return dflt
            return max(lo, min(hi, n))

        # Only real site names, so a stale checkbox can't leave an orphan key that
        # silently looks like an enabled site.
        known_sites = set((config.get("sites") or {}).keys())
        sites_in = payload.get("sites") or {}
        sites = {s: True for s in sites_in
                 if s in known_sites and bool(sites_in.get(s))}

        cc = {
            "system_enabled": bool(payload.get("system_enabled")),
            "swap_enabled": bool(payload.get("swap_enabled")),
            "move_enabled": bool(payload.get("move_enabled")),
            "order_enabled": bool(payload.get("order_enabled")),
            "ems_on_shutdown_request": bool(payload.get("ems_on_shutdown_request")),
            "poll_seconds": _cc_clamp(payload.get("poll_seconds"), 30, 10, 300),
            "duplicate_window_seconds": _cc_clamp(
                payload.get("duplicate_window_seconds"), 3600, 60, 86400),
            "hint_enabled": bool(payload.get("hint_enabled")),
            "hint_cooldown_seconds": _cc_clamp(
                payload.get("hint_cooldown_seconds"), 3600, 60, 86400),
            "all_sites": bool(payload.get("all_sites")),
            "sites": sites,
        }
        config.setdefault("misc", {})["chat_commands"] = cc

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        print('{"success": true}')

    except Exception as e:
        print(f'{{"success": false, "error": "Error saving chat commands: {str(e)}"}}')

    sys.exit(0)

# Handle per-category alert switches (misc.alerts). Direct file write, mirroring
# ajax_auto_restart. notifications_api re-reads master_config on every request, so
# a change takes effect on the very next alert with no restart.
#
# Stored as {category: bool} and only ever written for categories the UI knows
# about. Absent == enabled everywhere that reads it, so an un-toggled system and a
# category nobody has classified yet both behave exactly as before.
if "ajax_alerts" in form and form.getvalue("ajax_alerts"):
    al_data = form.getvalue("ajax_alerts")

    print("Content-Type: application/json\n")

    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(al_data)
        if not isinstance(payload, dict):
            print('{"success": false, "error": "Expected an object of category -> bool"}')
            sys.exit(0)

        # Keys come from notifications_api /alert_settings, so accept whatever the
        # page was rendered with rather than duplicating the catalogue here — but
        # coerce hard, since this ends up gating whether operators get told things.
        alerts = {}
        for k, v in payload.items():
            key = str(k).strip()
            if key:
                alerts[key] = bool(v)

        config.setdefault("misc", {})["alerts"] = alerts

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        off = sorted(k for k, v in alerts.items() if not v)
        print(json.dumps({"success": True, "disabled": off}))

    except Exception as e:
        print(f'{{"success": false, "error": "Error saving alert settings: {str(e)}"}}')

    sys.exit(0)

# Handle gas-pressure hold settings (misc.gas_hold). Direct file write, mirroring
# ajax_auto_restart. generator_monitor reads these every reconcile cycle (~15 s) via
# config.get_gas_hold_settings(), so changes land without a restart. Only the master
# switch and the ORG-WIDE defaults live here; per-group arming and thresholds are on
# the status page kW modal.
if "ajax_gas_hold" in form and form.getvalue("ajax_gas_hold"):
    gh_data = form.getvalue("ajax_gas_hold")

    print("Content-Type: application/json\n")

    try:
        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(gh_data)
        d = (payload.get("defaults") or {})

        def _gclamp(v, dflt, lo, hi):
            try:
                n = float(v)
            except (TypeError, ValueError):
                return dflt
            return max(lo, min(hi, n))

        watch = _gclamp(d.get("watch_psi"), 18.0, 1, 200)
        hold1 = _gclamp(d.get("hold1_psi"), 16.0, 1, 200)
        hold2 = _gclamp(d.get("hold2_psi"), 14.0, 1, 200)
        # A stage that isn't strictly deeper than the one above it is unreachable —
        # clamp rather than reject so a fat-fingered save can't leave a half-ladder.
        hold1 = min(hold1, watch)
        hold2 = min(hold2, hold1)

        config.setdefault("misc", {})["gas_hold"] = {
            "system_enabled": bool(payload.get("system_enabled")),
            "defaults": {
                "watch_psi": watch,
                "hold1_psi": hold1,
                "hold2_psi": hold2,
                "release_margin_psi": _gclamp(d.get("release_margin_psi"), 1.0, 0, 50),
                "release_minutes": _gclamp(d.get("release_minutes"), 30.0, 1, 720),
                "hold1_reserve": int(_gclamp(d.get("hold1_reserve"), 1, 1, 6)),
                "hold2_reserve": int(_gclamp(d.get("hold2_reserve"), 2, 1, 6)),
                "confirm_samples": int(_gclamp(d.get("confirm_samples"), 2, 1, 10)),
                "min_live_gens": int(_gclamp(d.get("min_live_gens"), 2, 1, 10)),
                "stale_minutes": _gclamp(d.get("stale_minutes"), 45.0, 5, 720),
            },
        }

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        # Direct file write, so status_api's in-memory copy is now behind. Broadcast
        # or the status page keeps showing the master switch as OFF and refuses to arm
        # any group. Best-effort: the save itself already succeeded.
        try:
            import urllib.request
            urllib.request.urlopen(
                urllib.request.Request("http://localhost:5050/api/config/broadcast",
                                       method="POST"), timeout=3).read()
        except Exception:
            pass

        print('{"success": true}')

    except Exception as e:
        print(f'{{"success": false, "error": "Error saving gas hold: {str(e)}"}}')

    sys.exit(0)

# Handle read-only public view links (misc.public_view.views). Direct file write,
# mirroring ajax_gas_hold. view.py reads master_config on every request, so a save
# takes effect on the next page load with no restart -- which is what makes
# "disable" and "delete" immediate revocations rather than eventual ones.
#
# Tokens are minted HERE, server-side, never sent up from the browser: the client
# has no business choosing the secret that guards the link.
if "ajax_public_view" in form and form.getvalue("ajax_public_view"):
    pv_data = form.getvalue("ajax_public_view")

    print("Content-Type: application/json\n")

    try:
        import secrets as _secrets
        import datetime as _dt

        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(pv_data)
        existing = {v.get("id"): v for v in
                    ((config.get("misc", {}) or {}).get("public_view", {}) or {}).get("views", [])
                    if isinstance(v, dict)}

        known_sites = set((config.get("sites") or {}).keys())
        out = []
        for row in (payload.get("views") or []):
            if not isinstance(row, dict):
                continue
            vid = str(row.get("id") or "")[:16] or _secrets.token_hex(4)
            prev = existing.get(vid, {})
            # Keep the existing token unless this is new or a regenerate was asked
            # for. The browser never supplies one.
            token = prev.get("token")
            if not token or row.get("regenerate"):
                token = _secrets.token_urlsafe(24)
            # Only real site names, so a typo cannot silently widen a view to
            # nothing-matches (which reads as "all sites" downstream).
            sites = [x for x in (row.get("sites") or []) if x in known_sites]
            out.append({
                "id": vid,
                "name": (str(row.get("name") or "").strip() or "Untitled")[:60],
                "token": token,
                "sites": sites,
                "enabled": bool(row.get("enabled")),
                "created": prev.get("created") or _dt.date.today().isoformat(),
            })

        config.setdefault("misc", {})["public_view"] = {
            "views": out,
            "_comment": "Read-only no-login views at /status/view.py?k=<token>. One "
                        "entry per link. sites=[] means every site; otherwise only "
                        "those listed. enabled=false kills that link instantly. "
                        "Delete an entry to revoke it.",
        }

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        # view.py reads the file directly, so no broadcast is needed for the links
        # themselves; broadcast anyway so anything holding an in-memory copy agrees.
        try:
            import urllib.request
            urllib.request.urlopen(
                urllib.request.Request("http://localhost:5050/api/config/broadcast",
                                       method="POST"), timeout=3).read()
        except Exception:
            pass

        print(json.dumps({"success": True, "views": out}))

    except Exception as e:
        print(json.dumps({"success": False, "error": f"Error saving public views: {e}"}))

    sys.exit(0)

# Handle dead-miner scoring settings (misc.dead_miner). Direct file write,
# mirroring ajax_auto_restart. Every reader (the hunter page, the inventory
# API) scores from the persisted signal store using these values at read time,
# so a save re-scores the whole fleet on the next page load — no re-extraction.
if "ajax_dead_miner" in form and form.getvalue("ajax_dead_miner"):
    dm_data = form.getvalue("ajax_dead_miner")

    print("Content-Type: application/json\n")

    try:
        sys.path.insert(0, '/opt/ngon/apps')
        from miners.dead_hunter import DEFAULTS as DM_DEFAULTS

        if not os.path.exists(MASTER_CONFIG_FILE):
            print('{"success": false, "error": "Config file not found"}')
            sys.exit(0)

        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)

        payload = json.loads(dm_data)

        def _dm_clamp(key, lo, hi, as_int=True):
            dflt = DM_DEFAULTS[key]
            try:
                n = float(payload.get(key, dflt))
            except (TypeError, ValueError):
                return dflt
            n = max(lo, min(hi, n))
            return int(round(n)) if as_int else n

        cats = payload.get("hw_error_cats")
        if not isinstance(cats, list):
            cats = DM_DEFAULTS["hw_error_cats"]
        cats = [str(c).strip().lower() for c in cats if str(c).strip()]

        dm = {
            "w_reboots":   _dm_clamp("w_reboots", 0, 100),
            "w_hw_error":  _dm_clamp("w_hw_error", 0, 100),
            "w_zero_hash": _dm_clamp("w_zero_hash", 0, 100),
            "w_unstable":  _dm_clamp("w_unstable", 0, 100),
            "reboots_full":   _dm_clamp("reboots_full", 1, 500),
            "pull_threshold": _dm_clamp("pull_threshold", 1, 100),
            "score_floor":    _dm_clamp("score_floor", 0, 100),
            "never_hashed_reboots": _dm_clamp("never_hashed_reboots", 0, 500),
            "auto_min_days":  _dm_clamp("auto_min_days", 1, 30),
            "hw_error_cats":  cats,
            "no_hash_days":   _dm_clamp("no_hash_days", 1, 365),
            "use_pod_gate":   bool(payload.get("use_pod_gate", True)),
            "pod_active_frac": _dm_clamp("pod_active_frac", 0.0, 1.0, as_int=False),
            # extract-time knobs
            "window_days":         _dm_clamp("window_days", 1, 90),
            "min_samples_per_day": _dm_clamp("min_samples_per_day", 1, 200),
            "short_uptime":        _dm_clamp("short_uptime", 60, 86400),
            "real_hash_th":        _dm_clamp("real_hash_th", 1, 500),
        }
        # A floor above the pull threshold would make 'suspect' impossible and
        # silently swallow the amber band.
        if dm["score_floor"] > dm["pull_threshold"]:
            dm["score_floor"] = dm["pull_threshold"]

        config.setdefault("misc", {})["dead_miner"] = dm

        from datetime import datetime
        config["last_updated"] = datetime.now().isoformat()

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)

        print(json.dumps({"success": True, "saved": dm}))

    except Exception as e:
        print(json.dumps({"success": False, "error": f"Error saving dead-miner settings: {e}"}))

    sys.exit(0)

# Dead-miner tuning preview: score the persisted signal store against a
# CANDIDATE config without saving anything, so an operator can see how many
# miners a weight change would put on a truck before committing to it.
# Cheap (~70 ms fleet-wide) because the expensive signal extraction already
# happened on the cron.
if "ajax_dead_preview" in form and form.getvalue("ajax_dead_preview"):
    dp_data = form.getvalue("ajax_dead_preview")

    print("Content-Type: application/json\n")

    try:
        sys.path.insert(0, '/opt/ngon/apps')
        from miners.dead_hunter import (DEFAULTS as DM_DEFAULTS, tier_counts,
                                        load_config as dm_load_config)

        payload = json.loads(dp_data)
        cfg = dm_load_config()
        candidate = dict(cfg)
        for k, v in payload.items():
            if k in DM_DEFAULTS and v is not None:
                candidate[k] = v

        saved = tier_counts(cfg=cfg)
        preview = tier_counts(cfg=candidate)
        print(json.dumps({"success": True, "preview": preview, "saved": saved}))

    except Exception as e:
        print(json.dumps({"success": False, "error": str(e)}))

    sys.exit(0)

# ajax_groups_data handler removed - now using direct_config_save

# Handle direct config save (simplified approach)
if "direct_config_save" in form and form.getvalue("direct_config_save"):
    config_data = form.getvalue("direct_config_save")

    print("Content-Type: application/json\n")

    try:
        new_config = json.loads(config_data)

        # Read on-disk config once for merges. Even if we can't read it, we can
        # still save (degraded — missing concurrency protection for this request).
        existing = None
        try:
            with open(MASTER_CONFIG_FILE, 'r') as f:
                existing = json.load(f)
        except Exception:
            existing = None

        # Merge passwords back into users (browser config has passwords stripped)
        if 'misc' in new_config and 'users' in new_config['misc']:
            existing_users = (existing or {}).get('misc', {}).get('users', {}) if existing else {}
            for uname, udata in new_config['misc']['users'].items():
                if 'password' not in udata or not udata.get('password'):
                    udata['password'] = existing_users.get(uname, {}).get('password', '6767')

        # Preserve `generators` per group from disk. Site Manager has no UI for
        # adding/moving generators — Generator Manager is the only authority for
        # that, and it writes via gen_history_api/ConfigManager. Without this,
        # a Site Manager save with a stale window.siteConfig (e.g. a tab loaded
        # before someone moved gens via Generator Manager) wholesale-overwrites
        # the file and silently reverts those moves with no history trail.
        # Renamed/new groups (key not on disk) keep the browser's data so the
        # rename flow continues to carry generators forward.
        # Generator ORDER, however, comes from the browser: drag-to-reorder in Site
        # Manager is the only way to set it, so taking the disk dict wholesale silently
        # ate every reorder. Take values from disk, key order from the browser.
        if existing is not None:
            existing_sites = existing.get('sites', {})
            for site_name, site_data in new_config.get('sites', {}).items():
                existing_groups = existing_sites.get(site_name, {}).get('generator_groups', {})
                for group_name, group_data in site_data.get('generator_groups', {}).items():
                    if group_name not in existing_groups:
                        continue
                    # gas_hold_state is runtime state the gas-hold evaluator
                    # owns — live psi, current stage, reserved gens. Site Manager
                    # edits the gas hold SETTINGS (thresholds, per-group arm), not
                    # this, and has no UI for it at all. Left to the browser's
                    # copy, a save made while Fortson sat at stage 1 would write
                    # back whatever that tab loaded and drop a live reserve until
                    # the next evaluation cycle rebuilt it. Same reasoning as
                    # generators below: automation owns it, so it comes from disk.
                    if 'gas_hold_state' in existing_groups[group_name]:
                        group_data['gas_hold_state'] = existing_groups[group_name]['gas_hold_state']
                    else:
                        group_data.pop('gas_hold_state', None)

                    disk_gens = existing_groups[group_name].get('generators', {})
                    browser_gens = group_data.get('generators') or {}
                    # browser order first (dropping gens moved away since page load),
                    # then any disk gens the browser hasn't seen yet, at the end
                    ordered = {g: disk_gens[g] for g in browser_gens if g in disk_gens}
                    for g, gdata in disk_gens.items():
                        ordered.setdefault(g, gdata)
                    group_data['generators'] = ordered

        # Pin Spares / Out of Service below the real sites, keeping their own
        # relative order. See TRAILING_SITES above.
        sites = new_config.get('sites')
        if isinstance(sites, dict):
            tail = [s for s in sites if s in TRAILING_SITES]
            if tail:
                new_config['sites'] = {k: v for k, v in sites.items() if k not in tail}
                new_config['sites'].update({k: sites[k] for k in tail})

        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(new_config, f, indent=2)

        print('{"success": true}')

    except Exception as e:
        print(f'{{"success": false, "error": "Save failed: {str(e)}"}}')

    sys.exit(0)

# Surgical generator identity save (mac / bridge_mac) via ConfigManager.
# NOT direct_config_save: that path preserves `generators` from disk and would
# discard these edits. Notes/status/moves remain Generator Manager's job.
# (master_config.json must be ngon:ngon 664 so this www-data CGI can write it.)
if "save_gen_fields" in form:
    print("Content-Type: application/json\n")
    try:
        gen_id = (form.getvalue("gen_id") or "").strip()
        site_name = (form.getvalue("site_name") or "").strip()
        group_name = (form.getvalue("group_name") or "").strip()
        if not (gen_id and site_name and group_name):
            print('{"success": false, "error": "gen_id, site_name, group_name required"}')
            sys.exit(0)

        from managers.config_manager import ConfigManager
        cm = ConfigManager()
        base = ["sites", site_name, "generator_groups", group_name, "generators", gen_id]

        # values arrive pre-normalized from the browser; '' clears the field
        changed = False
        for field in ("mac", "bridge_mac"):
            if field in form:
                val = (form.getvalue(field) or "").strip()
                r = cm.update_value(base + [field], val if val else None, broadcast=False)
                if not r.get("success"):
                    msg = str(r.get("error")).replace('"', "'")
                    print('{"success": false, "error": "update %s failed: %s"}' % (field, msg))
                    sys.exit(0)
                changed = True
        if changed:
            cm._broadcast_update()
        print('{"success": true}')
    except Exception as e:
        print('{"success": false, "error": "%s"}' % str(e).replace('"', "'"))
    sys.exit(0)

# Handle session check requests (for automatic logout)
if "session_check" in form and form.getvalue("session_check"):
    print("Content-Type: application/json\n")
    print('{"success": true}')
    sys.exit(0)

# Disable cgitb for remaining requests to prevent any unwanted output
cgitb.enable()



# update_gen_note handler removed - now using in-memory system with simplifiedSave()

# ajax_action handler removed - now using in-memory system with simplifiedSave()

# If not an AJAX request, output HTML header
print("Content-Type: text/html\n")

def load_sites_with_groups():
    """Load sites with their generator groups, pods and generators from master config"""
    sites = {}
    unassigned_pods = {}
    all_generators = set()
    
    if os.path.exists(MASTER_CONFIG_FILE):
        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)
        
        # Extract sites with their groups, pods, and generators
        for site_name, site_data in config.get("sites", {}).items():
            sites[site_name] = {
                'site_id': site_data.get('site_id', ''),
                'pool1': site_data.get('pool1', ''),
                'pool2': site_data.get('pool2', ''),
                'pool3': site_data.get('pool3', ''),
                'worker_name1': site_data.get('worker_name1', ''),
                'worker_name2': site_data.get('worker_name2', ''),
                'worker_name3': site_data.get('worker_name3', ''),
                'location': site_data.get('location', {}),
                'network': site_data.get('network', ''),
                'generator_groups': {},
                'peplink': site_data.get('peplink', {}),
                'switch_ip': site_data.get('switch_ip', ''),
                'webhooks': site_data.get('webhooks', {'alerts': '', 'chat': ''})
            }
            
            for group_name, group_data in site_data.get("generator_groups", {}).items():
                sites[site_name]['generator_groups'][group_name] = {
                    'pods': {},
                    'generators': group_data.get("generators", {}),
                    'network': group_data.get('network', ''),
                    'peplink': group_data.get('peplink', {}),
                    'switch_ip': group_data.get('switch_ip', ''),
                    'max_gen_kw': group_data.get('max_gen_kw', 330)
                }
                
                # Add generators to master list
                for gen_id in group_data.get("generators", {}):
                    all_generators.add(gen_id)
                
                # Add pods with their settings
                for pod_name, pod_settings in group_data.get("pods", {}).items():
                    sites[site_name]['generator_groups'][group_name]['pods'][pod_name] = pod_settings
    
    return sites, unassigned_pods, all_generators


def load_settings():
    """Load pod settings (sleep, reboot, miner counts, miner types, sleep targets) from master config"""
    settings = {
        'sleep_eligible': {},
        'reboot_eligible': {},
        'wake_eligible': {},
        'miner_counts': {},
        'miner_types': {},
    }
    
    if os.path.exists(MASTER_CONFIG_FILE):
        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)
        
        # Extract pod settings from all sites
        for site_name, site_data in config.get("sites", {}).items():
            for group_name, group_data in site_data.get("generator_groups", {}).items():
                for pod_name, pod_settings in group_data.get("pods", {}).items():
                    settings['sleep_eligible'][pod_name] = pod_settings.get('auto_sleep_enabled', False)
                    settings['reboot_eligible'][pod_name] = pod_settings.get('auto_reboot_enabled', False)
                    settings['wake_eligible'][pod_name] = pod_settings.get('auto_wake_enabled', False)
                    settings['miner_counts'][pod_name] = pod_settings.get('miner_count', 0)
                    settings['miner_types'][pod_name] = pod_settings.get('miner_type', 'M60')
    
    return settings


def save_groups_data(groups_data):
    """Parse and save groups data from the web interface"""
    if not os.path.exists(MASTER_CONFIG_FILE):
        return
    
    try:
        # Parse the groups data format: "site_name:group1,group2|site2_name:group3,group4"
        # Within each site, groups have format: "group_name@pod1,pod2;gen1,gen2"
        # This preserves both site organization and group order
        sites_order = []
        sites_info = {}
        
        if groups_data:  # Handle empty data
            # First pass: parse the data structure
            for site_data in groups_data.split('||'):
                if '::' in site_data:
                    site_name, groups_part = site_data.split('::', 1)
                    sites_order.append(site_name)
                    sites_info[site_name] = []
                    
                    if groups_part:
                        for group_data in groups_part.split('|'):
                            if '@' in group_data:
                                group_name, rest = group_data.split('@', 1)
                                if ';' in rest:
                                    pods_part, gens_part = rest.split(';', 1)
                                    pods = [p.strip() for p in pods_part.split(',') if p.strip()]
                                    generators = [g.strip() for g in gens_part.split(',') if g.strip()]
                                else:
                                    pods = [p.strip() for p in rest.split(',') if p.strip()]
                                    generators = []
                                
                                sites_info[site_name].append({
                                    'name': group_name,
                                    'pods': pods,
                                    'generators': generators
                                })
    
        # Update the master config
        with open(MASTER_CONFIG_FILE, "r") as f:
            config = json.load(f)
        
        # Create a new sites dictionary with the correct order
        new_sites = {}
        
        # First, add sites in the order they appear in the UI
        for site_name in sites_order:
            if site_name in config["sites"]:
                new_sites[site_name] = config["sites"][site_name]
        
        # Then add any remaining sites that weren't in the UI (shouldn't happen, but just in case)
        for site_name in config["sites"]:
            if site_name not in new_sites:
                new_sites[site_name] = config["sites"][site_name]
        
        # Replace the sites dictionary with the ordered one
        config["sites"] = new_sites
        
        # Update each site with its groups in the correct order
        for site_name, groups_list in sites_info.items():
            if site_name in config["sites"]:
                site_data = config["sites"][site_name]
                
                # Create new ordered generator_groups
                new_generator_groups = {}
                
                for group_info in groups_list:
                    group_name = group_info['name']
                    
                    # Preserve existing group data if it exists
                    if group_name in site_data.get("generator_groups", {}):
                        existing_group = site_data["generator_groups"][group_name]
                        
                        # Update generators (preserve existing generator data like notes)
                        new_generators = {}
                        for gen_entry in group_info['generators']:
                            # Parse generator ID and note if present
                            if ':' in gen_entry:
                                gen_id, gen_note = gen_entry.split(':', 1)
                                new_generators[gen_id] = {"notes": gen_note}
                            else:
                                gen_id = gen_entry
                                # Check if this generator exists anywhere with notes
                                found_notes = False
                                for check_site in config["sites"].values():
                                    for check_group in check_site.get("generator_groups", {}).values():
                                        if gen_id in check_group.get("generators", {}):
                                            gen_data = check_group["generators"][gen_id]
                                            if isinstance(gen_data, dict) and gen_data.get("notes"):
                                                new_generators[gen_id] = gen_data
                                                found_notes = True
                                                break
                                    if found_notes:
                                        break
                                if not found_notes:
                                    new_generators[gen_id] = {}
                        existing_group["generators"] = new_generators
                        
                        # Update pods (preserve existing settings)
                        current_pods = set(existing_group.get("pods", {}).keys())
                        new_pods = set(group_info['pods'])
                        
                        # Remove pods not in new list
                        for pod_name in current_pods - new_pods:
                            del existing_group["pods"][pod_name]
                        
                        # Add new pods with default settings
                        for pod_name in new_pods - current_pods:
                            existing_group["pods"][pod_name] = {
                                "miner_count": 0,
                                "auto_sleep_enabled": True,
                                "auto_reboot_enabled": True,
                                "auto_wake_enabled": False,
                                "power_target": 0,
                                "miner_type": "M60",
                                "peplink": {
                                    "display_children": true
                                }
                            }
                        
                        new_generator_groups[group_name] = existing_group
                    else:
                        # Create new group
                        new_generators = {}
                        for gen_entry in group_info['generators']:
                            # Parse generator ID and note if present
                            if ':' in gen_entry:
                                gen_id, gen_note = gen_entry.split(':', 1)
                                new_generators[gen_id] = {"notes": gen_note}
                            else:
                                gen_id = gen_entry
                                # Check if this generator exists anywhere with notes
                                found_notes = False
                                for check_site in config["sites"].values():
                                    for check_group in check_site.get("generator_groups", {}).values():
                                        if gen_id in check_group.get("generators", {}):
                                            gen_data = check_group["generators"][gen_id]
                                            if isinstance(gen_data, dict) and gen_data.get("notes"):
                                                new_generators[gen_id] = gen_data
                                                found_notes = True
                                                break
                                    if found_notes:
                                        break
                                if not found_notes:
                                    new_generators[gen_id] = {}
                        
                        new_generator_groups[group_name] = {
                            "pods": {pod: {"miner_count": 0, "auto_sleep_enabled": True, "auto_reboot_enabled": True, "auto_wake_enabled": False, "power_target": 0, "miner_type": "M60"}
                                    for pod in group_info['pods']},
                            "generators": new_generators
                        }
                
                # Replace with the ordered version
                site_data["generator_groups"] = new_generator_groups
        
        # Update timestamp
        from datetime import datetime, timezone
        new_timestamp = datetime.now(timezone.utc).astimezone().isoformat()
        config["last_updated"] = new_timestamp
        
        with open(MASTER_CONFIG_FILE, "w") as f:
            json.dump(config, f, indent=2)
        
            
    except Exception as e:
        # Log error but don't crash
        import sys
        print(f"Error saving groups data: {e}", file=sys.stderr)
        

# rename_group function removed - now using in-memory system

# rename_pod function removed - now using in-memory system

# update_pod_miner_count function removed - now using in-memory system

# update_pod_miner_type function removed - now using in-memory system


# update_pod_setting function removed - now using in-memory system

# rename_site function removed - now using in-memory system

# update_generator_note function removed - now using in-memory system

# update_site_id function removed - now using in-memory system

# create_new_group function removed - now using in-memory system

# create_new_site function removed - now using in-memory system

# create_new_pod function removed - now using in-memory system

# add_generator_to_group function removed - now using in-memory system

# add_generator_to_spares function removed - now using in-memory system

# update_peplink function removed - now using in-memory system

# delete_peplink function removed - now using in-memory system

# update_network function removed - now using in-memory system

# delete_network function removed - now using in-memory system

# delete_site function removed - now using in-memory system

# delete_group function removed - now using in-memory system

# delete_pod function removed - now using in-memory system

def read_config_files():
    """Read all existing configuration files"""
    sites, unassigned_pods, all_generators = load_sites_with_groups()
    settings = load_settings()
    
    config = {
        'sites': sites,
        'unassigned_pods': unassigned_pods,
        'all_generators': list(all_generators),
        'sleep_eligible': settings['sleep_eligible'],
        'reboot_eligible': settings['reboot_eligible'],
        'miner_counts': settings['miner_counts'],
        'miner_types': settings['miner_types'],
    }
    return config


# Old POST handlers removed - all operations now use direct_config_save with in-memory system

# Peplink and network handlers removed - now using in-memory system

# All remaining handlers removed - using in-memory system only

# Read config AFTER all form processing
config = read_config_files()

# Hash of what a save from this page would overwrite, handed to the browser so
# it can tell a collision from the automation traffic that dominates config
# writes (20-35 an hour, nearly all of it per-gen fields this page's save
# re-reads from disk anyway). Same function status_api uses when it emits
# gen_config_changed — one definition, so the two can't drift.
try:
    from managers.config_manager import site_manager_save_digest
    with open(MASTER_CONFIG_FILE) as _mc:
        _SM_DIGEST = site_manager_save_digest(json.load(_mc))
except Exception:
    # No digest -> warn on everything, which is how this page behaved before.
    _SM_DIGEST = ""
sites = config['sites']
unassigned_pods = config['unassigned_pods']
all_generators = config['all_generators']
settings = {
    'sleep_eligible': config['sleep_eligible'],
    'reboot_eligible': config['reboot_eligible'],
    'miner_counts': config['miner_counts'],
    'miner_types': config['miner_types'],
}

print("""
<!DOCTYPE html>
<html>
<head>
    <title>NGON Site Manager</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
    <link rel="stylesheet" href="ngon-modals.css">
    <link rel="stylesheet" href="site_manager.css">
    <style>
        .config-changed-banner {
            display: none;
            position: fixed;
            top: 0;
            left: 50%;
            transform: translateX(-50%);
            z-index: 20000;
            background: #2a2a2a;
            border: 1px solid #ffaa00;
            border-top: none;
            border-radius: 0 0 8px 8px;
            padding: 10px 16px;
            color: #ffaa00;
            font-size: 13px;
            align-items: center;
            gap: 10px;
            box-shadow: 0 4px 14px rgba(0,0,0,0.5);
        }
        .config-changed-banner button {
            background: #ffaa00;
            color: #000;
            border: none;
            padding: 5px 12px;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
            font-size: 12px;
        }
        .config-changed-banner button.ccb-dismiss {
            background: transparent;
            color: #888;
            font-weight: normal;
            border: 1px solid #444;
        }
        .config-changed-overlay {
            display: none;
            position: fixed;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0, 0, 0, 0.85);
            z-index: 20000;
            justify-content: center;
            align-items: center;
        }
        .config-changed-modal {
            background: #1a1a1a;
            border: 2px solid #ff6600;
            border-radius: 12px;
            padding: 40px;
            text-align: center;
            max-width: 400px;
        }
        .config-changed-modal h2 { color: #ff6600; margin: 0 0 20px 0; }
        .config-changed-modal p  { color: #ccc; margin: 0 0 30px 0; font-size: 16px; }
        .config-changed-modal button {
            background: #ff6600;
            color: white;
            border: none;
            padding: 15px 40px;
            font-size: 16px;
            border-radius: 6px;
            cursor: pointer;
        }
        .config-changed-modal button:hover { background: #ff8833; }
    </style>
    <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
</head>
<body>

<!-- Someone changed config in a way a save from this page would overwrite.
     A banner rather than a blocking overlay: taking the page away mid-edit is
     how the old modal ate work. The real protection is at the save itself —
     saveConfig() re-asks if a change is still outstanding. -->
<div id="configChangedBanner" class="config-changed-banner">
    <span>Config changed elsewhere — reload before saving, or your save may revert it.</span>
    <button onclick="location.reload()">Reload</button>
    <button class="ccb-dismiss" onclick="dismissConfigChanged()" title="Keep working — you'll be asked again if you save">Dismiss</button>
</div>
""")

# Show login modal if not authenticated
if auth_required:
    username = auth.is_authenticated()
    if username:
        print(generate_access_denied_html("Site Manager"))
    else:
        print(generate_login_modal_html("Site Manager"))
    print("</body></html>")
    sys.exit(0)

print("""
<!-- Main content -->
<div id="mainContent">
<h1>NGON Site Manager</h1>

    <div class="main-area" ondrop="dropInMainArea(event)" ondragover="allowDrop(event)" ondragleave="leaveDrop(event)">
""")

# Output sites
for site_name, site_data in sites.items():
        
    print(f'<div class="site-container" data-site="{site_name}" draggable="true" ondragstart="dragSite(event)" ondragend="endDrag(event)" ondrop="dropInSite(event)" ondragover="allowDrop(event)" ondragleave="leaveDrop(event)">')
    print(f'<div class="site-header">')
    print(f'<div>')
    print(f'<div class="site-title" onclick="editSiteName(\'{site_name}\'); event.stopPropagation();" style="cursor: pointer;" title="Click to edit site name">{site_name}</div>')

    # Webhooks - displayed under site name
    site_webhooks = site_data.get('webhooks', {})
    alerts_wh = site_webhooks.get('alerts', '')
    chat_wh = site_webhooks.get('chat', '')
    alerts_short = alerts_wh[-20:] if alerts_wh else ''
    chat_short = chat_wh[-20:] if chat_wh else ''
    print(f'<div style="display: flex; gap: 10px; font-size: 11px; margin: 2px 0; flex-wrap: wrap;">')
    if alerts_wh:
        print(f'<span onclick="editWebhook(\'{site_name}\', \'alerts\'); event.stopPropagation();" style="cursor: pointer; color: #ff6600; padding: 2px 4px; background: rgba(255,102,0,0.1); border-radius: 3px;" title="Edit alerts webhook"><i class="fas fa-bell" style="margin-right: 3px;"></i>Alerts: ...{alerts_short}</span>')
    else:
        print(f'<span onclick="editWebhook(\'{site_name}\', \'alerts\'); event.stopPropagation();" style="cursor: pointer; color: #888; padding: 2px 4px;" title="Add alerts webhook"><i class="fas fa-bell" style="margin-right: 3px;"></i>Add Alerts Webhook</span>')
    if chat_wh:
        print(f'<span onclick="editWebhook(\'{site_name}\', \'chat\'); event.stopPropagation();" style="cursor: pointer; color: #4a9eff; padding: 2px 4px; background: rgba(74,158,255,0.1); border-radius: 3px;" title="Edit chat webhook"><i class="fas fa-comment" style="margin-right: 3px;"></i>Chat: ...{chat_short}</span>')
    else:
        print(f'<span onclick="editWebhook(\'{site_name}\', \'chat\'); event.stopPropagation();" style="cursor: pointer; color: #888; padding: 2px 4px;" title="Add chat webhook"><i class="fas fa-comment" style="margin-right: 3px;"></i>Add Chat Webhook</span>')
    print(f'</div>')

    site_id = site_data.get('site_id', '')
    if site_id:
        print(f'<div class="site-location" onclick="editSiteId(\'{site_name}\', \'{site_id}\'); event.stopPropagation();" style="cursor: pointer;" title="Click to edit site ID">Site ID: {site_id}</div>')
    else:
        print(f'<div class="site-location" onclick="editSiteId(\'{site_name}\', \'\'); event.stopPropagation();" style="cursor: pointer; color: #888;" title="Click to add site ID">Click to add Site ID</div>')
    
    # Pools and Worker names (side by side)
    pool1 = site_data.get('pool1', '')
    pool2 = site_data.get('pool2', '')
    pool3 = site_data.get('pool3', '')
    worker_name1 = site_data.get('worker_name1', '')
    worker_name2 = site_data.get('worker_name2', '')
    worker_name3 = site_data.get('worker_name3', '')
    
    # Pool 1 and Worker 1
    print(f'<div style="display: flex; gap: 10px; font-size: 12px; margin: 2px 0;">')
    if pool1:
        print(f'<div onclick="editPool(\'{site_name}\', 1, \'{pool1}\'); event.stopPropagation();" style="cursor: pointer; color: #666; white-space: nowrap; min-width: 300px;" title="Click to edit pool 1">Pool 1: {pool1}</div>')
    else:
        print(f'<div onclick="editPool(\'{site_name}\', 1, \'\'); event.stopPropagation();" style="cursor: pointer; color: #888; white-space: nowrap; min-width: 300px;" title="Click to add pool 1">Click to add Pool 1</div>')
    
    if worker_name1:
        print(f'<div onclick="editWorkerName(\'{site_name}\', 1, \'{worker_name1}\'); event.stopPropagation();" style="cursor: pointer; color: #666; white-space: nowrap;" title="Click to edit worker name 1">Worker 1: {worker_name1}</div>')
    else:
        print(f'<div onclick="editWorkerName(\'{site_name}\', 1, \'\'); event.stopPropagation();" style="cursor: pointer; color: #888; white-space: nowrap;" title="Click to add worker name 1">Click to add Worker 1</div>')
    print(f'</div>')
    
    # Pool 2 and Worker 2
    print(f'<div style="display: flex; gap: 10px; font-size: 12px; margin: 2px 0;">')
    if pool2:
        print(f'<div onclick="editPool(\'{site_name}\', 2, \'{pool2}\'); event.stopPropagation();" style="cursor: pointer; color: #666; white-space: nowrap; min-width: 300px;" title="Click to edit pool 2">Pool 2: {pool2}</div>')
    else:
        print(f'<div onclick="editPool(\'{site_name}\', 2, \'\'); event.stopPropagation();" style="cursor: pointer; color: #888; white-space: nowrap; min-width: 300px;" title="Click to add pool 2">Click to add Pool 2</div>')
    
    if worker_name2:
        print(f'<div onclick="editWorkerName(\'{site_name}\', 2, \'{worker_name2}\'); event.stopPropagation();" style="cursor: pointer; color: #666; white-space: nowrap;" title="Click to edit worker name 2">Worker 2: {worker_name2}</div>')
    else:
        print(f'<div onclick="editWorkerName(\'{site_name}\', 2, \'\'); event.stopPropagation();" style="cursor: pointer; color: #888; white-space: nowrap;" title="Click to add worker name 2">Click to add Worker 2</div>')
    print(f'</div>')
    
    # Pool 3 and Worker 3
    print(f'<div style="display: flex; gap: 10px; font-size: 12px; margin: 2px 0;">')
    if pool3:
        print(f'<div onclick="editPool(\'{site_name}\', 3, \'{pool3}\'); event.stopPropagation();" style="cursor: pointer; color: #666; white-space: nowrap; min-width: 300px;" title="Click to edit pool 3">Pool 3: {pool3}</div>')
    else:
        print(f'<div onclick="editPool(\'{site_name}\', 3, \'\'); event.stopPropagation();" style="cursor: pointer; color: #888; white-space: nowrap; min-width: 300px;" title="Click to add pool 3">Click to add Pool 3</div>')
    
    if worker_name3:
        print(f'<div onclick="editWorkerName(\'{site_name}\', 3, \'{worker_name3}\'); event.stopPropagation();" style="cursor: pointer; color: #666; white-space: nowrap;" title="Click to edit worker name 3">Worker 3: {worker_name3}</div>')
    else:
        print(f'<div onclick="editWorkerName(\'{site_name}\', 3, \'\'); event.stopPropagation();" style="cursor: pointer; color: #888; white-space: nowrap;" title="Click to add worker name 3">Click to add Worker 3</div>')
    print(f'</div>')
    
    print(f'</div>')
    print(f'<div class="site-controls">')

    # Peplink
    site_peplink = site_data.get('peplink', {})
    if site_peplink.get('group_id') and site_peplink.get('device_id'):
        group_id = site_peplink.get('group_id')
        device_id = site_peplink.get('device_id')
        router_ip = site_peplink.get('router_ip', '')
        display_children = str(site_peplink.get('display_children', True)).lower()
        pep_label = router_ip or f'{group_id}/{device_id}'
        print(f'<span onclick="editPeplink(\'site\', \'{site_name}\', {group_id}, {device_id}, \'\', \'\', {display_children}, \'{router_ip}\'); event.stopPropagation();" style="cursor: pointer; color: #00ff00; font-size: 11px; padding: 2px 4px; background: rgba(0,255,0,0.1); border-radius: 3px;" title="Edit peplink"><i class="fas fa-wifi" style="margin-right: 3px;"></i>{pep_label}</span>')
    else:
        print(f'<div onclick="editPeplink(\'site\', \'{site_name}\', 0, 0, \'\', \'\', true, \'\'); event.stopPropagation();" style="cursor: pointer; width: 16px; height: 16px; border: 2px dashed #00ff00; display: inline-block;" title="Click to add peplink"></div>')

    # Switch
    site_switch_ip = site_data.get('switch_ip', '')
    if site_switch_ip:
        print(f'<span onclick="editSwitch(\'site\', \'{site_name}\', \'{site_switch_ip}\', \'\', \'\'); event.stopPropagation();" style="cursor: pointer; color: #8B5CF6; font-size: 11px; padding: 2px 4px; background: rgba(139,92,246,0.1); border-radius: 3px;" title="Edit switch"><i class="fas fa-network-wired" style="margin-right: 3px;"></i>{site_switch_ip}</span>')
    else:
        print(f'<div onclick="editSwitch(\'site\', \'{site_name}\', \'\', \'\', \'\'); event.stopPropagation();" style="cursor: pointer; width: 16px; height: 16px; border: 2px dashed #8B5CF6; display: inline-block;" title="Click to add switch"></div>')

    # Network
    site_network = site_data.get('network', '')
    if site_network:
        print(f'<span onclick="editNetwork(\'site\', \'{site_name}\', \'{site_network}\', \'\', \'\'); event.stopPropagation();" style="cursor: pointer; color: #0080ff; font-size: 11px; padding: 2px 4px; background: rgba(0,128,255,0.1); border-radius: 3px;" title="Edit network"><i class="fas fa-sitemap" style="margin-right: 3px;"></i>{site_network}</span>')
    else:
        print(f'<div onclick="editNetwork(\'site\', \'{site_name}\', \'\', \'\', \'\'); event.stopPropagation();" style="cursor: pointer; width: 16px; height: 16px; border: 2px dashed #0080ff; display: inline-block;" title="Click to add network"></div>')

    print(f'</div>')
    print(f'</div>')

    print(f'<div class="groups-wrapper" data-site="{site_name}">')
    
    # Output generator groups for this site
    for group_name, group_data in site_data['generator_groups'].items():
        group_pods = group_data['pods']
        group_generators = group_data['generators']
        
        print(f'<div class="group-container" data-group="{group_name}" draggable="true" ondragstart="dragGroup(event)" ondragend="endDrag(event)" ondrop="dropInGroup(event)" ondragover="allowDrop(event)" ondragleave="leaveDrop(event)">')
        print(f'<div class="group-header">')
        print(f'<div class="group-title" onclick="editGroupName(\'{group_name}\')">{group_name}</div>')
        print(f'<div class="group-controls">')

        # Peplink icon/box
        group_peplink = group_data.get('peplink', {})
        if group_peplink.get('group_id') and group_peplink.get('device_id'):
            group_id = group_peplink.get('group_id')
            device_id = group_peplink.get('device_id')
            router_ip = group_peplink.get('router_ip', '')
            display_children = str(group_peplink.get('display_children', True)).lower()
            pep_label = router_ip or f'{group_id}/{device_id}'
            print(f'<span onclick="editPeplink(\'group\', \'{group_name}\', {group_id}, {device_id}, \'{site_name}\', \'{group_name}\', {display_children}, \'{router_ip}\'); event.stopPropagation();" style="cursor: pointer; color: #00ff00; font-size: 10px; padding: 2px 4px; background: rgba(0,255,0,0.1); border-radius: 3px;" title="Edit peplink (Group: {group_id}, Device: {device_id})"><i class="fas fa-wifi" style="margin-right: 3px;"></i>{pep_label}</span>')
        else:
            print(f'<div onclick="editPeplink(\'group\', \'{group_name}\', 0, 0, \'{site_name}\', \'{group_name}\', true, \'\'); event.stopPropagation();" style="cursor: pointer; width: 14px; height: 14px; border: 2px dashed #00ff00; display: inline-block;" title="Click to add peplink"></div>')

        # Master switch icon/box
        group_switch_ip = group_data.get('switch_ip', '')
        if group_switch_ip:
            print(f'<span onclick="editSwitch(\'group\', \'{group_name}\', \'{group_switch_ip}\', \'{site_name}\', \'{group_name}\'); event.stopPropagation();" style="cursor: pointer; color: #8B5CF6; font-size: 10px; padding: 2px 4px; background: rgba(139,92,246,0.1); border-radius: 3px;" title="Edit master switch (IP: {group_switch_ip})"><i class="fas fa-network-wired" style="margin-right: 3px;"></i>{group_switch_ip}</span>')
        else:
            print(f'<div onclick="editSwitch(\'group\', \'{group_name}\', \'\', \'{site_name}\', \'{group_name}\'); event.stopPropagation();" style="cursor: pointer; width: 14px; height: 14px; border: 2px dashed #8B5CF6; display: inline-block;" title="Click to add master switch"></div>')

        # Network
        group_network = group_data.get('network', '')
        if group_network:
            print(f'<span onclick="editNetwork(\'group\', \'{group_name}\', \'{group_network}\', \'{site_name}\', \'\'); event.stopPropagation();" style="cursor: pointer; color: #0080ff; font-size: 10px; padding: 2px 4px; background: rgba(0,128,255,0.1); border-radius: 3px;" title="Edit network: {group_network}"><i class="fas fa-sitemap" style="margin-right: 3px;"></i>{group_network}</span>')
        else:
            print(f'<div onclick="editNetwork(\'group\', \'{group_name}\', \'\', \'{site_name}\', \'\'); event.stopPropagation();" style="cursor: pointer; width: 14px; height: 14px; border: 2px dashed #0080ff; display: inline-block;" title="Click to add network"></div>')

        # Max gen kW
        max_gen_kw = group_data.get('max_gen_kw', 330)
        print(f'<span onclick="editMaxGenKw(\'{site_name}\', \'{group_name}\', {max_gen_kw}); event.stopPropagation();" style="cursor: pointer; color: #ffaa00; font-size: 10px; padding: 2px 4px; background: rgba(255,170,0,0.1); border-radius: 3px; white-space: nowrap;" title="Click to edit max kW per generator"><i class="fas fa-bolt" style="margin-right: 3px;"></i>{max_gen_kw} kW/gen</span>')

        print(f'</div>')
        print(f'</div>')

        # Show generators in this group
        print(f'<div class="generators-section">')
        print(f'<div class="generators-title"><i class="fas fa-bolt"></i> Generators ({len(group_generators)})</div>')
        print(f'<div class="generators-grid" ondrop="dropInGeneratorsGrid(event)" ondragover="allowDrop(event)" ondragleave="leaveDrop(event)">')
        
        # Display generators in their natural dictionary order (which is preserved in Python 3.7+)
        for gen_id, gen_data in group_generators.items():
            gen_note = gen_data.get('notes', '') if isinstance(gen_data, dict) else ''
            # Use data attributes to avoid quote escaping issues
            escaped_note = gen_note.replace('"', '&quot;')
            print(f'<div class="gen" draggable="true" ondragstart="dragGen(event)" ondragend="endDrag(event)" ondrop="dropOnGenerator(event)" ondragover="allowDrop(event)" ondragleave="leaveDrop(event)" data-gen="{gen_id}" data-note="{escaped_note}" onclick="editGeneratorNoteFromData(this); event.stopPropagation();" style="cursor: pointer;" title="Click to edit generator note">')
            print(f'<div class="gen-id">{gen_id}</div>')
            if gen_note:
                print(f'<div class="gen-note">{gen_note}</div>')
            print(f'</div>')
        print(f'</div>')
        print(f'</div>')
        
        # Show pods in this group
        print(f'<div class="pods-section">')
        for pod_name, pod_settings in group_pods.items():
            miner_count = pod_settings.get('miner_count', 0)
            miner_type = pod_settings.get('miner_type', 'M60')
            
            print(f'<div class="pod" data-pod="{pod_name}" draggable="true" ondragstart="dragPod(event)" ondragend="endDrag(event)">')
            print(f'<div style="display: flex; justify-content: space-between; align-items: center;">')
            print(f'<h4 onclick="editPodName(\'{pod_name}\'); event.stopPropagation();" style="cursor: pointer; margin: 0;" title="Click to edit pod name">{pod_name}</h4>')
            print(f'<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">')

            # Peplink
            pod_peplink = pod_settings.get('peplink', {})
            if pod_peplink.get('group_id') and pod_peplink.get('device_id'):
                group_id = pod_peplink.get('group_id')
                device_id = pod_peplink.get('device_id')
                router_ip = pod_peplink.get('router_ip', '')
                display_children = str(pod_peplink.get('display_children', True)).lower()
                pep_label = router_ip or f'{group_id}/{device_id}'
                print(f'<span onclick="editPeplink(\'pod\', \'{pod_name}\', {group_id}, {device_id}, \'{site_name}\', \'{group_name}\', {display_children}, \'{router_ip}\'); event.stopPropagation();" style="cursor: pointer; color: #00ff00; font-size: 9px; padding: 1px 3px; background: rgba(0,255,0,0.1); border-radius: 3px;" title="Edit peplink"><i class="fas fa-wifi" style="margin-right: 2px;"></i>{pep_label}</span>')
            else:
                print(f'<div onclick="editPeplink(\'pod\', \'{pod_name}\', 0, 0, \'{site_name}\', \'{group_name}\', true, \'\'); event.stopPropagation();" style="cursor: pointer; width: 12px; height: 12px; border: 2px dashed #00ff00; display: inline-block;" title="Click to add peplink"></div>')

            # Switch
            pod_switch_ip = pod_settings.get('switch_ip', '')
            if pod_switch_ip:
                print(f'<span onclick="editSwitch(\'pod\', \'{pod_name}\', \'{pod_switch_ip}\', \'{site_name}\', \'{group_name}\'); event.stopPropagation();" style="cursor: pointer; color: #8B5CF6; font-size: 9px; padding: 1px 3px; background: rgba(139,92,246,0.1); border-radius: 3px;" title="Edit switch"><i class="fas fa-network-wired" style="margin-right: 2px;"></i>{pod_switch_ip}</span>')
            else:
                print(f'<div onclick="editSwitch(\'pod\', \'{pod_name}\', \'\', \'{site_name}\', \'{group_name}\'); event.stopPropagation();" style="cursor: pointer; width: 12px; height: 12px; border: 2px dashed #8B5CF6; display: inline-block;" title="Click to add switch"></div>')

            # Network
            network = pod_settings.get('network', '')
            if network:
                print(f'<span onclick="editNetwork(\'pod\', \'{pod_name}\', \'{network}\', \'{site_name}\', \'{group_name}\'); event.stopPropagation();" style="cursor: pointer; color: #0080ff; font-size: 9px; padding: 1px 3px; background: rgba(0,128,255,0.1); border-radius: 3px;" title="Edit network"><i class="fas fa-sitemap" style="margin-right: 2px;"></i>{network}</span>')
            else:
                print(f'<div onclick="editNetwork(\'pod\', \'{pod_name}\', \'\', \'{site_name}\', \'{group_name}\'); event.stopPropagation();" style="cursor: pointer; width: 12px; height: 12px; border: 2px dashed #0080ff; display: inline-block;" title="Click to add network"></div>')

            print(f'</div>')
            print(f'</div>')
            print(f'<div class="pod-info" onclick="editMinerCount(\'{pod_name}\', {miner_count}); event.stopPropagation();" style="cursor: pointer;" title="Click to edit miner count"><i class="fas fa-microchip"></i> {miner_count} miners</div>')
            print(f'<div class="pod-info" onclick="editMinerType(\'{pod_name}\', \'{miner_type}\'); event.stopPropagation();" style="cursor: pointer;" title="Click to edit miner type"><i class="fas fa-cog"></i> Type: {miner_type}</div>')
            power_target = pod_settings.get('power_target', 0)
            power_label = f'{power_target}W' if power_target else 'Off'
            print(f'<div class="pod-info" onclick="editPowerTarget(\'{pod_name}\', {power_target}); event.stopPropagation();" style="cursor: pointer;" title="Click to edit power target (watts per miner, 0=unmanaged)"><i class="fas fa-bolt" style="color: #ffaa00;"></i> Power Target: {power_label}</div>')

            print('</div>')
        print(f'</div>')
        print('</div>')

    print(f'</div>')  # Close groups-wrapper
    print(f'</div>')  # Close site-container

print("""
    </div>
""")

# Show unassigned pods if any
if unassigned_pods:
    print(f'<div class="unassigned-pods" ondrop="dropInUnassignedPods(event)" ondragover="allowDrop(event)" ondragleave="leaveDrop(event)">')
    print(f'<div class="unassigned-title">Unassigned Pods</div>')
    print(f'<div class="pods-section">')
    for pod_name, pod_settings in unassigned_pods.items():
        miner_count = pod_settings.get('miner_count', 0)
        miner_type = pod_settings.get('miner_type', 'M60')
        
        print(f'<div class="pod" data-pod="{pod_name}" draggable="true" ondragstart="dragPod(event)" ondragend="endDrag(event)">')
        print(f'<div style="display: flex; justify-content: space-between; align-items: center;">')
        print(f'<h4 onclick="editPodName(\'{pod_name}\'); event.stopPropagation();" style="cursor: pointer; margin: 0;" title="Click to edit pod name">{pod_name}</h4>')
        print(f'<div style="display: flex; align-items: center; gap: 8px;">')
        
        # Add peplink icon for unassigned pod
        pod_peplink = pod_settings.get('peplink', {})
        if pod_peplink.get('group_id') and pod_peplink.get('device_id'):
            # Has peplink data - show router icon
            group_id = pod_peplink.get('group_id')
            device_id = pod_peplink.get('device_id')
            display_children = str(pod_peplink.get('display_children', True)).lower()
            print(f'<i class="fas fa-wifi" onclick="editPeplink(\'pod\', \'{pod_name}\', {group_id}, {device_id}, \'\', \'\', {display_children}); event.stopPropagation();" style="cursor: pointer; color: #00ff00; font-size: 12px; padding: 4px; margin-right: 8px; vertical-align: middle; display: inline-block;" title="Edit peplink (Group: {group_id}, Device: {device_id})"></i>')
        else:
            # No peplink data - show empty box
            print(f'<div onclick="editPeplink(\'pod\', \'{pod_name}\', 0, 0, \'\', \'\', true); event.stopPropagation();" style="cursor: pointer; width: 12px; height: 12px; border: 2px dashed #00ff00; margin-right: 8px; display: inline-block; vertical-align: middle;" title="Click to add peplink"></div>')
        
        # Add network field for unassigned pod
        network = pod_settings.get('network', '')
        if network:
            # Has network data - show network text
            print(f'<span onclick="editNetwork(\'pod\', \'{pod_name}\', \'{network}\', \'\', \'\'); event.stopPropagation();" style="cursor: pointer; color: #0080ff; font-size: 10px; padding: 2px; background: rgba(0,128,255,0.1); border-radius: 3px; margin-left: 4px; vertical-align: middle;" title="Edit network: {network}">{network}</span>')
        else:
            # No network data - show empty box
            print(f'<div onclick="editNetwork(\'pod\', \'{pod_name}\', \'\', \'\', \'\'); event.stopPropagation();" style="cursor: pointer; width: 12px; height: 12px; border: 2px dashed #0080ff; display: inline-block; margin-left: 4px; vertical-align: middle;" title="Click to add network"></div>')
        
        print(f'</div>')
        print(f'</div>')
        print(f'<div class="pod-info" onclick="editMinerCount(\'{pod_name}\', {miner_count}); event.stopPropagation();" style="cursor: pointer;" title="Click to edit miner count"><i class="fas fa-microchip"></i> {miner_count} miners</div>')
        print(f'<div class="pod-info" onclick="editMinerType(\'{pod_name}\', \'{miner_type}\'); event.stopPropagation();" style="cursor: pointer;" title="Click to edit miner type"><i class="fas fa-cog"></i> Type: {miner_type}</div>')
        print('</div>')
    print(f'</div>')
    print('</div>')

# The alert-category catalogue is owned by notifications_api (ALERT_CATEGORIES) so
# there is exactly one list. Fetched here server-side rather than from the browser,
# which keeps notifications_api off the public proxy — it is localhost-only and has
# no auth of its own. Current on/off state comes from window.configData.misc.alerts,
# already in the page, so this only supplies keys and labels.
_alert_cats = []
try:
    import urllib.request as _urlreq
    with _urlreq.urlopen("http://127.0.0.1:5005/alert_settings", timeout=4) as _r:
        _alert_cats = (json.loads(_r.read().decode()) or {}).get("categories", [])
except Exception:
    _alert_cats = []          # panel renders an explicit "unavailable" message
print(f'<script>window.ALERT_CATALOGUE = {json.dumps(_alert_cats)};</script>')

print("""
    </div>

<hr>

<!-- Creation buttons using in-memory system -->
<div class="forms-container">
    <div class="form-section">
        <h3>Create New Site</h3>
        <button type="button" onclick="createNewSite()" class="btn">Create Site</button>
    </div>
    
    <div class="form-section">
        <h3>Create New Group</h3>
        <button type="button" onclick="createNewGroup()" class="btn">Create Group</button>
    </div>
    
    <div class="form-section">
        <h3>Create New Pod</h3>
        <button type="button" onclick="createNewPod()" class="btn">Create Pod</button>
    </div>
    
    <div class="form-section">
        <h3>Create New Generator</h3>
        <button type="button" onclick="createNewGenerator()" class="btn">Create Generator</button>
        <p style="font-size: 12px; color: #888;">Generator will be added to the selected group</p>
    </div>
    
    <div class="form-section">
        <h3>Miner Type Specifications</h3>
        <div id="minerTypesContainer"></div>
        <div style="margin-top: 15px;">
            <button onclick="showAddMinerTypeForm()" style="background-color: #28a745; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; cursor: pointer;">
                <i class="fas fa-plus"></i> Add New Miner Type
            </button>
        </div>
        <div id="addMinerTypeForm" style="display: none; margin-top: 15px; padding: 15px; background: rgba(0,0,0,0.3); border-radius: 5px;">
            <div style="display: flex; flex-wrap: wrap; gap: 10px; align-items: center;">
                <input type="text" id="newMinerTypeName" placeholder="Type Name (e.g., S21)" style="background: #333; border: 1px solid #555; color: white; padding: 8px; border-radius: 4px; flex: 1; min-width: 120px;">
                <input type="number" id="newMinerTypeSpec" placeholder="Spec TH/s (e.g., 200)" style="background: #333; border: 1px solid #555; color: white; padding: 8px; border-radius: 4px; flex: 1; min-width: 120px;">
                <input type="number" id="newMinerTypeWattage" placeholder="Spec Watts (e.g., 3400)" style="background: #333; border: 1px solid #555; color: white; padding: 8px; border-radius: 4px; flex: 1; min-width: 120px;">
                <button onclick="addMinerType()" style="background-color: #007bff; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; white-space: nowrap;">Add</button>
                <button onclick="hideAddMinerTypeForm()" style="background-color: #6c757d; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; white-space: nowrap;">Cancel</button>
            </div>
        </div>
    </div>
    
    </div>
</div>

<!-- User Management - full width below other sections -->
<div style="background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; padding: 20px; margin-top: 20px;">
    <h3 style="color: #00ff00; margin-top: 0;">User Management</h3>
    <div id="employeesContainer"></div>
    <div style="margin-top: 15px;">
        <button onclick="showAddEmployeeForm()" style="background-color: #28a745; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; cursor: pointer;">
            <i class="fas fa-plus"></i> Add New User
        </button>
    </div>
    <div id="addEmployeeForm" style="display: none; margin-top: 15px; padding: 15px; background: rgba(0,0,0,0.3); border-radius: 5px;">
        <div style="display: flex; flex-wrap: wrap; gap: 10px; align-items: center;">
            <input type="text" id="newEmployeeInitials" placeholder="Username (e.g., JD)" style="background: #333; border: 1px solid #555; color: white; padding: 8px; border-radius: 4px; flex: 1; min-width: 150px; text-transform: uppercase;">
            <button onclick="addEmployee()" style="background-color: #007bff; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; white-space: nowrap;">Add</button>
            <button onclick="hideAddEmployeeForm()" style="background-color: #6c757d; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; white-space: nowrap;">Cancel</button>
        </div>
    </div>
</div>

<!-- Gen Control: engine-action safety switch (START / ON LOAD) -->
<div style="background: rgba(255,102,0,0.06); border: 1px solid rgba(255,120,60,0.3); border-radius: 10px; padding: 16px; margin-top: 20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#ff6600; margin:0;">Generator Control &mdash; Engine Actions</h3>
        <label style="color:#ddd; font-size:14px; display:flex; align-items:center; gap:8px; cursor:pointer;">
            <input type="checkbox" id="gcEngineActions" onchange="saveGenControl()"> Allow remote START / ON&nbsp;LOAD
        </label>
        <span id="gcSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#d9a273; font-size:12px; margin-top:8px; line-height:1.5;">
        <i class="fas fa-exclamation-triangle" style="color:#ff6600;"></i> When ON, users with Gen Control access can remotely <b>start generators</b> and <b>close breakers (on&nbsp;load)</b> from the status page &mdash; each still gated by on-controller interlocks (manual&nbsp;+&nbsp;alarms-clear&nbsp;+&nbsp;stopped for start) and a type-&ldquo;yes&rdquo; confirm for start. <b>Leave OFF unless a field tech is on-site.</b> Manual-mode and Clear-alarms are always allowed regardless of this switch.
    </div>
</div>

<!-- Power Management: live gen-data miner control -->
<div style="background: rgba(0,204,102,0.06); border:1px solid rgba(0,204,102,0.3); border-radius:10px; padding:16px; margin-top:20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#00cc66; margin:0;">Power Management &mdash; Live Gen Data Control</h3>
        <label style="color:#ddd; font-size:14px; display:flex; align-items:center; gap:8px; cursor:pointer;">
            <input type="checkbox" id="pmLiveEnabled" onchange="savePowerMgmt()"> Use live gen data for miner control
        </label>
        <span id="pmSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#9bbfa8; font-size:12px; margin-top:8px; line-height:1.5;">
        When ON, any generator group where <b>every gen is reporting fresh live telemetry</b> is controlled from live data &mdash; fast instant-shed on overload, patient 30&nbsp;s-smoothed wake, with a boot-wake hold once gens come <b>on&nbsp;load</b> (running <i>and</i> breaker closed). Groups missing live data fall back to Mesa automatically. Live and Mesa control stay fully separate. Changes take effect within ~5&nbsp;s &mdash; no restart. Turning this OFF is an instant kill switch back to Mesa for every group.
    </div>
    <style>
      /* Label text at top, input box pinned to bottom, so boxes line up in rows
         no matter how tall each field's help blurb is. */
      #pmGrid > label > input, #pmGrid > label > select { margin-top: auto; }
    </style>
    <div id="pmGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:10px 22px; margin-top:14px; font-size:13px; color:#ccc; align-items:stretch;">
        <label style="display:flex; flex-direction:column; gap:3px;">Only these groups <span style="color:#777; font-size:11px;">comma-separated; blank = all live-capable groups</span>
            <input type="text" id="pmGroups" placeholder="e.g. GN 6, Will 1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Live freshness (s) <span style="color:#777; font-size:11px;">gen counts as live if seen within this</span>
            <input type="number" id="pmFresh" min="5" max="600" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Boot-wake hold (s) <span style="color:#777; font-size:11px;">on-load settle before waking</span>
            <input type="number" id="pmBoot" min="0" max="3600" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Wake lockout (s) <span style="color:#777; font-size:11px;">pace between wake batches (ramp ~90% @ 140s)</span>
            <input type="number" id="pmWakeLock" min="0" max="3600" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Wake lockout &mdash; high load (s) <span style="color:#777; font-size:11px;">longer pace when a gen is &ge;90% rated; instant-shed guards the top</span>
            <input type="number" id="pmWakeLockHi" min="0" max="3600" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Shed lockout (s) <span style="color:#777; font-size:11px;">pace between sheds (shed ~95% @ 4s)</span>
            <input type="number" id="pmShedLock" min="0" max="600" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Deadband (miners) <span style="color:#777; font-size:11px;">flap buffer; acts only past this many miners of drift</span>
            <input type="number" id="pmDeadband" min="1" max="20" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
    </div>
    <div style="margin-top:14px; display:flex; align-items:center; gap:12px;">
        <button onclick="savePowerMgmt()" style="background-color:#00994d; color:white; border:none; padding:8px 16px; font-size:14px; border-radius:4px; cursor:pointer;">
            <i class="fas fa-save"></i> Save Power Settings
        </button>
        <span style="color:#888; font-size:12px;">Fully-live groups today: GN 1&ndash;7, Will 1/3/4/5 (others auto-fall-back to Mesa)</span>
    </div>
</div>

<!-- Auto-Restart (AR) gate settings — org-wide defaults for the gen_autostart service -->
<div style="background: rgba(204,136,0,0.06); border:1px solid rgba(204,136,0,0.35); border-radius:10px; padding:16px; margin-top:20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#cc8800; margin:0;">Auto-Restart &mdash; Gate Settings</h3>
        <label style="color:#ddd; font-size:14px; display:flex; align-items:center; gap:8px; cursor:pointer;">
            <input type="checkbox" id="arSystemEnabled" onchange="saveAutoRestart()"> <b>System ENABLED</b>
        </label>
        <span id="arSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#cbb083; font-size:12px; margin-top:6px; line-height:1.5;">
        <i class="fas fa-power-off" style="color:#cc8800;"></i> Master kill switch. While OFF, the AR toggle on the status page is <b>locked</b> (no group can be armed) and the service actuates nothing. Turn ON only when you're ready to run auto-restart.
    </div>
    <div style="color:#cbb083; font-size:12px; margin-top:8px; line-height:1.5;">
        Org-wide defaults for the autonomous group auto-restart service. Per-group arming is the <b>AR</b> toggle on the status page (default OFF). On a full group-down, if all gates pass the service clears alarms, restarts the healthy gens, warms them, then asks for load &mdash; it does <b>not</b> close breakers. Requires <b>Generator Control &rarr; Engine Actions</b> (above) to be ON to actuate. Takes effect within one service cycle (~15&nbsp;s), no restart. <b>Window uses server-local time.</b>
    </div>
    <style>
      /* Keep every field's label text at the top of its grid cell and pin the
         input box to the bottom, so the boxes line up in neat rows regardless of
         how tall each field's help blurb is. */
      #arGrid > label > input:not([type=checkbox]),
      #arGrid > label > select { margin-top: auto; }
    </style>
    <div id="arGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:10px 22px; margin-top:14px; font-size:13px; color:#ccc; align-items:stretch;">
        <label style="display:flex; flex-direction:column; gap:3px;">Night window start <span style="color:#777; font-size:11px;">HH:MM, server-local</span>
            <input type="time" id="arWinStart" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Night window end <span style="color:#777; font-size:11px;">HH:MM, server-local (wraps midnight)</span>
            <input type="time" id="arWinEnd" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Just-ran window (min) <span style="color:#777; font-size:11px;">fresh trip restarts any time (nobody's on it yet)</span>
            <input type="number" id="arJustRan" min="0" max="120" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Confirm settle (s) <span style="color:#777; font-size:11px;">group must stay down this long before acting</span>
            <input type="number" id="arConfirm" min="10" max="600" step="5" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Warm-up mode
            <select id="arWarmMode" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;">
                <option value="minutes">Fixed minutes</option>
                <option value="temp">Coolant temperature</option>
            </select></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Warm-up minutes <span style="color:#777; font-size:11px;">used in 'Fixed minutes' mode</span>
            <input type="number" id="arWarmMin" min="0" max="60" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Warm-up target (&deg;F) <span style="color:#777; font-size:11px;">coolant target in 'temperature' mode (Mesa winter: 160)</span>
            <input type="number" id="arWarmTarget" min="100" max="220" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Warm-up cap (min) <span style="color:#777; font-size:11px;">max wait in temperature mode</span>
            <input type="number" id="arWarmMax" min="1" max="60" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <div style="grid-column:1/-1; display:flex; flex-direction:column; gap:4px;">
            <div style="color:#ddd;">Exclude causes <span style="color:#777; font-size:11px;">check a cause to hold a gen carrying it out of auto-restart. Each cause folds in the Mesa no-restart CODES that map to it &mdash; click <b>&#9656;</b> to see them. 'overload' is a cascade victim and is locked out; gas / manual / e-stop have their own gates.</span></div>
            <div id="arCatList" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(230px, 1fr)); gap:2px 14px; background:#222; border:1px solid #444; border-radius:4px; padding:8px; max-height:260px; overflow:auto;"></div>
        </div>
        <label style="display:flex; align-items:center; gap:8px; color:#ddd; align-self:end; margin-bottom:6px;"><input type="checkbox" id="arGasBlock"> Block restart on a gas (Low NG) cause</label>
        <label style="display:flex; flex-direction:column; gap:3px;">Re-trip window (min) <span style="color:#777; font-size:11px;">if a restarted gen falls back down within this many minutes, AR disables it (kW modal) until repaired</span>
            <input type="number" id="arRetripWin" min="1" max="120" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Flow floor (MCF/day) <span style="color:#777; font-size:11px;">GN/Will only; blank = skip flowmeter check</span>
            <input type="number" id="arFlowMin" min="0" step="1" placeholder="(none)" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Retry limit (attempts) <span style="color:#777; font-size:11px;">anti-loop circuit breaker</span>
            <input type="number" id="arCbMax" min="1" max="20" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Retry window (min) <span style="color:#777; font-size:11px;">count attempts within this span</span>
            <input type="number" id="arCbWin" min="1" max="1440" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Cooldown (min) <span style="color:#777; font-size:11px;">hold after hitting the limit</span>
            <input type="number" id="arCbCool" min="0" max="1440" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
    </div>
    <div style="margin-top:14px;">
        <button onclick="saveAutoRestart()" style="background-color:#996600; color:white; border:none; padding:8px 16px; font-size:14px; border-radius:4px; cursor:pointer;">
            <i class="fas fa-save"></i> Save Auto-Restart Settings
        </button>
    </div>
</div>

<!-- Chat commands — !swap in the site Spaces, and auto-EMS on a Mesa shutdown request -->
<div style="background: rgba(170,68,255,0.06); border:1px solid rgba(170,68,255,0.35); border-radius:10px; padding:16px; margin-top:20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#aa44ff; margin:0;">Chat Commands &mdash; Site Spaces</h3>
        <label style="color:#ddd; font-size:14px; display:flex; align-items:center; gap:8px; cursor:pointer;">
            <input type="checkbox" id="ccSystemEnabled" onchange="saveChatCommands()"> <b>System ENABLED</b>
        </label>
        <span id="ccSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#d3b3f0; font-size:12px; margin-top:6px; line-height:1.5;">
        <i class="fas fa-power-off" style="color:#aa44ff;"></i> Master kill switch. While OFF the service reads the Spaces but parses nothing and answers nothing &mdash; and because it keeps its cursors moving, switching ON starts from <b>now</b> rather than replaying a backlog of old commands.
    </div>
    <div style="color:#d3b3f0; font-size:12px; margin-top:8px; line-height:1.5;">
        Watches the <b>12 per-site chat Spaces</b> (not the alerts Spaces, and not the Mesa tracker &mdash; those are already read elsewhere). A command must start with <b>!</b> &mdash; prose is never acted on, because &ldquo;swap&rdquo; in a site room is usually a blower motor or a filter. Every !command gets an answer: the result, or why not.<br>
        <span style="color:#b9e6b9;"><b>!swap &lt;gen coming out&gt; for &lt;gen going in&gt;</b></span> &mdash; e.g. <code>!swap 588 for 583</code>. No site needed: wherever the outgoing gen is, that's where the incoming one goes.<br>
        <span style="color:#b9e6b9;"><b>!move &lt;gen&gt; to &lt;group or OOS&gt;</b></span> &mdash; e.g. <code>!move 588 to OOS</code>, <code>!move 500 to Will 2</code>. For a gen pulled with nothing going back in. &ldquo;OOS&rdquo; picks TX or ND from the gen's own site.<br>
        <span style="color:#b9e6b9;"><b>!order &lt;group&gt; &lt;gens in order&gt;</b></span> &mdash; e.g. <code>!order Will 2 577 583 565 320 482</code>. Shuffles the group into that line-up; every gen in the group must be listed. <code>#</code> works in place of <code>!</code>.<br>
        <code>!help</code> (or just <code>!</code>) lists the commands in-room and marks any that aren't switched on for that site. It isn't gated by the switches below &mdash; asking what exists isn't an action &mdash; but the master switch above still hides it. A wrong command gets the same list back, so nobody has to ask.
    </div>
    <div id="ccGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px 22px; margin-top:14px; font-size:13px; color:#ccc; align-items:stretch;">
        <label style="display:flex; flex-direction:column; gap:3px;">
            <span style="display:flex; align-items:center; gap:8px; color:#ddd;"><input type="checkbox" id="ccSwapEnabled"> <b>!swap</b> &mdash; gen swaps from chat</span>
            <span style="color:#777; font-size:11px;">Swaps on sight and answers with what moved. One atomic config write &mdash; it cannot half-complete.</span></label>
        <label style="display:flex; flex-direction:column; gap:3px;">
            <span style="display:flex; align-items:center; gap:8px; color:#ddd;"><input type="checkbox" id="ccMoveEnabled"> <b>!move</b> &mdash; single-gen moves from chat</span>
            <span style="color:#777; font-size:11px;">Moves one gen to a group, or to Out of Service. Refuses a bare multi-group site name rather than guessing which pad.</span></label>
        <label style="display:flex; flex-direction:column; gap:3px;">
            <span style="display:flex; align-items:center; gap:8px; color:#ddd;"><input type="checkbox" id="ccOrderEnabled"> <b>!order</b> &mdash; gen line-up from chat</span>
            <span style="color:#777; font-size:11px;">Display order is the physical line-up on the pad. Refuses a partial list rather than quietly shuffling the gen you left out to the end.</span></label>
        <label style="display:flex; flex-direction:column; gap:3px;">
            <span style="display:flex; align-items:center; gap:8px; color:#ddd;"><input type="checkbox" id="ccEmsEnabled"> <b>Auto-EMS</b> on a Mesa SHUTDOWN request</span>
            <span style="color:#777; font-size:11px;">Sleeps the group's pods the moment a tech requests shutdown, and suspends Auto-Wake + Reboot-Zero. STARTUP stays manual on purpose.</span></label>
        <label style="display:flex; flex-direction:column; gap:3px;">
            <span style="display:flex; align-items:center; gap:8px; color:#ddd;"><input type="checkbox" id="ccHintEnabled"> Hint on a swap typed without <b>!</b></span>
            <span style="color:#777; font-size:11px;">One nudge, never an action. Useful while people learn the sigil.</span></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Poll interval (seconds) <span style="color:#777; font-size:11px;">how often each Space is checked</span>
            <input type="number" id="ccPoll" min="10" max="300" step="5" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Duplicate window (seconds) <span style="color:#777; font-size:11px;">same swap re-typed inside this window is answered, not re-run</span>
            <input type="number" id="ccDupWin" min="60" max="86400" step="60" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Hint cooldown (seconds) <span style="color:#777; font-size:11px;">at most one nudge per Space per window</span>
            <input type="number" id="ccHintCool" min="60" max="86400" step="60" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
    </div>
    <div style="margin-top:16px;">
        <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:8px;">
            <span style="color:#ddd; font-size:13px;"><b>Sites allowed to run commands</b></span>
            <label style="color:#ddd; font-size:13px; display:flex; align-items:center; gap:8px; cursor:pointer;">
                <input type="checkbox" id="ccAllSites" onchange="renderCcSites()"> every site
            </label>
            <span style="color:#777; font-size:11px;">Pilot one or two first; a site that isn't ticked gets told so rather than ignored.</span>
        </div>
        <div id="ccSiteList" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:4px 14px; background:#222; border:1px solid #444; border-radius:4px; padding:10px;"></div>
    </div>
    <div style="margin-top:14px;">
        <button onclick="saveChatCommands()" style="background-color:#7a2fb8; color:white; border:none; padding:8px 16px; font-size:14px; border-radius:4px; cursor:pointer;">
            <i class="fas fa-save"></i> Save Chat Command Settings
        </button>
    </div>
</div>

<!-- Alert switches — which categories reach the chat rooms -->
<div style="background: rgba(74,158,255,0.06); border:1px solid rgba(74,158,255,0.35); border-radius:10px; padding:16px; margin-top:20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#4a9eff; margin:0;">Alerts &mdash; What Reaches Chat</h3>
        <span id="alSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#a8c7e8; font-size:12px; margin-top:6px; line-height:1.5;">
        Switch off any category that isn't worth an interruption. Turning one off suppresses the <b>live chat message</b> only &mdash; the event still feeds the summary digest and every subsystem keeps logging it, so nothing is lost, it just stops pinging. Takes effect on the very next alert; no restart.
    </div>
    <div id="alertCatList" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:8px 22px; margin-top:14px;">
        <span style="color:#888; font-size:13px;">Loading categories&hellip;</span>
    </div>
    <div style="margin-top:14px;">
        <button onclick="saveAlertSettings()" style="background-color:#2a6fb0; color:white; border:none; padding:8px 16px; font-size:14px; border-radius:4px; cursor:pointer;">
            <i class="fas fa-save"></i> Save Alert Settings
        </button>
    </div>
</div>

<!-- Read-only public view links (misc.public_view.views) -->
<div style="background: rgba(0,204,102,0.06); border:1px solid rgba(0,204,102,0.3); border-radius:10px; padding:16px; margin-top:20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#00cc66; margin:0;">Read-Only View Links</h3>
        <button onclick="addPublicView()" style="padding:5px 12px; background:#14261c; border:1px solid #235c3c; color:#00cc66; border-radius:4px; cursor:pointer; font-size:12px;">
            <i class="fas fa-plus"></i> New link
        </button>
        <span id="pvSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#9bbfa8; font-size:12px; margin-top:6px; line-height:1.5;">
        Shareable links to a <b>view-only</b> site page &mdash; no login, no menu, nothing actionable. For temps and partners who need to see what is up and what is down. Each link can be limited to chosen sites (none ticked = every site). Turning one off, or deleting it, revokes it on the next page load.
    </div>
    <div style="color:#9bbfa8; font-size:12px; margin-top:8px; line-height:1.5;">
        <i class="fas fa-triangle-exclamation" style="color:#ffaa00;"></i> Anyone holding the link can open it &mdash; treat it like a password. It shows fleet status only; it cannot sleep miners, touch generators or reach any other page.
    </div>
    <div id="pvList" style="margin-top:14px; display:flex; flex-direction:column; gap:12px;"></div>
</div>

<!-- Gas-Pressure Hold — org-wide defaults for the gas capacity hold -->
<div style="background: rgba(255,102,0,0.06); border:1px solid rgba(255,102,0,0.35); border-radius:10px; padding:16px; margin-top:20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#ff6600; margin:0;">Gas-Pressure Hold &mdash; Defaults</h3>
        <label style="color:#ddd; font-size:14px; display:flex; align-items:center; gap:8px; cursor:pointer;">
            <input type="checkbox" id="ghSystemEnabled" onchange="saveGasHold()"> <b>System ENABLED</b>
        </label>
        <span id="ghSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#e0b48a; font-size:12px; margin-top:6px; line-height:1.5;">
        <i class="fas fa-power-off" style="color:#ff6600;"></i> Master kill switch. While OFF nothing is evaluated and no group can be armed. Per-group arming and per-group psi thresholds live on the <b>status page &rarr; gen group kW modal</b>, because normal NG pressure differs by site.
    </div>
    <div style="color:#e0b48a; font-size:12px; margin-top:8px; line-height:1.5;">
        Sheds mining load <b>before</b> a group's gens starve on gas, and gives it back when supply recovers &mdash; the gas analogue of the pre-emptive capacity hold. Thresholds are <b>absolute psi</b>, not percentages: controller trips cluster near 10&nbsp;psi while per-gen baselines run 19&ndash;44&nbsp;psi, so a relative rule fires constantly on a high-baseline gen and far too late on a low-baseline one. Takes effect within one monitor cycle (~15&nbsp;s), no restart.
    </div>
    <style>
      #ghGrid > label > input:not([type=checkbox]) { margin-top: auto; }
    </style>
    <div id="ghGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:10px 22px; margin-top:14px; font-size:13px; color:#ccc; align-items:stretch;">
        <label style="display:flex; flex-direction:column; gap:3px;">Watch below (psi) <span style="color:#777; font-size:11px;">pause auto-wake; reserves nothing</span>
            <input type="number" id="ghWatch" min="1" max="200" step="0.5" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Reserve 1 gen below (psi) <span style="color:#777; font-size:11px;">first stage that actually sheds load</span>
            <input type="number" id="ghHold1" min="1" max="200" step="0.5" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Reserve 2 gens below (psi) <span style="color:#777; font-size:11px;">deep stage; never empties a group</span>
            <input type="number" id="ghHold2" min="1" max="200" step="0.5" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Release margin (psi) <span style="color:#777; font-size:11px;">must recover this far above a step to leave it</span>
            <input type="number" id="ghRelMargin" min="0" max="50" step="0.5" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Release dwell (min) <span style="color:#777; font-size:11px;">how long recovery must hold before capacity returns</span>
            <input type="number" id="ghRelMin" min="1" max="720" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Reserve at stage 1 (gens) <span style="color:#777; font-size:11px;">capacity held back at the first shedding step</span>
            <input type="number" id="ghRes1" min="1" max="6" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Reserve at stage 2 (gens) <span style="color:#777; font-size:11px;">deep step; never leaves a group with nothing on load</span>
            <input type="number" id="ghRes2" min="1" max="6" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Confirm samples <span style="color:#777; font-size:11px;">consecutive readings before reserving (watch needs 1)</span>
            <input type="number" id="ghConfirm" min="1" max="10" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Min live sensors <span style="color:#777; font-size:11px;">usable gen readings needed to judge a group</span>
            <input type="number" id="ghMinGens" min="1" max="10" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Stale decay (min) <span style="color:#777; font-size:11px;">no usable reading this long &rarr; ease the hold one step</span>
            <input type="number" id="ghStale" min="5" max="720" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
    </div>
    <div style="margin-top:14px;">
        <button onclick="saveGasHold()" style="background-color:#a34f00; color:white; border:none; padding:8px 16px; font-size:14px; border-radius:4px; cursor:pointer;">
            <i class="fas fa-save"></i> Save Gas-Hold Settings
        </button>
    </div>
</div>

<!-- Dead-Miner Scoring — weights for the Dead Miner Hunter + inventory API -->
<div style="background: rgba(255,68,68,0.06); border:1px solid rgba(255,68,68,0.35); border-radius:10px; padding:16px; margin-top:20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color:#ff6666; margin:0;">Dead-Miner Scoring &mdash; Confidence Weights</h3>
        <span id="dmSaveStatus" style="font-size:13px;"></span>
    </div>
    <div style="color:#e0b3b3; font-size:12px; margin-top:8px; line-height:1.5;">
        Scores every miner 0&ndash;100 for dead-confidence. Drives the <b>Dead Miner Hunter</b> page and the dead-miner feed the <b>inventory app</b> reads when a pod is opened. Signals are extracted from history on a cron; the score itself is computed <b>on every read</b>, so a change here re-scores the whole fleet immediately &mdash; no re-scan.
        <br><b>The dominant signal is reboots, not zero-hash</b>: a power-parked healthy miner also sits at zero hash. What separates dead from parked is that reboot_zero hammers it and it still won't run. Keep <i>zero-hash</i> weighted below the <i>score floor</i> so it can never surface a parked miner on its own.
    </div>

    <!-- Live preview — the whole point of the signals/score split -->
    <div id="dmPreview" style="margin-top:14px; padding:12px; background:rgba(0,0,0,0.3); border-radius:8px; border:1px solid #333;">
        <div style="font-size:12px; color:#888; margin-bottom:8px;">Live preview &mdash; how many miners these settings would flag, scored against the current signal store:</div>
        <div style="display:flex; gap:26px; flex-wrap:wrap; align-items:flex-end;">
            <div><div style="font-size:26px; font-weight:700; color:#ff4444;" id="dmPvDead">&mdash;</div><div style="font-size:11px; color:#888;">DEAD (pull)</div></div>
            <div><div style="font-size:26px; font-weight:700; color:#ffaa00;" id="dmPvSuspect">&mdash;</div><div style="font-size:11px; color:#888;">SUSPECT</div></div>
            <div><div style="font-size:26px; font-weight:700; color:#666;" id="dmPvDropped">&mdash;</div><div style="font-size:11px; color:#888;">below floor / parked</div></div>
            <div style="border-left:1px solid #444; padding-left:22px;"><div style="font-size:13px; color:#aaa;" id="dmPvDelta">&nbsp;</div><div style="font-size:11px; color:#666;" id="dmPvMeta">&nbsp;</div></div>
        </div>
    </div>

    <div id="dmGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:10px 22px; margin-top:14px; font-size:13px; color:#ccc; align-items:stretch;">
        <label style="display:flex; flex-direction:column; gap:3px;">Weight: reboots <span style="color:#777; font-size:11px;">"tried, won't run" &mdash; strongest signal</span>
            <input type="number" id="dmWReboots" min="0" max="100" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Weight: hardware error <span style="color:#777; font-size:11px;">hashboard/fan/temp/power fault present</span>
            <input type="number" id="dmWHw" min="0" max="100" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Weight: zero-hash <span style="color:#777; font-size:11px;">keep BELOW the score floor</span>
            <input type="number" id="dmWZero" min="0" max="100" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Weight: crash-loop <span style="color:#777; font-size:11px;">repeated sub-30-min uptimes</span>
            <input type="number" id="dmWUnstable" min="0" max="100" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Reboots for full points <span style="color:#777; font-size:11px;">reboots/30d that earns the whole reboot weight</span>
            <input type="number" id="dmRebootsFull" min="1" max="500" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Pull threshold <span style="color:#777; font-size:11px;">score &ge; this = "dead", pull for repair</span>
            <input type="number" id="dmPull" min="1" max="100" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Score floor <span style="color:#777; font-size:11px;">below this a miner isn't reported at all</span>
            <input type="number" id="dmFloor" min="0" max="100" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Never-hashed reboot trigger <span style="color:#777; font-size:11px;">never hashed + this many reboots = auto-dead</span>
            <input type="number" id="dmNeverReboots" min="0" max="500" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Auto-dead min days <span style="color:#777; font-size:11px;">history needed before auto-dead can fire</span>
            <input type="number" id="dmAutoMinDays" min="1" max="30" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px;">Parked-gate (days) <span style="color:#777; font-size:11px;">genuinely hashed within this many days &rarr; parked, not dead</span>
            <input type="number" id="dmNoHashDays" min="1" max="365" step="1" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        <label style="display:flex; flex-direction:column; gap:3px; grid-column:1/-1;">Hardware-fault categories <span style="color:#777; font-size:11px;">error categories that count as hardware failure (comma-separated)</span>
            <input type="text" id="dmHwCats" placeholder="hashboard, fan, temp_sensor, overtemp, power" oninput="dmPreview()" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
    </div>

    <div style="margin-top:16px; padding:12px; background:rgba(0,120,60,0.07); border:1px solid rgba(0,200,100,0.25); border-radius:8px;">
        <div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
            <label style="display:flex; align-items:center; gap:8px; color:#ddd; font-size:13px; cursor:pointer;">
                <input type="checkbox" id="dmPodGate" onchange="dmPreview()"> <b>Peer-relative pod gate</b>
            </label>
            <label style="display:flex; align-items:center; gap:8px; color:#ccc; font-size:13px;">pod-active fraction
                <input type="number" id="dmPodFrac" min="0" max="1" step="0.05" oninput="dmPreview()" style="width:90px; background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        </div>
        <div style="color:#8fbf9f; font-size:12px; margin-top:8px; line-height:1.5;">
            Judges a miner only on days its <b>own pod</b> was demonstrably able to run &mdash; if this share of the pod's miners hashed that day, a silent miner is genuinely broken; if the pod was dark, the day is discarded rather than counted as evidence of death. Uses the pod's other miners as the control group, so it covers gas, gen-down, peplink-down and breaker outages from one data source.
            <br>A pod dark for <i>weeks</i> (Osprey) is already immune &mdash; it produces no snapshots at all, so its miners are never scored. What this protects is a pod <b>coming back</b>: M60s boot sleeping, and without the gate any with &ge;10 reboots would auto-confirm dead within <i>auto-dead min days</i>.
        </div>
    </div>

    <div style="margin-top:16px; padding-top:12px; border-top:1px solid #442222;">
        <div style="color:#cc8888; font-size:12px; margin-bottom:10px;">
            <i class="fas fa-clock"></i> <b>Extraction settings</b> &mdash; these change what gets pulled out of history, so they only take effect on the <b>next extraction run</b> (cron, every 2 h). The live preview above will not reflect them until then.
        </div>
        <div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:10px 22px; font-size:13px; color:#ccc;">
            <label style="display:flex; flex-direction:column; gap:3px;">Scoring window (days)
                <input type="number" id="dmWindowDays" min="1" max="90" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
            <label style="display:flex; flex-direction:column; gap:3px;">Min samples/day <span style="color:#777; font-size:11px;">day counts as an eval day at &ge; this (~43/day when online)</span>
                <input type="number" id="dmMinSamples" min="1" max="200" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
            <label style="display:flex; flex-direction:column; gap:3px;">Short-uptime (sec) <span style="color:#777; font-size:11px;">uptime below this counts as a recent reboot</span>
                <input type="number" id="dmShortUptime" min="60" max="86400" step="60" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
            <label style="display:flex; flex-direction:column; gap:3px;">Real-hash threshold (TH/s) <span style="color:#777; font-size:11px;">hashrate that counts as "actually hashing"</span>
                <input type="number" id="dmRealHash" min="1" max="500" step="1" style="background:#2a2a2a; border:1px solid #555; color:#fff; padding:5px; border-radius:3px;"></label>
        </div>
    </div>

    <div style="margin-top:14px;">
        <button onclick="saveDeadMiner()" style="background-color:#993333; color:white; border:none; padding:8px 16px; font-size:14px; border-radius:4px; cursor:pointer;">
            <i class="fas fa-save"></i> Save Dead-Miner Settings
        </button>
    </div>
</div>

<!-- Generator Warning Settings - full width below other sections -->
<div style="background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; padding: 20px; margin-top: 20px;">
    <div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
        <h3 style="color: #00ff00; margin: 0;">Generator Warning Settings</h3>
        <label style="color:#ccc; font-size:13px; display:flex; align-items:center; gap:6px;">
            <input type="checkbox" id="gwEnabled"> Warnings enabled
        </label>
        <span style="color:#888; font-size:12px;">Thresholds in operator units (&deg;F / psi / V / rpm). These are tunable starter values.</span>
    </div>

    <div style="background:rgba(74,158,255,0.08); border:1px solid rgba(74,158,255,0.25); border-radius:8px; padding:10px 14px; margin-top:14px; color:#bcd; font-size:12.5px; line-height:1.5;">
        These warnings evaluate off <b>both</b> live telemetry and Mesa data &mdash; we prefer live when a gen has a wifi bridge, and fall back to Mesa otherwise (slower, but valid). Every threshold below is available from either source. If we later add data points that only exist on the live feed, we'll designate those individually.
    </div>

    <h4 style="color:#4a9eff; margin:18px 0 6px;">Thresholds <span style="color:#888; font-weight:normal; font-size:12px;">(blank = not checked; a field can flag on low and/or high)</span></h4>
    <div style="overflow-x:auto;">
    <table style="width:100%; border-collapse:collapse; font-size:13px; min-width:560px;">
        <thead><tr style="color:#888; text-align:left;">
            <th style="padding:6px 8px;">On</th><th style="padding:6px 8px;">Field</th>
            <th style="padding:6px 8px;">Crit Low</th><th style="padding:6px 8px;">Warn Low</th>
            <th style="padding:6px 8px;">Warn High</th><th style="padding:6px 8px;">Crit High</th>
            <th style="padding:6px 8px;">Unit</th>
            <th style="padding:6px 8px;" title="When this rule hits CRITICAL, arm a pre-emptive capacity hold on the gen (reserve one gen's worth of headroom for the group).">Pre-empt</th>
        </tr></thead>
        <tbody id="gwThresholdRows"></tbody>
    </table>
    </div>

    <h4 style="color:#4a9eff; margin:18px 0 6px;">Controller Status Bits <span style="color:#888; font-weight:normal; font-size:12px;">(specific faults the controller flags; DSE Warning is the suppressed fleet-wide rollup)</span></h4>
    <div style="overflow-x:auto;">
    <table style="width:100%; border-collapse:collapse; font-size:13px; min-width:420px;">
        <thead><tr style="color:#888; text-align:left;">
            <th style="padding:6px 8px;">On</th><th style="padding:6px 8px;">Condition</th>
            <th style="padding:6px 8px;">Bit</th><th style="padding:6px 8px;">Severity</th>
        </tr></thead>
        <tbody id="gwStatusBitRows"></tbody>
    </table>
    </div>

    <div style="margin-top:15px; display:flex; align-items:center; gap:12px;">
        <button onclick="saveGenWarnings()" style="background-color:#28a745; color:white; border:none; padding:8px 16px; font-size:14px; border-radius:4px; cursor:pointer;">
            <i class="fas fa-save"></i> Save Warning Settings
        </button>
        <span id="gwSaveStatus" style="font-size:13px;"></span>
    </div>
</div>

    <script>
    // Pass dynamic data to window object for external JavaScript
    window.siteGroups = {
""")

# Generate JavaScript object with site-to-groups mapping
for site_name, site_data in sites.items():
    if site_name not in ["Out of Service", "Spares"]:
        groups_list = list(site_data['generator_groups'].keys())
        print(f'        "{site_name}": {json.dumps(groups_list)},')

print("""
    };

    window.siteGroupPods = {
""")

# Generate JavaScript object with full hierarchy for generator creation
for site_name, site_data in sites.items():
    if site_name not in ["Out of Service", "Spares"]:
        print(f'        "{site_name}": {{')
        for group_name, group_data in site_data['generator_groups'].items():
            pods_list = list(group_data['pods'].keys())
            print(f'            "{group_name}": {json.dumps(pods_list)},')
        print('        },')

print("""
    };
    
    // Hash of everything a save from this page would OVERWRITE. A config event
    // carrying this same value means the write can't collide with us — almost
    // always automation on per-gen fields, which the save handler re-reads from
    // disk anyway. See config_manager.site_manager_save_digest.
    window.SM_CONFIG_DIGEST = """ + json.dumps(_SM_DIGEST) + """;

    window.configData = """)

# Load the full config with misc section, strip passwords for client
try:
    with open(MASTER_CONFIG_FILE, 'r') as f:
        full_config = json.load(f)
    # Strip passwords from users before sending to browser
    if 'misc' in full_config and 'users' in full_config['misc']:
        for uname in full_config['misc']['users']:
            if 'password' in full_config['misc']['users'][uname]:
                del full_config['misc']['users'][uname]['password']
    print(json.dumps(full_config))
except:
    print("{}")

# Inject page keys for access control UI
sys.path.insert(0, '/var/www/html/ngon')
from links import PAGE_KEYS
print(f"""
    ;
    window.pageKeys = {json.dumps(PAGE_KEYS)};""")

# Inject gen-warning fleet defaults + field metadata so the settings section can
# render even before misc.gen_warnings has ever been saved.
try:
    from generators import gen_warnings as _gw
    print(f"""
    window.genWarningDefaults = {json.dumps(_gw.DEFAULT_RULES)};
    window.genFieldMeta = {json.dumps(_gw.GEN_FIELD_META)};""")
except Exception:
    print("""
    window.genWarningDefaults = {"enabled": true, "thresholds": {}, "status_bits": {}};
    window.genFieldMeta = {};""")

# Inject the Auto-Restart down-cause category catalog (category -> {codes, keywords})
# so the exclude checklist can render every cause and show the Mesa no-restart CODES
# folded into each one.
try:
    from generators import gen_fields as _gf_cat
    print(f"""
    window.AR_CATEGORY_CATALOG = {json.dumps(_gf_cat.category_catalog())};""")
except Exception:
    print("""
    window.AR_CATEGORY_CATALOG = {};""")

print("""
    </script>
    <script src="site_manager.js?v=""" + str(int(os.path.getmtime(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'site_manager.js')))) + """"></script>

    <script>
    // ===== Generator Warning Settings (self-contained; not part of site_manager.js) =====
    function gwEffectiveRules() {
        var d = window.genWarningDefaults || {enabled:true, thresholds:{}, status_bits:{}};
        var saved = (window.configData && window.configData.misc && window.configData.misc.gen_warnings) || null;
        function mergeMap(base, over) {
            var out = {};
            Object.keys(base||{}).forEach(function(k){ out[k] = Object.assign({}, base[k]); });
            if (over) Object.keys(over).forEach(function(k){ out[k] = Object.assign({}, (base&&base[k])||{}, over[k]); });
            return out;
        }
        return {
            enabled: saved && typeof saved.enabled === 'boolean' ? saved.enabled : (d.enabled !== false),
            thresholds: mergeMap(d.thresholds, saved && saved.thresholds),
            status_bits: mergeMap(d.status_bits, saved && saved.status_bits)
        };
    }

    function gwNumCell(cls, v) {
        var val = (v === null || v === undefined) ? '' : v;
        return '<td style="padding:6px 8px;"><input type="number" step="any" class="gw-th '+cls+'" value="'+val+'" '+
               'style="width:72px; background:#333; border:1px solid #555; color:#fff; padding:4px; border-radius:3px;"></td>';
    }

    function renderGenWarnings() {
        if (!document.getElementById('gwThresholdRows')) return;
        var eff = gwEffectiveRules();
        var meta = window.genFieldMeta || {};
        document.getElementById('gwEnabled').checked = eff.enabled !== false;

        var tb = document.getElementById('gwThresholdRows'); tb.innerHTML = '';
        Object.keys(eff.thresholds).forEach(function(field){
            var r = eff.thresholds[field];
            var unit = r.unit || (meta[field] && meta[field].unit) || '';
            var label = r.label || (meta[field] && meta[field].label) || field;
            var tr = document.createElement('tr');
            tr.style.borderTop = '1px solid rgba(255,255,255,0.07)';
            tr.setAttribute('data-field', field);
            tr.setAttribute('data-label', label);
            tr.setAttribute('data-unit', unit);
            tr.innerHTML =
                '<td style="padding:6px 8px;"><input type="checkbox" class="gw-th-en" '+(r.enabled!==false?'checked':'')+'></td>'+
                '<td style="padding:6px 8px; color:#ddd;">'+label+'</td>'+
                gwNumCell('gw-crit-low', r.crit_low) + gwNumCell('gw-warn-low', r.warn_low) +
                gwNumCell('gw-warn-high', r.warn_high) + gwNumCell('gw-crit-high', r.crit_high) +
                '<td style="padding:6px 8px; color:#888;">'+unit+'</td>'+
                '<td style="padding:6px 8px;"><input type="checkbox" class="gw-th-preempt" '+(r.preemptive?'checked':'')+' title="Arm a capacity hold when this rule hits critical"></td>';
            tb.appendChild(tr);
        });

        var sb = document.getElementById('gwStatusBitRows'); sb.innerHTML = '';
        Object.keys(eff.status_bits).sort(function(a,b){return a-b;}).forEach(function(bit){
            var r = eff.status_bits[bit];
            var sev = r.severity || 'warn';
            var label = r.label || ('Bit '+bit);
            var tr = document.createElement('tr');
            tr.style.borderTop = '1px solid rgba(255,255,255,0.07)';
            tr.setAttribute('data-bit', bit);
            tr.setAttribute('data-label', label);
            tr.innerHTML =
                '<td style="padding:6px 8px;"><input type="checkbox" class="gw-bit-en" '+(r.enabled!==false?'checked':'')+'></td>'+
                '<td style="padding:6px 8px; color:#ddd;">'+label+'</td>'+
                '<td style="padding:6px 8px; color:#888;">'+bit+'</td>'+
                '<td style="padding:6px 8px;"><select class="gw-bit-sev" style="background:#333; border:1px solid #555; color:#fff; padding:4px; border-radius:3px;">'+
                    '<option value="warn"'+(sev==='warn'?' selected':'')+'>warn</option>'+
                    '<option value="critical"'+(sev==='critical'?' selected':'')+'>critical</option>'+
                '</select></td>';
            sb.appendChild(tr);
        });
    }

    function saveGenWarnings() {
        var out = {enabled: document.getElementById('gwEnabled').checked, thresholds:{}, status_bits:{}};
        document.querySelectorAll('#gwThresholdRows tr').forEach(function(tr){
            function val(sel){ var el=tr.querySelector(sel); if(!el||el.value==='') return null; var n=parseFloat(el.value); return isNaN(n)?null:n; }
            out.thresholds[tr.getAttribute('data-field')] = {
                label: tr.getAttribute('data-label'), unit: tr.getAttribute('data-unit'),
                enabled: tr.querySelector('.gw-th-en').checked,
                crit_low: val('.gw-crit-low'), warn_low: val('.gw-warn-low'),
                warn_high: val('.gw-warn-high'), crit_high: val('.gw-crit-high'),
                preemptive: tr.querySelector('.gw-th-preempt').checked
            };
        });
        document.querySelectorAll('#gwStatusBitRows tr').forEach(function(tr){
            out.status_bits[tr.getAttribute('data-bit')] = {
                label: tr.getAttribute('data-label'),
                enabled: tr.querySelector('.gw-bit-en').checked,
                severity: tr.querySelector('.gw-bit-sev').value
            };
        });
        var status = document.getElementById('gwSaveStatus');
        status.textContent = 'Saving...'; status.style.color = '#888';
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_gen_warnings='+encodeURIComponent(JSON.stringify(out))})
            .then(function(r){return r.json();})
            .then(function(j){
                if (j.success) {
                    status.textContent = 'Saved ✓ (takes effect next 5-min sweep)'; status.style.color = '#00ff00';
                    if (!window.configData.misc) window.configData.misc = {};
                    window.configData.misc.gen_warnings = out;
                    setTimeout(function(){ status.textContent = ''; }, 4000);
                } else {
                    status.textContent = 'Error: ' + (j.error || 'unknown'); status.style.color = '#ff4444';
                }
            })
            .catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = '#ff4444'; });
    }

    // ===== Gen Control engine-action toggle (self-contained) =====
    function renderGenControl() {
        var el = document.getElementById('gcEngineActions');
        if (!el) return;
        var gc = (window.configData && window.configData.misc && window.configData.misc.gen_control) || {};
        el.checked = !!gc.enable_engine_actions;
    }

    function saveGenControl() {
        var on = document.getElementById('gcEngineActions').checked;
        var status = document.getElementById('gcSaveStatus');
        status.textContent = 'Saving...'; status.style.color = '#888';
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_gen_control='+encodeURIComponent(JSON.stringify({enable_engine_actions:on}))})
            .then(function(r){return r.json();})
            .then(function(j){
                if (j.success) {
                    status.textContent = on ? 'ENABLED ✓' : 'disabled ✓';
                    status.style.color = on ? '#ff6600' : '#00ff00';
                    if (!window.configData.misc) window.configData.misc = {};
                    window.configData.misc.gen_control = {enable_engine_actions:on};
                    setTimeout(function(){ status.textContent = ''; }, 4000);
                } else {
                    status.textContent = 'Error: ' + (j.error || 'unknown'); status.style.color = '#ff4444';
                }
            })
            .catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = '#ff4444'; });
    }

    // ===== Live Power Management settings (self-contained) =====
    function pmDefaults() {
        return {live_control_enabled:false, live_control_groups:[], live_fresh_sec:60,
                live_boot_wake_sec:120, live_wake_lockout_sec:300, live_wake_lockout_highload_sec:450,
                live_shed_lockout_sec:15, deadband_miners:2};
    }
    function renderPowerMgmt() {
        if (!document.getElementById('pmLiveEnabled')) return;
        var d = pmDefaults();
        var pm = (window.configData && window.configData.misc && window.configData.misc.power_mgmt) || {};
        function g(k){ return (pm[k] !== undefined && pm[k] !== null) ? pm[k] : d[k]; }
        document.getElementById('pmLiveEnabled').checked = !!g('live_control_enabled');
        var groups = g('live_control_groups') || [];
        document.getElementById('pmGroups').value = Array.isArray(groups) ? groups.join(', ') : '';
        document.getElementById('pmFresh').value = g('live_fresh_sec');
        document.getElementById('pmBoot').value = g('live_boot_wake_sec');
        document.getElementById('pmWakeLock').value = g('live_wake_lockout_sec');
        document.getElementById('pmWakeLockHi').value = g('live_wake_lockout_highload_sec');
        document.getElementById('pmShedLock').value = g('live_shed_lockout_sec');
        document.getElementById('pmDeadband').value = g('deadband_miners');
    }
    function savePowerMgmt() {
        var d = pmDefaults();
        function num(id, def){ var el=document.getElementById(id); var n=parseFloat(el.value); return isNaN(n)?def:n; }
        var raw = (document.getElementById('pmGroups').value || '').trim();
        var groups = raw ? raw.split(',').map(function(s){return s.trim();}).filter(Boolean) : [];
        var out = {
            live_control_enabled: document.getElementById('pmLiveEnabled').checked,
            live_control_groups: groups,
            live_fresh_sec: num('pmFresh', d.live_fresh_sec),
            live_boot_wake_sec: num('pmBoot', d.live_boot_wake_sec),
            live_wake_lockout_sec: num('pmWakeLock', d.live_wake_lockout_sec),
            live_wake_lockout_highload_sec: num('pmWakeLockHi', d.live_wake_lockout_highload_sec),
            live_shed_lockout_sec: num('pmShedLock', d.live_shed_lockout_sec),
            deadband_miners: parseInt(num('pmDeadband', d.deadband_miners), 10)
        };
        var status = document.getElementById('pmSaveStatus');
        status.textContent = 'Saving...'; status.style.color = '#888';
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_power_mgmt='+encodeURIComponent(JSON.stringify(out))})
            .then(function(r){return r.json();})
            .then(function(j){
                if (j.success) {
                    status.textContent = out.live_control_enabled ? 'LIVE ✓ (effective within ~5s)' : 'saved ✓ (Mesa mode)';
                    status.style.color = out.live_control_enabled ? '#00cc66' : '#00ff00';
                    if (!window.configData.misc) window.configData.misc = {};
                    window.configData.misc.power_mgmt = out;
                    setTimeout(function(){ status.textContent = ''; }, 4000);
                } else {
                    status.textContent = 'Error: ' + (j.error || 'unknown'); status.style.color = '#ff4444';
                }
            })
            .catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = '#ff4444'; });
    }

    // ===== Auto-Restart gate settings (self-contained) =====
    function arDefaults() {
        return {system_enabled:false, window:{start:'21:00', end:'05:00'}, just_ran_minutes:5, confirm_seconds:45,
                warmup:{mode:'minutes', minutes:5, target_f:160, max_minutes:15},
                exclude_categories:['knock','oil','speed','overtemp','coolant','ecu','estop','electrical','catalyst','breaker','start','frequency','voltage','ignition','emissions','scrubber'],
                gas:{block_on_category_gas:true, flow_min_mcfd:null},
                retrip:{window_minutes:15},
                circuit_breaker:{max_attempts:3, window_minutes:120, cooldown_minutes:120}};
    }
    var AR_HIDDEN_CATS = {gas:1, manual:1, estop:1};   // handled by their own gates
    var AR_LOCKED_CATS = {overload:1};                 // cascade victim, never excludable
    function renderArCatList(selected) {
        var host = document.getElementById('arCatList');
        if (!host) return;
        function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;'); }
        var cat = window.AR_CATEGORY_CATALOG || {};
        var sel = {}; (selected||[]).forEach(function(c){ sel[String(c).toLowerCase()]=1; });
        var names = Object.keys(cat).filter(function(c){ return !AR_HIDDEN_CATS[c]; }).sort();
        var html = '';
        names.forEach(function(c){
            var codes = (cat[c].codes || []);
            var locked = !!AR_LOCKED_CATS[c];
            var checked = (sel[c] && !locked) ? 'checked' : '';
            var dis = locked ? 'disabled' : '';
            var col = locked ? '#777' : '#ddd';
            var title = locked ? 'title="Overload is a cascade victim (the survivors of a cascade, not the culprit) — never excluded."' : '';
            var caret = codes.length
                ? '<span class="ar-cat-caret" style="cursor:pointer; color:#888; font-size:11px; margin-left:6px;" title="show the Mesa codes in this cause">&#9656; ' + codes.length + '</span>'
                : '<span style="color:#555; font-size:11px; margin-left:6px;">&mdash;</span>';
            var codeBox = codes.length
                ? '<div class="ar-cat-codes" style="display:none; margin:2px 0 6px 22px; color:#8fb3d9; font-size:11px; line-height:1.5;">' + codes.map(esc).join('<br>') + '</div>'
                : '';
            html += '<div style="padding:1px 0;"><label style="display:flex; align-items:center; gap:7px; color:' + col + ';" ' + title + '>' +
                    '<input type="checkbox" class="ar-cat-cb" data-cat="' + c + '" ' + checked + ' ' + dis + '> ' + c + caret + '</label>' + codeBox + '</div>';
        });
        host.innerHTML = html;
        host.onclick = function(e){
            var t = e.target;
            if (t && t.classList && t.classList.contains('ar-cat-caret')) {
                var box = t.parentNode.parentNode.querySelector('.ar-cat-codes');
                if (box) box.style.display = (box.style.display === 'none' ? 'block' : 'none');
            }
        };
    }
    function renderAutoRestart() {
        if (!document.getElementById('arWinStart')) return;
        var d = arDefaults();
        var ar = (window.configData && window.configData.misc && window.configData.misc.auto_restart) || {};
        var w = ar.window || d.window, wu = ar.warmup || d.warmup, gas = ar.gas || d.gas, cb = ar.circuit_breaker || d.circuit_breaker;
        document.getElementById('arSystemEnabled').checked = (ar.system_enabled === true);
        document.getElementById('arWinStart').value = w.start || d.window.start;
        document.getElementById('arWinEnd').value = w.end || d.window.end;
        document.getElementById('arJustRan').value = (ar.just_ran_minutes != null) ? ar.just_ran_minutes : d.just_ran_minutes;
        document.getElementById('arConfirm').value = (ar.confirm_seconds != null) ? ar.confirm_seconds : d.confirm_seconds;
        document.getElementById('arWarmMode').value = wu.mode || d.warmup.mode;
        document.getElementById('arWarmMin').value = (wu.minutes != null) ? wu.minutes : d.warmup.minutes;
        document.getElementById('arWarmTarget').value = (wu.target_f != null) ? wu.target_f : d.warmup.target_f;
        document.getElementById('arWarmMax').value = (wu.max_minutes != null) ? wu.max_minutes : d.warmup.max_minutes;
        var ex = ar.exclude_categories || d.exclude_categories;
        renderArCatList(Array.isArray(ex) ? ex : []);
        var rt = ar.retrip || d.retrip;
        document.getElementById('arRetripWin').value = (rt && rt.window_minutes != null) ? rt.window_minutes : d.retrip.window_minutes;
        document.getElementById('arGasBlock').checked = (gas.block_on_category_gas != null) ? !!gas.block_on_category_gas : true;
        document.getElementById('arFlowMin').value = (gas.flow_min_mcfd != null) ? gas.flow_min_mcfd : '';
        document.getElementById('arCbMax').value = (cb.max_attempts != null) ? cb.max_attempts : d.circuit_breaker.max_attempts;
        document.getElementById('arCbWin').value = (cb.window_minutes != null) ? cb.window_minutes : d.circuit_breaker.window_minutes;
        document.getElementById('arCbCool').value = (cb.cooldown_minutes != null) ? cb.cooldown_minutes : d.circuit_breaker.cooldown_minutes;
    }
    // Alert switches. Catalogue (keys + labels) is injected server-side from
    // notifications_api; current state comes from the config already in the page.
    function renderAlerts() {
        var box = document.getElementById('alertCatList');
        if (!box) return;
        var cats = window.ALERT_CATALOGUE || [];
        if (!cats.length) {
            box.innerHTML = '<span style="color:#ff6666;font-size:13px;">'
                + 'Category list unavailable &mdash; notifications API not reachable.</span>';
            return;
        }
        var state = (window.configData && window.configData.misc && window.configData.misc.alerts) || {};
        box.innerHTML = cats.map(function (c) {
            // Absent means enabled — same default the server applies.
            var on = (state[c.key] !== false);
            return '<label style="color:#ddd; font-size:13px; display:flex; align-items:center; gap:8px; cursor:pointer;">'
                 + '<input type="checkbox" class="alertCat" data-key="' + c.key + '"' + (on ? ' checked' : '') + '> '
                 + c.label + '</label>';
        }).join('');
    }

    function saveAlertSettings() {
        const out = {};
        document.querySelectorAll('#alertCatList .alertCat').forEach(cb => {
            out[cb.getAttribute('data-key')] = cb.checked;
        });
        const status = document.getElementById('alSaveStatus');
        if (status) { status.textContent = 'Saving...'; status.style.color = '#888'; }
        const fd = new FormData();
        fd.append('ajax_alerts', JSON.stringify(out));
        fetch(window.location.pathname, { method: 'POST', body: fd })
            .then(r => r.json())
            .then(d => {
                if (!status) return;
                if (d.success) {
                    const n = (d.disabled || []).length;
                    status.textContent = n ? ('Saved \u2014 ' + n + ' category(ies) silenced') : 'Saved \u2014 all alerts on';
                    status.style.color = '#00cc66';
                } else {
                    status.textContent = d.error || 'Save failed';
                    status.style.color = '#ff4444';
                }
                setTimeout(() => { if (status) status.textContent = ''; }, 4000);
            })
            .catch(e => {
                if (status) { status.textContent = 'Save failed: ' + e; status.style.color = '#ff4444'; }
            });
    }

    function saveAutoRestart() {
        var d = arDefaults();
        function num(id, def){ var el=document.getElementById(id); var n=parseFloat(el.value); return isNaN(n)?def:n; }
        var flowRaw = (document.getElementById('arFlowMin').value || '').trim();
        var exSel = Array.prototype.slice.call(document.querySelectorAll('#arCatList .ar-cat-cb'))
                        .filter(function(cb){ return cb.checked && !cb.disabled; })
                        .map(function(cb){ return cb.dataset.cat; });
        var out = {
            system_enabled: document.getElementById('arSystemEnabled').checked,
            window: {start: document.getElementById('arWinStart').value || d.window.start,
                     end: document.getElementById('arWinEnd').value || d.window.end},
            just_ran_minutes: num('arJustRan', d.just_ran_minutes),
            confirm_seconds: num('arConfirm', d.confirm_seconds),
            warmup: {mode: document.getElementById('arWarmMode').value || 'minutes',
                     minutes: num('arWarmMin', d.warmup.minutes),
                     target_f: num('arWarmTarget', d.warmup.target_f),
                     max_minutes: num('arWarmMax', d.warmup.max_minutes)},
            exclude_categories: exSel,
            gas: {block_on_category_gas: document.getElementById('arGasBlock').checked,
                  flow_min_mcfd: flowRaw ? parseFloat(flowRaw) : null},
            retrip: {window_minutes: num('arRetripWin', d.retrip.window_minutes)},
            circuit_breaker: {max_attempts: parseInt(num('arCbMax', d.circuit_breaker.max_attempts), 10),
                              window_minutes: num('arCbWin', d.circuit_breaker.window_minutes),
                              cooldown_minutes: num('arCbCool', d.circuit_breaker.cooldown_minutes)}
        };
        var status = document.getElementById('arSaveStatus');
        status.textContent = 'Saving...'; status.style.color = '#888';
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_auto_restart='+encodeURIComponent(JSON.stringify(out))})
            .then(function(r){return r.json();})
            .then(function(j){
                if (j.success) {
                    status.textContent = 'saved ✓ (effective within ~15s)'; status.style.color = '#00cc66';
                    if (!window.configData.misc) window.configData.misc = {};
                    window.configData.misc.auto_restart = out;
                    setTimeout(function(){ status.textContent = ''; }, 4000);
                } else {
                    status.textContent = 'Error: ' + (j.error || 'unknown'); status.style.color = '#ff4444';
                }
            })
            .catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = '#ff4444'; });
    }

    // ===== Chat commands (misc.chat_commands) =====
    // Mirrors DEFAULTS in /opt/ngon/apps/chat/actions.py -- the BACKEND is
    // authoritative; these are only what the inputs show before a save.
    function ccDefaults() {
        return {system_enabled:false, swap_enabled:false, move_enabled:false,
                order_enabled:false, ems_on_shutdown_request:false,
                poll_seconds:30, duplicate_window_seconds:3600, hint_enabled:true,
                hint_cooldown_seconds:3600, all_sites:false, sites:{}};
    }
    // Only sites that actually have a chat Space -- the service builds its poll
    // list from the same webhook URLs, so a site with no chat hook can never
    // produce a command and must not look as though it could.
    function ccChatSites() {
        var sites = (window.configData && window.configData.sites) || {};
        return Object.keys(sites).filter(function(s) {
            var h = (sites[s] && sites[s].webhooks && sites[s].webhooks.chat) || '';
            return h.indexOf('/spaces/') !== -1;
        }).sort();
    }
    function renderCcSites() {
        var box = document.getElementById('ccSiteList');
        if (!box) return;
        var cc = (window.configData && window.configData.misc && window.configData.misc.chat_commands) || {};
        var on = cc.sites || {};
        var all = document.getElementById('ccAllSites').checked;
        var names = ccChatSites();
        if (!names.length) { box.innerHTML = '<span style="color:#888; font-size:12px;">No sites have a chat Space configured.</span>'; return; }
        box.innerHTML = names.map(function(s) {
            var checked = (all || on[s] === true) ? ' checked' : '';
            var dis = all ? ' disabled' : '';
            return '<label style="display:flex; align-items:center; gap:7px; color:' + (all ? '#888' : '#ddd') + '; font-size:13px; cursor:' + (all ? 'default' : 'pointer') + ';">' +
                   '<input type="checkbox" class="cc-site-cb" data-site="' + s + '"' + checked + dis + '> ' + s + '</label>';
        }).join('');
    }
    function renderChatCommands() {
        if (!document.getElementById('ccPoll')) return;
        var d = ccDefaults();
        var cc = (window.configData && window.configData.misc && window.configData.misc.chat_commands) || {};
        document.getElementById('ccSystemEnabled').checked = (cc.system_enabled === true);
        document.getElementById('ccSwapEnabled').checked = (cc.swap_enabled === true);
        document.getElementById('ccMoveEnabled').checked = (cc.move_enabled === true);
        document.getElementById('ccOrderEnabled').checked = (cc.order_enabled === true);
        document.getElementById('ccEmsEnabled').checked = (cc.ems_on_shutdown_request === true);
        document.getElementById('ccHintEnabled').checked = (cc.hint_enabled != null) ? !!cc.hint_enabled : d.hint_enabled;
        document.getElementById('ccPoll').value = (cc.poll_seconds != null) ? cc.poll_seconds : d.poll_seconds;
        document.getElementById('ccDupWin').value = (cc.duplicate_window_seconds != null) ? cc.duplicate_window_seconds : d.duplicate_window_seconds;
        document.getElementById('ccHintCool').value = (cc.hint_cooldown_seconds != null) ? cc.hint_cooldown_seconds : d.hint_cooldown_seconds;
        document.getElementById('ccAllSites').checked = (cc.all_sites === true);
        renderCcSites();
    }
    function saveChatCommands() {
        var d = ccDefaults();
        function num(id, def) { var el = document.getElementById(id); var n = parseFloat(el.value); return isNaN(n) ? def : n; }
        var sites = {};
        Array.prototype.slice.call(document.querySelectorAll('#ccSiteList .cc-site-cb')).forEach(function(cb) {
            if (cb.checked && !cb.disabled) sites[cb.dataset.site] = true;
        });
        var allSites = document.getElementById('ccAllSites').checked;
        // Keep the stored per-site picks while "every site" is on, so unticking it
        // restores the pilot list instead of clearing it.
        if (allSites) {
            var prev = (window.configData && window.configData.misc && window.configData.misc.chat_commands && window.configData.misc.chat_commands.sites) || {};
            sites = prev;
        }
        var out = {
            system_enabled: document.getElementById('ccSystemEnabled').checked,
            swap_enabled: document.getElementById('ccSwapEnabled').checked,
            move_enabled: document.getElementById('ccMoveEnabled').checked,
            order_enabled: document.getElementById('ccOrderEnabled').checked,
            ems_on_shutdown_request: document.getElementById('ccEmsEnabled').checked,
            hint_enabled: document.getElementById('ccHintEnabled').checked,
            poll_seconds: num('ccPoll', d.poll_seconds),
            duplicate_window_seconds: num('ccDupWin', d.duplicate_window_seconds),
            hint_cooldown_seconds: num('ccHintCool', d.hint_cooldown_seconds),
            all_sites: allSites,
            sites: sites
        };
        var status = document.getElementById('ccSaveStatus');
        status.textContent = 'Saving...'; status.style.color = '#888';
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_chat_commands=' + encodeURIComponent(JSON.stringify(out))})
            .then(function(r){ return r.json(); })
            .then(function(j){
                if (j.success) {
                    status.textContent = 'saved \u2713 (effective next poll)'; status.style.color = '#00cc66';
                    if (!window.configData.misc) window.configData.misc = {};
                    window.configData.misc.chat_commands = out;
                    setTimeout(function(){ status.textContent = ''; }, 4000);
                } else {
                    status.textContent = 'Error: ' + (j.error || 'unknown'); status.style.color = '#ff4444';
                }
            })
            .catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = '#ff4444'; });
    }

    // ===== Gas-pressure hold defaults (self-contained) =====
    // Mirrors gas_hold.DEFAULTS in /opt/ngon/apps/generators/gas_hold.py — the
    // BACKEND is authoritative; these are only what the inputs show before the
    // block has ever been saved. Keep the two in step.
    function ghDefaults() {
        return {watch_psi:18, hold1_psi:16, hold2_psi:14, release_margin_psi:1,
                release_minutes:30, confirm_samples:2, min_live_gens:2, stale_minutes:45,
                hold1_reserve:1, hold2_reserve:2};
    }
    function renderGasHold() {
        if (!document.getElementById('ghWatch')) return;
        var d = ghDefaults();
        var gh = (window.configData && window.configData.misc && window.configData.misc.gas_hold) || {};
        var v = gh.defaults || {};
        document.getElementById('ghSystemEnabled').checked = (gh.system_enabled === true);
        var map = {ghWatch:'watch_psi', ghHold1:'hold1_psi', ghHold2:'hold2_psi',
                   ghRelMargin:'release_margin_psi', ghRelMin:'release_minutes',
                   ghConfirm:'confirm_samples', ghMinGens:'min_live_gens', ghStale:'stale_minutes',
                   ghRes1:'hold1_reserve', ghRes2:'hold2_reserve'};
        Object.keys(map).forEach(function(id) {
            var k = map[id];
            document.getElementById(id).value = (v[k] != null) ? v[k] : d[k];
        });
    }
    function saveGasHold() {
        var d = ghDefaults();
        function num(id, def){ var el=document.getElementById(id); var n=parseFloat(el.value); return isNaN(n)?def:n; }
        var out = {
            system_enabled: document.getElementById('ghSystemEnabled').checked,
            defaults: {
                watch_psi: num('ghWatch', d.watch_psi),
                hold1_psi: num('ghHold1', d.hold1_psi),
                hold2_psi: num('ghHold2', d.hold2_psi),
                release_margin_psi: num('ghRelMargin', d.release_margin_psi),
                release_minutes: num('ghRelMin', d.release_minutes),
                hold1_reserve: parseInt(num('ghRes1', d.hold1_reserve), 10),
                hold2_reserve: parseInt(num('ghRes2', d.hold2_reserve), 10),
                confirm_samples: parseInt(num('ghConfirm', d.confirm_samples), 10),
                min_live_gens: parseInt(num('ghMinGens', d.min_live_gens), 10),
                stale_minutes: num('ghStale', d.stale_minutes)
            }
        };
        var status = document.getElementById('ghSaveStatus');
        status.textContent = 'Saving...'; status.style.color = '#888';
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_gas_hold='+encodeURIComponent(JSON.stringify(out))})
            .then(function(r){return r.json();})
            .then(function(j){
                if (j.success) {
                    status.textContent = 'saved \u2713 (effective within ~15s)'; status.style.color = '#00cc66';
                    if (!window.configData.misc) window.configData.misc = {};
                    window.configData.misc.gas_hold = out;
                    renderGasHold();
                    setTimeout(function(){ status.textContent = ''; }, 4000);
                } else {
                    status.textContent = 'Error: ' + (j.error || 'unknown'); status.style.color = '#ff4444';
                }
            })
            .catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = '#ff4444'; });
    }

    // ===== Dead-miner scoring weights (self-contained) =====
    function dmDefaults() {
        return {w_reboots:40, w_hw_error:25, w_zero_hash:20, w_unstable:15,
                reboots_full:30, pull_threshold:75, score_floor:25,
                never_hashed_reboots:10, auto_min_days:3,
                hw_error_cats:['hashboard','fan','temp_sensor','overtemp','power'],
                no_hash_days:30, use_pod_gate:true, pod_active_frac:0.5,
                window_days:7, min_samples_per_day:24,
                short_uptime:1800, real_hash_th:100};
    }
    var DM_FIELDS = {
        w_reboots:'dmWReboots', w_hw_error:'dmWHw', w_zero_hash:'dmWZero',
        w_unstable:'dmWUnstable', reboots_full:'dmRebootsFull',
        pull_threshold:'dmPull', score_floor:'dmFloor',
        never_hashed_reboots:'dmNeverReboots', auto_min_days:'dmAutoMinDays',
        no_hash_days:'dmNoHashDays', pod_active_frac:'dmPodFrac',
        window_days:'dmWindowDays',
        min_samples_per_day:'dmMinSamples', short_uptime:'dmShortUptime',
        real_hash_th:'dmRealHash'
    };
    function renderDeadMiner() {
        if (!document.getElementById('dmWReboots')) return;
        var d = dmDefaults();
        var dm = (window.configData && window.configData.misc && window.configData.misc.dead_miner) || {};
        Object.keys(DM_FIELDS).forEach(function(k){
            var el = document.getElementById(DM_FIELDS[k]);
            if (el) el.value = (dm[k] != null) ? dm[k] : d[k];
        });
        var cats = dm.hw_error_cats || d.hw_error_cats;
        document.getElementById('dmHwCats').value = Array.isArray(cats) ? cats.join(', ') : '';
        document.getElementById('dmPodGate').checked = (dm.use_pod_gate != null) ? !!dm.use_pod_gate : d.use_pod_gate;
        dmPreview();
    }
    function dmCollect() {
        var d = dmDefaults(), out = {};
        Object.keys(DM_FIELDS).forEach(function(k){
            var el = document.getElementById(DM_FIELDS[k]);
            var n = el ? parseFloat(el.value) : NaN;
            out[k] = isNaN(n) ? d[k] : n;
        });
        var raw = (document.getElementById('dmHwCats').value || '').trim();
        out.hw_error_cats = raw ? raw.split(',').map(function(s){return s.trim().toLowerCase();}).filter(Boolean) : [];
        out.use_pod_gate = document.getElementById('dmPodGate').checked;
        return out;
    }
    // Debounced so dragging a number input doesn't fire a request per keystroke.
    var dmPvTimer = null;
    function dmPreview() {
        if (dmPvTimer) clearTimeout(dmPvTimer);
        dmPvTimer = setTimeout(dmPreviewNow, 250);
    }
    function dmPreviewNow() {
        var body = dmCollect();
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_dead_preview='+encodeURIComponent(JSON.stringify(body))})
            .then(function(r){return r.json();})
            .then(function(j){
                if (!j.success) { document.getElementById('dmPvMeta').textContent = 'preview error: '+(j.error||''); return; }
                var p = j.preview, s = j.saved;
                document.getElementById('dmPvDead').textContent = p.dead;
                document.getElementById('dmPvSuspect').textContent = p.suspect;
                document.getElementById('dmPvDropped').textContent = p.dropped;
                var dd = p.dead - s.dead;
                var el = document.getElementById('dmPvDelta');
                if (dd === 0) { el.textContent = 'same as saved settings'; el.style.color = '#888'; }
                else { el.textContent = (dd > 0 ? '+' : '') + dd + ' vs saved (' + s.dead + ' dead)';
                       el.style.color = dd > 0 ? '#ff8888' : '#88cc88'; }
                var when = p.extracted_at ? p.extracted_at.replace('T',' ').slice(0,16) : 'never';
                document.getElementById('dmPvMeta').textContent = p.total + ' miners in store · extracted ' + when;
            })
            .catch(function(e){ document.getElementById('dmPvMeta').textContent = 'preview error: '+e; });
    }
    function saveDeadMiner() {
        var out = dmCollect();
        var status = document.getElementById('dmSaveStatus');
        if (out.score_floor > out.pull_threshold) {
            status.textContent = 'Score floor cannot exceed the pull threshold'; status.style.color = '#ff4444';
            return;
        }
        status.textContent = 'Saving...'; status.style.color = '#888';
        fetch('site_manager.py', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
            body:'ajax_dead_miner='+encodeURIComponent(JSON.stringify(out))})
            .then(function(r){return r.json();})
            .then(function(j){
                if (j.success) {
                    status.textContent = 'saved ✓ (fleet re-scored on next page load)'; status.style.color = '#00cc66';
                    if (!window.configData.misc) window.configData.misc = {};
                    window.configData.misc.dead_miner = j.saved || out;
                    dmPreview();
                    setTimeout(function(){ status.textContent = ''; }, 4000);
                } else {
                    status.textContent = 'Error: ' + (j.error || 'unknown'); status.style.color = '#ff4444';
                }
            })
            .catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = '#ff4444'; });
    }

    renderGenControl();
    renderGenWarnings();
    renderPowerMgmt();
    renderAutoRestart();
    renderChatCommands();
    renderAlerts();
    renderGasHold();
    renderDeadMiner();
    </script>

<!-- Miner Type Selection Modal -->
<div id="minerTypeModal" class="modal">
    <div class="modal-content">
        <h3 id="modalTitle">Select Miner Type</h3>
        <p id="modalPodName"></p>
        <select id="minerTypeSelect">
            <option value="M60">M60</option>
        </select>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="saveMinerType()">Save</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeMinerTypeModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Text Input Modal -->
<div id="textInputModal" class="modal">
    <div class="modal-content">
        <h3 id="textModalTitle">Edit</h3>
        <p id="textModalSubtitle"></p>
        <input type="text" id="textModalInput" placeholder="Enter value...">
        <textarea id="textModalTextarea" placeholder="Enter text..." style="display: none;"></textarea>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="saveTextInput()">Save</button>
            <button class="modal-btn" onclick="deleteCurrentEntity()" style="background: #dc2626; color: white;" id="deleteEntityBtn">Delete</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeTextInputModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Create Pod Modal -->
<div id="createPodModal" class="modal">
    <div class="modal-content">
        <h3>Create New Pod</h3>
        <div style="margin-bottom: 15px;">
            <label>Site:</label>
            <select id="podSiteSelect" style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;" onchange="updateGroupsForPod()">
                <option value="">Select a site...</option>
            </select>
        </div>
        <div style="margin-bottom: 15px;">
            <label>Group:</label>
            <select id="podGroupSelect" style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
                <option value="">Select a group...</option>
            </select>
        </div>
        <div style="margin-bottom: 15px;">
            <label>Pod Name:</label>
            <input type="text" id="podNameInput" placeholder="Enter pod name..." style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="createPodFromModal()">Create</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeCreatePodModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Create Generator Modal -->
<div id="createGeneratorModal" class="modal">
    <div class="modal-content">
        <h3>Create New Generator</h3>
        <div style="margin-bottom: 15px;">
            <label>Generator ID:</label>
            <input type="text" id="genIdInput" placeholder="Enter generator ID..." style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
        </div>
        <div style="margin-bottom: 15px;">
            <label>Site:</label>
            <select id="genSiteSelect" style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;" onchange="updateGroupsForGenerator()">
                <option value="">Select a site...</option>
                <option value="Out of Service">Out of Service</option>
                <option value="Spares">Spares</option>
            </select>
        </div>
        <div style="margin-bottom: 15px;">
            <label>Group:</label>
            <select id="genGroupSelect" style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
                <option value="">Select a group...</option>
            </select>
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="createGeneratorFromModal()">Create</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeCreateGeneratorModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Create Group Modal -->
<div id="createGroupModal" class="modal">
    <div class="modal-content">
        <h3>Create New Group</h3>
        <div style="margin-bottom: 15px;">
            <label>Site:</label>
            <select id="groupSiteSelect" style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
                <option value="">Select a site...</option>
            </select>
        </div>
        <div style="margin-bottom: 15px;">
            <label>Group Name:</label>
            <input type="text" id="groupNameInput" placeholder="Enter group name..." style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="createGroupFromModal()">Create</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeCreateGroupModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Create Site Modal -->
<div id="createSiteModal" class="modal">
    <div class="modal-content">
        <h3>Create New Site</h3>
        <div style="margin-bottom: 15px;">
            <label>Site Name:</label>
            <input type="text" id="siteNameInput" placeholder="Enter site name..." style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
        </div>
        <div style="margin-bottom: 15px;">
            <label>Site ID:</label>
            <input type="text" id="siteIdInput" placeholder="e.g., CSITE-999..." style="width: 100%; background: #333; border: 1px solid #555; color: white; padding: 8px; margin-top: 5px;">
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="createSiteFromModal()">Create</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeCreateSiteModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Peplink Configuration Modal -->
<div id="peplinkModal" class="modal">
    <div class="modal-content">
        <h3 id="peplinkModalTitle">Configure Peplink</h3>
        <p id="peplinkModalSubtitle"></p>
        <div style="margin: 20px 0;">
            <label for="peplinkGroupId" style="display: block; margin-bottom: 5px; color: #00ff00;">Group ID:</label>
            <input type="number" id="peplinkGroupId" placeholder="Enter group ID..." style="width: 100%; padding: 10px; margin-bottom: 15px; background: #333; border: 1px solid #555; color: white; border-radius: 5px;">
            
            <label for="peplinkDeviceId" style="display: block; margin-bottom: 5px; color: #00ff00;">Device ID:</label>
            <input type="number" id="peplinkDeviceId" placeholder="Enter device ID..." style="width: 100%; padding: 10px; margin-bottom: 15px; background: #333; border: 1px solid #555; color: white; border-radius: 5px;">
            
            <label for="peplinkRouterIp" style="display: block; margin-bottom: 5px; color: #00ff00;">Router IP:</label>
            <input type="text" id="peplinkRouterIp" placeholder="Enter router IP (e.g., 10.4.1.1)..." style="width: 100%; padding: 10px; margin-bottom: 15px; background: #333; border: 1px solid #555; color: white; border-radius: 5px;">
            
            <label style="display: flex; align-items: center; margin-bottom: 10px; color: #00ff00;">
                <input type="checkbox" id="peplinkDisplayChildren" style="margin-right: 10px; transform: scale(1.2);">
                Display Children (show associated pods)
            </label>
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="savePeplink()">Save</button>
            <button class="modal-btn" onclick="deletePeplink()" style="background: #dc2626; color: white;">Delete</button>
            <button class="modal-btn modal-btn-cancel" onclick="closePeplinkModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Master Switch Configuration Modal -->
<div id="switchModal" class="modal">
    <div class="modal-content">
        <h3 id="switchModalTitle">Configure Master Switch</h3>
        <p id="switchModalSubtitle"></p>
        <div style="margin: 20px 0;">
            <label for="switchIp" style="display: block; margin-bottom: 5px; color: #8B5CF6;">Switch IP:</label>
            <input type="text" id="switchIp" placeholder="Enter switch IP (e.g., 10.4.1.100)..." style="width: 100%; padding: 10px; margin-bottom: 15px; background: #333; border: 1px solid #555; color: white; border-radius: 5px;">
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="saveSwitch()">Save</button>
            <button class="modal-btn" onclick="deleteSwitchData()" style="background: #dc2626; color: white;">Delete</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeSwitchModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Power Target Modal -->
<div id="powerTargetModal" class="modal">
    <div class="modal-content">
        <h3 id="powerTargetModalTitle">Power Target</h3>
        <p id="powerTargetModalSubtitle"></p>
        <div style="margin: 20px 0;">
            <label for="powerTargetWatts" style="display: block; margin-bottom: 5px; color: #ffaa00;">Watts per miner:</label>
            <input type="number" id="powerTargetWatts" placeholder="e.g. 3000" min="0" style="width: 100%; padding: 10px; margin-bottom: 10px; background: #333; border: 1px solid #555; color: white; border-radius: 5px;">
            <p style="color: #888; font-size: 12px; margin: 0;">Set to 0 or leave empty for unmanaged (no power limit).</p>
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="savePowerTarget()">Save</button>
            <button class="modal-btn" onclick="clearPowerTarget()" style="background: #dc2626; color: white;">Clear</button>
            <button class="modal-btn modal-btn-cancel" onclick="closePowerTargetModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Max Gen kW Modal -->
<div id="maxGenKwModal" class="modal">
    <div class="modal-content">
        <h3 id="maxGenKwModalTitle">Max Generator kW</h3>
        <p id="maxGenKwModalSubtitle"></p>
        <div style="margin: 20px 0;">
            <label for="maxGenKwValue" style="display: block; margin-bottom: 5px; color: #ffaa00;">kW per generator:</label>
            <input type="number" id="maxGenKwValue" placeholder="e.g. 330" min="0" step="1" style="width: 100%; padding: 10px; margin-bottom: 10px; background: #333; border: 1px solid #555; color: white; border-radius: 5px;">
            <p style="color: #888; font-size: 12px; margin: 0;">Max safe kW capacity per generator in this group.</p>
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="saveMaxGenKw()">Save</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeMaxGenKwModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Generator Edit Modal -->
<div id="generatorModal" class="modal">
    <div class="modal-content" style="width: 400px;">
        <h3>Edit Generator</h3>
        <p id="genModalSubtitle"></p>

        <label for="genModalMac" style="display: block; margin-bottom: 5px; color: #00ff00;">MAC Address:</label>
        <input type="text" id="genModalMac" placeholder="E8A4C1143E33"
               style="width: 100%; padding: 10px; margin-bottom: 15px; background: #333; border: 1px solid #555;
                      color: white; border-radius: 5px; font-family: monospace; text-transform: uppercase;"
               maxlength="12" pattern="[A-Fa-f0-9]{12}">

        <label for="genModalBridgeMac" style="display: block; margin-bottom: 5px; color: #00ff00;">Bridge MAC (MikroTik):</label>
        <input type="text" id="genModalBridgeMac" placeholder="04F41C8AC14A"
               style="width: 100%; padding: 10px; margin-bottom: 8px; background: #333; border: 1px solid #555;
                      color: white; border-radius: 5px; font-family: monospace; text-transform: uppercase;"
               maxlength="17">
        <p style="color: #888; font-size: 11px; margin: 0 0 15px;">
            Wifi MAC of the gen's MikroTik bridge (from Peplink / InControl). Colons optional &mdash; stored without.
        </p>

        <p style="color: #888; font-size: 12px; margin-bottom: 15px;">
            Use <a href="/status/gen_manager.py" style="color: #4a9eff;">Generator Manager</a> for notes, status, and moves.
        </p>

        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="saveGenerator()">Save</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeGeneratorModal()">Cancel</button>
        </div>
    </div>
</div>

<!-- Network Configuration Modal -->
<div id="networkModal" class="modal">
    <div class="modal-content">
        <h3 id="networkModalTitle">Configure Network</h3>
        <p id="networkModalSubtitle"></p>
        <div style="margin: 20px 0;">
            <label for="networkCidr" style="display: block; margin-bottom: 5px; color: #0080ff;">Network CIDR:</label>
            <input type="text" id="networkCidr" placeholder="Enter network CIDR (e.g., 10.1.1.0/24)..." style="width: 100%; padding: 10px; margin-bottom: 15px; background: #333; border: 1px solid #555; color: white; border-radius: 5px;">
            <small style="color: #888; font-size: 12px;">Examples: 10.1.1.0/24, 192.168.1.0/24, 172.16.0.0/22</small>
        </div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="saveNetwork()">Save</button>
            <button class="modal-btn" onclick="deleteNetwork()" style="background: #dc2626; color: white;">Delete</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeNetworkModal()">Cancel</button>
        </div>
    </div>
</div>


</div> <!-- Close mainContent -->

</body>
</html>""")
