#!/usr/bin/env python3
import cgi, sys
sys.path.insert(0, '/opt/ngon/apps')
from managers.auth_manager import AuthManager, generate_login_page_html

_form = cgi.FieldStorage()
_auth = AuthManager('dead')
_auth_required, _should_exit, _headers = _auth.require_auth(_form)

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

if _auth_required:
    print("Content-Type: text/html")
    print("")
    print(generate_login_page_html("Dead Miner Hunter"))
    sys.exit(0)


"""
Dead Miner Hunter - Redesigned Interface
File: /var/www/html/ngon/nmm/dead.py

Identifies miners with errors and presents them in a filterable table view.
"""

import pandas as pd
import json
import os
import sys
from html import escape
from datetime import datetime, timedelta

sys.path.insert(0, '/opt/ngon/apps')
from managers.config_manager import config
from miners.error_categories import category_for

# Scoring logic + the DeadMinerHunter class live in the shared backend module
# so this page, the signal-extraction cron and the inventory-facing API all
# score from ONE implementation with ONE config. Tunables come from
# master_config -> misc.dead_miner (Site Manager panel); see dead_hunter.py.
from miners.dead_hunter import (          # noqa: E402
    DeadMinerHunter,
    load_config as load_dead_config,
    score_signals,
    score_store,
)

_DEAD_CFG = load_dead_config()
DEAD_PULL_THRESHOLD = _DEAD_CFG['pull_threshold']
DEAD_SCORE_FLOOR = _DEAD_CFG['score_floor']



def build_miners_json(hunter, miners):
    """Build JSON data for miners (with errors, or otherwise notable)."""
    miners_json = []
    for miner in miners:
        raw_mac = str(miner.get('mac', ''))
        mac = raw_mac.strip().upper() if raw_mac else ''
        serial = str(miner.get('serial', 'Unknown'))
        last5 = serial[-5:] if len(serial) >= 5 else serial
        cd = hunter.confirmed_dead.get(mac, {}) if mac else {}
        has_hashboard_err = any(category_for(e) == 'hashboard' for e in miner.get('errors', []))

        miners_json.append({
            'mac': mac,
            'ip': str(miner.get('ip', '')),
            'pod': miner.get('pod', 'Unknown'),
            'position': str(miner.get('position', 'Unknown')),
            'serial': serial,
            'serial_short': last5,
            'site': str(miner.get('site', 'Unknown')),
            'hashrate': miner.get('hashrate', 0),
            'errors': miner.get('errors', []),
            'reboots': hunter.get_reboot_count(raw_mac),
            'reboots_life': hunter.get_lifetime_reboot_count(raw_mac),
            'dead_score': cd.get('score', 0),
            'dead_needs_setup': cd.get('needs_setup', False),
            'dead_detail': cd,
            'has_hashboard_error': has_hashboard_err,
            'is_sleeping': bool(mac and mac in hunter.sleeping_miners),
        })
    return miners_json


def main():
    # AJAX entry point: lazy-load dead-confidence scores when the user clicks
    # the pill. compute_confirmed_dead loads its own 30d reboot data, so the
    # full reboot InfluxDB pull is skipped here.
    action = _form.getvalue('action') if '_form' in globals() else None
    if action == 'confirmed_dead':
        # Scores come from the persisted signal store, scored with the weights
        # configured right now — milliseconds, versus the ~90 s live history
        # scan this used to do. The cron (dead_signals_job.py) owns the
        # expensive extraction. If the store is missing (first deploy, or a
        # failed cron), fall back to computing live so the page still works.
        records, extracted_at = score_store()
        used_store = bool(records) or extracted_at is not None

        if not used_store:
            hunter = DeadMinerHunter(skip_reboots=True)
            hunter.compute_confirmed_dead()
            if not hunter.lifetime_reboot_counts and hunter.influx_query_api:
                hunter._query_reboots('0', hunter.lifetime_reboot_counts)
            records = []
            for mac, info in hunter.confirmed_dead.items():
                sig = hunter.signals.get(mac, {})
                records.append({
                    'mac': mac, 'pod': sig.get('pod', 'Unknown'),
                    'site': sig.get('site', 'Unknown'),
                    'position': sig.get('position', 'Unknown'),
                    'serial': sig.get('serial', 'Unknown'),
                    'hashrate': sig.get('hs_rt'),
                    'mining_state': sig.get('mining_state'),
                    'offline': sig.get('offline', False),
                    'errors': sig.get('errors', []),
                    'reboots_life': hunter.get_lifetime_reboot_count(mac),
                    'score': info.get('score', 0),
                    'tier': info.get('tier', 'low'),
                    'signals': {'reboots_30d': info.get('reboots_30d', 0),
                                'last_hash': info.get('last_hash')},
                    'detail': info,
                })

        # Live IP + sleep state aren't in the store (they change every ~30 s);
        # join them from the current scan so the table's IP column and the blue
        # sleeping-row tint still work.
        ip_by_mac, sleeping = {}, set()
        try:
            df_live = pd.read_csv('/opt/ngon/data/live/miner_status.csv')
            for r in df_live.itertuples(index=False):
                m = str(getattr(r, 'mac', '') or '').strip().upper()
                if not m:
                    continue
                ip_by_mac[m] = str(getattr(r, 'ip', '') or '')
                if str(getattr(r, 'mining_state', '') or '').lower() == 'sleeping':
                    sleeping.add(m)
        except Exception:
            pass

        # Reshape the store's records into the field names the page's JS uses.
        out = []
        for rec in records:
            mac = rec['mac']
            info = rec.get('detail', {})
            serial = str(rec.get('serial') or 'Unknown')
            errs = rec.get('errors', []) or []
            out.append({
                'mac': mac,
                'ip': ip_by_mac.get(mac, ''),
                'pod': rec.get('pod') or 'Unknown',
                'position': str(rec.get('position') or 'Unknown'),
                'serial': serial,
                'serial_short': serial[-5:] if len(serial) >= 5 else serial,
                'site': str(rec.get('site') or 'Unknown'),
                'hashrate': rec.get('hashrate'),
                'errors': errs,
                'reboots': rec.get('signals', {}).get('reboots_30d', 0),
                'reboots_life': rec.get('reboots_life', 0),
                'offline': rec.get('offline', False),
                'has_hashboard_error': any(category_for(e) == 'hashboard' for e in errs),
                'is_sleeping': mac in sleeping,
                'dead_score': rec.get('score', 0),
                'dead_needs_setup': info.get('needs_setup', False),
                'last_hash': rec.get('signals', {}).get('last_hash'),
                'dead_detail': info,
            })

        # Pill count is pull candidates only; records carry the full
        # amber-and-up set so their gauges render too.
        pull_count = sum(1 for r in out if r['dead_score'] >= DEAD_PULL_THRESHOLD)
        print("Content-Type: application/json\n")
        print(json.dumps({'dead_miners': out, 'count': pull_count,
                          'extracted_at': extracted_at,
                          'source': 'store' if used_store else 'live'}))
        return

    hunter = DeadMinerHunter()
    active_miners = hunter.get_active_miners()
    # Confirmed-dead is not pre-computed on page load; the table only shows
    # miners-with-errors (active_miners) + offline. Confirmed Dead miners that
    # would otherwise be invisible (zero-hash-no-errors, sleeping) get merged in
    # client-side after the user clicks the Confirmed Dead pill.
    table_miners = active_miners

    # Build error info
    all_errors = set()
    for miner in active_miners:
        all_errors.update(miner['errors'])

    error_info = []
    for error_code in sorted(list(all_errors)):
        info = hunter.get_error_info(error_code)
        miner_count = sum(1 for miner in active_miners if error_code in miner['errors'])
        error_info.append({
            'code': error_code,
            'meaning': info['meaning'],
            'solution': info['solution'],
            'count': miner_count,
            'category': category_for(error_code) or 'uncategorized',
        })

    # Build pod list with counts (use union: active errors + extra confirmed-dead)
    pod_counts = {}
    for miner in table_miners:
        pod = miner.get('pod', 'Unknown')
        pod_counts[pod] = pod_counts.get(pod, 0) + 1

    # Build pod ordering from master_config (site order, then pod order within each site)
    pod_order = []
    try:
        import json as _json
        with open('/opt/ngon/config/master_config.json') as f:
            cfg = _json.load(f)
        for site_name, site_data in cfg.get('sites', {}).items():
            for group_name, group_data in site_data.get('generator_groups', {}).items():
                for pod_name in group_data.get('pods', {}):
                    pod_order.append(pod_name)
    except Exception:
        pod_order = sorted(pod_counts.keys())

    # All error codes from CSV for keyword matching
    all_error_details = {code: info for code, info in hunter.error_codes.items()}
    for e in error_info:
        if e['code'] not in all_error_details:
            all_error_details[e['code']] = {'meaning': e['meaning'], 'solution': e['solution']}

    miners_json = build_miners_json(hunter, table_miners)
    zero_hash_with_errors = hunter.get_zero_hash_with_errors_count()
    zero_hash_no_errors = hunter.get_zero_hash_no_errors_count()

    # Synthesize offline-miner records and merge into the same JSON the table consumes.
    offline_json = []
    for o in hunter.offline_miners:
        serial = o.get('serial') or ''
        last5 = serial[-5:] if len(serial) >= 5 else serial
        mac_u = (o.get('mac', '') or '').upper()
        cd = hunter.confirmed_dead.get(mac_u, {})
        offline_json.append({
            'mac': o.get('mac', ''),
            'ip': '',
            'pod': o.get('pod', 'Unknown'),
            'position': o.get('position', ''),
            'serial': serial or 'Unknown',
            'serial_short': last5 or '',
            'site': o.get('site', 'Unknown'),
            'hashrate': None,
            'errors': [],
            'reboots': hunter.get_reboot_count(o.get('mac', '')),
            'reboots_life': hunter.get_lifetime_reboot_count(o.get('mac', '')),
            'offline': True,
            'dead_score': cd.get('score', 0),
            'dead_needs_setup': cd.get('needs_setup', False),
            'dead_detail': cd,
            'has_hashboard_error': False,
            'is_sleeping': False,
        })

    # Nav imports
    sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    from links import generate_dropdown_css, generate_dropdown_js, generate_dropdown_html

    print("Content-Type: text/html\n")
    print(f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dead Miner Hunter</title>
    <link rel="stylesheet" href="/shared/miner_view.css">
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
    <script src="/shared/miner_view.js"></script>
    <style>
        {generate_dropdown_css()}
    </style>
    <style>
        * {{ box-sizing: border-box; }}
        body {{
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: #1a1a1a; color: #e0e0e0; min-height: 100vh;
            margin: 0; padding: 0; line-height: 1.4;
        }}

        /* Floating-box header (matches status/gen_manager) */
        .header {{
            background-color: rgba(255,255,255,0.05) !important;
            border: 1px solid rgba(255,255,255,0.1) !important;
            border-radius: 8px !important;
            padding: 30px !important;
            margin: 20px !important;
            border-bottom: none !important;
        }}
        .header h1.dropdown-title {{
            font-size: 2.2em !important;
            font-weight: 600 !important;
            margin: 3px 0 0 0 !important;
            line-height: 1.2 !important;
            color: #00ff00 !important;
        }}
        .header p {{ color: #888; margin-top: 6px; font-size: 14px; }}

        .stats-row {{
            display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px;
        }}
        .stat-badge {{
            padding: 15px 25px; border-radius: 6px; font-size: 1rem; font-weight: 600;
            background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
        }}
        .stat-badge.critical {{ background: rgba(255,0,0,0.15); border-color: rgba(255,0,0,0.3); }}
        .stat-badge.warning {{ background: rgba(255,102,0,0.15); border-color: rgba(255,102,0,0.3); }}
        .stat-badge.dead {{ background: rgba(170,0,0,0.35); border-color: rgba(255,80,80,0.6); color: #fff; }}
        .stat-badge.clickable {{ cursor: pointer; transition: filter 0.15s; }}
        .stat-badge.clickable:hover {{ filter: brightness(1.2); }}
        .stat-badge.dead.active {{ background: rgba(220,0,0,0.55); border-color: #ff4444; }}

        .layout {{
            display: flex; height: calc(100vh - 160px); overflow: hidden;
        }}

        /* --- Sidebar --- */
        .sidebar {{
            width: 440px; min-width: 440px; background: #1a1a1a; border-right: 1px solid #2a2a2a;
            display: flex; flex-direction: column; overflow: hidden;
        }}
        .sidebar-section {{
            padding: 12px 14px; border-bottom: 1px solid #2a2a2a;
        }}
        .sidebar-section h3 {{
            font-size: 0.85rem; text-transform: uppercase; color: #888; letter-spacing: 0.5px; margin-bottom: 8px;
        }}
        .filter-pills {{
            display: flex; flex-wrap: wrap; gap: 4px;
        }}
        .pill {{
            padding: 6px 12px; border-radius: 4px; font-size: 0.85rem; cursor: pointer;
            background: #2a2a2a; border: 1px solid #3a3a3a; color: #ccc; transition: all 0.15s;
            user-select: none;
        }}
        .pill.active {{
            background: rgba(0,255,0,0.15); border-color: rgba(0,255,0,0.4); color: #00ff00;
        }}
        .pill:hover {{ border-color: #555; }}
        .pill.dead-pill {{
            background: rgba(170,0,0,0.20); border-color: rgba(255,80,80,0.4); color: #ff8888;
            font-weight: 600;
        }}
        .pill.dead-pill:hover {{ background: rgba(170,0,0,0.30); border-color: rgba(255,80,80,0.6); }}
        .pill.dead-pill.active {{
            background: rgba(170,0,0,0.45); border-color: #ff4444; color: #fff;
        }}
        .pill .pill-count {{
            font-size: 0.75rem; color: #888; margin-left: 4px;
        }}
        .pill.active .pill-count {{ color: rgba(0,255,0,0.6); }}

        .pill-actions {{
            display: flex; gap: 6px; margin-bottom: 6px;
        }}
        .pill-action {{
            font-size: 0.8rem; color: #4a9eff; cursor: pointer; padding: 2px 0;
        }}
        .pill-action:hover {{ text-decoration: underline; }}

        /* Error list in sidebar */
        .error-list-container {{
            flex: 1; overflow-y: auto; padding: 0;
            scrollbar-width: thin; scrollbar-color: #666 transparent;
        }}
        .error-item {{
            padding: 8px 14px; border-bottom: 1px solid #222; cursor: pointer;
            transition: background 0.15s; display: flex; align-items: flex-start; gap: 8px;
        }}
        .error-item:hover {{ background: #222; }}
        .error-item input[type="checkbox"] {{ margin-top: 2px; flex-shrink: 0; cursor: pointer; }}
        .error-item-content {{ flex: 1; min-width: 0; }}
        .error-item-header {{
            display: flex; justify-content: space-between; align-items: center;
        }}
        .error-item-code {{
            font-family: 'Courier New', monospace; font-weight: 600; font-size: 0.95rem;
        }}
        .error-item-count {{
            font-size: 0.8rem; color: #888; background: rgba(255,255,255,0.05);
            padding: 2px 8px; border-radius: 3px;
        }}
        .error-item-meaning {{
            font-size: 0.85rem; color: #888; margin-top: 2px;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }}
        /* Category accordion */
        .cat-row {{
            display: flex; align-items: center; gap: 8px;
            padding: 8px 14px; cursor: pointer; user-select: none;
            border-bottom: 1px solid #222; background: #1d1d1d;
            transition: background 0.15s;
        }}
        .cat-row:hover {{ background: #252525; }}
        .cat-arrow {{
            font-size: 0.7rem; color: #888; width: 10px; text-align: center;
            transition: transform 0.15s;
        }}
        .cat-row.expanded .cat-arrow {{ transform: rotate(90deg); }}
        .cat-name {{
            flex: 1; font-weight: 600; font-size: 0.9rem; color: #ccc;
            text-transform: capitalize;
        }}
        .cat-stats {{
            font-size: 0.78rem; color: #888;
        }}
        .cat-stats .miner-n {{ color: #00ff00; font-weight: 600; }}
        .cat-row input[type="checkbox"] {{ cursor: pointer; flex-shrink: 0; }}
        .cat-children {{ display: none; background: #161616; }}
        .cat-children.expanded {{ display: block; }}
        .cat-children .error-item {{ padding-left: 32px; background: #161616; }}
        .cat-children .error-item:hover {{ background: #1c1c1c; }}
        .error-search {{
            width: 100%; padding: 10px 15px; background: #222; border: 1px solid #333;
            border-radius: 4px; color: #fff; font-size: 0.9rem; outline: none;
        }}
        .error-search:focus {{ border-color: #555; }}

        /* Hashrate filter */
        .hashrate-filters {{
            display: flex; gap: 4px; flex-wrap: wrap;
        }}

        /* --- Main content --- */
        .main-content {{
            flex: 1; display: flex; flex-direction: column; overflow: hidden;
        }}

        .table-toolbar {{
            display: flex; justify-content: space-between; align-items: center;
            padding: 10px 16px; background: #1a1a1a; border-bottom: 1px solid #2a2a2a;
        }}
        .table-toolbar .result-count {{
            font-size: 1rem; color: #888;
        }}
        .table-toolbar .result-count span {{ color: #00ff00; font-weight: 600; }}
        .toolbar-actions {{
            display: flex; gap: 8px; align-items: center;
        }}
        .btn {{
            padding: 8px 16px; border-radius: 4px; font-size: 0.9rem; cursor: pointer;
            background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
            color: #ccc; transition: all 0.15s;
        }}
        .btn:hover {{ background: rgba(255,255,255,0.1); color: #fff; }}
        .btn.active {{ background: rgba(0,255,0,0.15); border-color: rgba(0,255,0,0.4); color: #00ff00; }}

        .table-wrap {{
            flex: 1; overflow-y: auto;
            scrollbar-width: thin; scrollbar-color: #666 transparent;
        }}
        table {{
            width: 100%; border-collapse: collapse; font-size: 0.95rem;
        }}
        thead th {{
            position: sticky; top: 0; background: #1a1a1a; padding: 12px 14px;
            text-align: left; font-size: 0.85rem; text-transform: uppercase;
            color: #888; letter-spacing: 0.5px; border-bottom: 1px solid #2a2a2a;
            cursor: pointer; user-select: none; white-space: nowrap;
        }}
        thead th:hover {{ color: #ccc; }}
        thead th .sort-arrow {{ color: #00ff00; margin-left: 4px; }}
        tbody tr {{
            border-bottom: 1px solid #1e1e1e; transition: background 0.1s;
        }}
        tbody tr:hover {{ background: rgba(255,255,255,0.03); }}
        td {{
            padding: 10px 14px; vertical-align: middle;
        }}
        td.mono {{ font-family: 'Courier New', monospace; font-size: 0.9rem; }}
        td.miner-link {{ cursor: pointer; color: #4a9eff; text-decoration: underline; }}
        td.miner-link:hover {{ color: #7ab8ff; }}

        .pod-group-row {{
            background: #1a1a1a !important;
        }}
        .pod-group-row td {{
            padding: 10px 14px; font-weight: 600; color: #00ff00; font-size: 0.95rem;
            border-bottom: 1px solid #2a2a2a;
        }}
        .pod-group-row .pod-type {{ color: #888; font-weight: normal; margin-left: 8px; }}

        .error-tag {{
            display: inline-block; background: #ff6600; color: #fff; padding: 4px 8px;
            border-radius: 4px; font-size: 0.8rem; font-weight: 500; margin: 1px 2px;
            cursor: help;
        }}
        .dead-gauge {{
            position: relative; width: 58px; height: 16px; border-radius: 3px;
            background: #2a2a2a; border: 1px solid #3a3a3a; overflow: hidden;
            display: inline-block; vertical-align: middle;
        }}
        .dead-gauge-fill {{ position: absolute; left: 0; top: 0; bottom: 0; }}
        .dead-gauge-label {{
            position: relative; display: block; line-height: 16px; text-align: center;
            font-size: 0.72rem; font-weight: 700; color: #fff;
            text-shadow: 0 0 2px rgba(0,0,0,0.9);
        }}
        .hashboard-tag {{
            display: inline-block; background: rgba(255,68,68,0.15); color: #ff8888;
            padding: 3px 7px; border-radius: 4px; font-size: 0.72rem; font-weight: 600;
            margin: 1px 2px; border: 1px solid rgba(255,68,68,0.3); text-transform: uppercase;
        }}
        .hashrate-cell {{ font-weight: 600; }}
        .hashrate-cell.zero {{ color: #ff4444; }}
        .hashrate-cell.low {{ color: #ff6600; }}
        .hashrate-cell.ok {{ color: #00ff00; }}

        .reboots-cell {{ color: #888; }}
        .reboots-cell.high {{ color: #ff6600; }}
        .reboots-cell.critical {{ color: #ff4444; font-weight: 600; }}

        /* Copy modal */
        .copy-modal {{
            display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center;
        }}
        .copy-modal.show {{ display: flex; }}
        .copy-modal-content {{
            background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px;
            width: 500px; max-height: 80vh; display: flex; flex-direction: column;
        }}
        .copy-modal-header {{
            display: flex; justify-content: space-between; align-items: center;
            padding: 14px 18px; border-bottom: 1px solid #2a2a2a;
        }}
        .copy-modal-header h3 {{ font-size: 0.95rem; color: #00ff00; }}
        .copy-modal-close {{ cursor: pointer; color: #888; font-size: 1.2rem; }}
        .copy-modal-close:hover {{ color: #fff; }}
        .copy-modal textarea {{
            flex: 1; padding: 14px 18px; background: #111; border: none; color: #ccc;
            font-family: 'Courier New', monospace; font-size: 0.8rem; resize: none; outline: none;
            min-height: 300px;
        }}
        .copy-modal-footer {{
            padding: 12px 18px; border-top: 1px solid #2a2a2a; display: flex;
            justify-content: space-between; align-items: center;
        }}
        .copy-modal-footer .count {{ color: #888; font-size: 0.85rem; }}

        /* Errors modal — click any bubble in a row to open */
        .errors-cell {{ cursor: pointer; }}
        .errors-cell .error-tag {{ cursor: pointer; }}
        .errors-modal {{
            display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center;
        }}
        .errors-modal.show {{ display: flex; }}
        .errors-modal-content {{
            background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px;
            width: 520px; max-width: 92vw; max-height: 80vh; display: flex; flex-direction: column;
        }}
        .errors-modal-header {{
            display: flex; justify-content: space-between; align-items: center;
            padding: 14px 18px; border-bottom: 1px solid #2a2a2a;
        }}
        .errors-modal-header h3 {{ font-size: 0.95rem; color: #00ff00; margin: 0; }}
        .errors-modal-close {{ cursor: pointer; color: #888; font-size: 1.2rem; }}
        .errors-modal-close:hover {{ color: #fff; }}
        .errors-modal-body {{
            padding: 8px 18px 16px; overflow-y: auto;
        }}
        .errors-modal-row {{
            display: grid; grid-template-columns: auto 1fr; gap: 10px 14px;
            padding: 12px 0; border-bottom: 1px solid #2a2a2a;
        }}
        .errors-modal-row:last-child {{ border-bottom: none; }}
        .errors-modal-row .error-tag {{
            cursor: default; align-self: start; margin: 0;
        }}
        .errors-modal-row .meaning {{ color: #eee; font-size: 0.9rem; font-weight: 500; }}
        .errors-modal-row .solution {{
            color: #aaa; font-size: 0.82rem; margin-top: 4px;
        }}
        .errors-modal-row .solution::before {{ content: '→ '; color: #4a9eff; }}
        .errors-modal-empty {{ color: #888; padding: 16px 0; font-style: italic; }}

        /* Dead-confidence breakdown (rendered inside the errors-modal shell) */
        .dead-bd-score {{ font-size: 1.6rem; font-weight: 700; padding: 2px 0 8px; }}
        .dead-bd-score span {{ font-size: 0.85rem; color: #888; font-weight: 400; }}
        .dead-bd-banner {{
            background: rgba(255,68,68,0.12); border: 1px solid rgba(255,68,68,0.4);
            color: #ffb0b0; padding: 8px 10px; border-radius: 4px;
            font-size: 0.84rem; margin-bottom: 6px;
        }}
        .dead-bd-section {{
            color: #00ff00; font-size: 0.72rem; text-transform: uppercase;
            letter-spacing: 0.5px; margin: 14px 0 4px;
            border-bottom: 1px solid #2a2a2a; padding-bottom: 3px;
        }}
        .dead-bd-reasons {{ margin: 0; padding-left: 18px; }}
        .dead-bd-reasons li {{ color: #eee; font-size: 0.88rem; margin: 4px 0; }}
        .dead-bd-signals {{
            display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px;
        }}
        .dead-bd-signals div {{
            display: flex; justify-content: space-between; gap: 10px;
            font-size: 0.85rem; border-bottom: 1px solid #222; padding: 4px 0;
        }}
        .dead-bd-signals span {{ color: #888; }}
        .dead-bd-signals b {{ color: #eee; font-weight: 600; text-align: right; }}
        .dead-bd-note {{
            color: #ffaa00; font-size: 0.8rem; margin-top: 10px; font-style: italic;
        }}

        @media (max-width: 1000px) {{
            .layout {{ flex-direction: column; height: auto; }}
            .sidebar {{ width: 100%; min-width: auto; max-height: 40vh; border-right: none; border-bottom: 1px solid #2a2a2a; }}
            .table-wrap {{ max-height: 60vh; }}
        }}
    </style>
</head>
<body>
<div class="header">
    <div class="dropdown">
        <h1 class="dropdown-title">NGON Mining - Dead Miner Hunter</h1>
        <div class="dropdown-content">{generate_dropdown_html(AuthManager.get_user_access())}</div>
    </div>
    <p>Identify and manage miners with errors</p>
    <div class="stats-row">
        <div class="stat-badge">Miners with Errors: {len(active_miners)}</div>
        <div class="stat-badge critical">Zero Hash (with errors): {zero_hash_with_errors}</div>
        <div class="stat-badge dead clickable" id="confirmed-dead-stat" onclick="toggleConfirmedDeadFilter()" title="Click to scan history and find miners safe to pull">Confirmed Dead (7d): <span id="confirmed-dead-count">click to scan</span></div>
        <div class="stat-badge warning">Zero Hash (no errors): {zero_hash_no_errors}</div>
        <div class="stat-badge critical">Offline: {len(offline_json)}</div>
        <div class="stat-badge">Sleeping: {len(hunter.sleeping_miners)}</div>
        <div class="stat-badge">Error Types: {len(error_info)}</div>
    </div>
</div>

<div class="layout">
    <!-- Sidebar: Pods + Errors -->
    <div class="sidebar">
        <div class="sidebar-section">
            <h3>Pods</h3>
            <div class="pill-actions">
                <span class="pill-action" onclick="selectAllPods()">Select All</span>
                <span class="pill-action" onclick="selectNoPods()">Clear All</span>
            </div>
            <div class="filter-pills" id="pod-pills"></div>
        </div>
        <div class="sidebar-section">
            <h3>Hashrate</h3>
            <div class="hashrate-filters">
                <div class="pill active" data-hr-filter="all" onclick="setHashrateFilter('all')">All</div>
                <div class="pill" data-hr-filter="zero" onclick="setHashrateFilter('zero')">Zero Hash</div>
                <div class="pill" data-hr-filter="low" onclick="setHashrateFilter('low')">Low Hash</div>
                <div class="pill" data-hr-filter="hashing" onclick="setHashrateFilter('hashing')">Hashing</div>
                <div class="pill" data-hr-filter="offline" onclick="setHashrateFilter('offline')">Offline <span class="pill-count" style="color:#ff4444">{len(offline_json)}</span></div>
            </div>
            <div class="threshold-row" style="display: flex; gap: 10px; margin-top: 8px; align-items: center;">
                <label style="font-size: 0.8rem; color: #888;">Low hash threshold (TH/s) <input type="number" id="thresh-low" value="170" style="width: 60px; padding: 4px 8px; background: #222; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 0.8rem;"></label>
            </div>
        </div>
        <div class="sidebar-section">
            <h3>Pull List</h3>
            <div class="hashrate-filters">
                <div class="pill dead-pill" id="confirmed-dead-pill" onclick="toggleConfirmedDeadFilter()" title="Miners scored >={DEAD_PULL_THRESHOLD}/100 dead-confidence (reboots, no hashrate, hardware errors, crash-loops) — strong pull candidates. Click any score for the full breakdown.">
                    Confirmed Dead <span class="pill-count" id="confirmed-dead-pill-count" style="color:#ff8888">click to scan</span>
                </div>
            </div>
            <div style="font-size: 0.75rem; color: #888; margin-top: 6px; line-height: 1.4;">
                Online &ge;5 of last 7 days, zero hashrate every day. Scan takes a few seconds.
            </div>
        </div>
        <div class="sidebar-section" style="padding-bottom: 8px;">
            <h3>Error Codes</h3>
            <div class="pill-actions">
                <span class="pill-action" onclick="selectAllErrors()">All</span>
                <span class="pill-action" onclick="selectNoErrors()">None</span>
            </div>
            <input type="text" class="error-search" id="error-search" placeholder="Filter errors...">
        </div>
        <div class="error-list-container" id="error-list"></div>
    </div>

    <!-- Main table -->
    <div class="main-content">
        <div class="table-toolbar">
            <div class="result-count" id="result-count">Showing <span>0</span> miners</div>
            <div class="toolbar-actions">
                <button class="btn" onclick="openCopyModal()">Copy List</button>
                <button class="btn" onclick="location.reload()">Refresh</button>
            </div>
        </div>
        <div class="table-wrap">
            <table>
                <thead>
                    <tr>
                        <th style="text-align: center; width: 32px;"><input type="checkbox" id="selectAll" onchange="toggleAllCheckboxes()" title="Select / deselect all"></th>
                        <th data-sort="pod" onclick="sortTable('pod')">Pod <span class="sort-arrow" id="sort-pod"></span></th>
                        <th data-sort="position" onclick="sortTable('position')">Pos <span class="sort-arrow" id="sort-position"></span></th>
                        <th data-sort="ip" onclick="sortTable('ip')">IP <span class="sort-arrow" id="sort-ip"></span></th>
                        <th data-sort="hashrate" onclick="sortTable('hashrate')">Hashrate <span class="sort-arrow" id="sort-hashrate"></span></th>
                        <th data-sort="last_hash" onclick="sortTable('last_hash')" title="Last date this miner genuinely hashed (>=100 TH/s) — populated after the Confirmed Dead scan">Last Hash <span class="sort-arrow" id="sort-last_hash"></span></th>
                        <th data-sort="dead_score" onclick="sortTable('dead_score')" title="Dead-confidence score 0-100">Confidence <span class="sort-arrow" id="sort-dead_score"></span></th>
                        <th>Errors</th>
                        <th data-sort="reboots" onclick="sortTable('reboots')" title="Reboots in last 30 days">30D R0 <span class="sort-arrow" id="sort-reboots"></span></th>
                        <th data-sort="reboots_life" onclick="sortTable('reboots_life')" title="Lifetime reboots">Lifetime R0 <span class="sort-arrow" id="sort-reboots_life"></span></th>
                        <th data-sort="serial" onclick="sortTable('serial')">Serial <span class="sort-arrow" id="sort-serial"></span></th>
                    </tr>
                </thead>
                <tbody id="miner-table-body"></tbody>
            </table>
        </div>
    </div>
</div>

<!-- Errors modal -->
<div class="errors-modal" id="errors-modal">
    <div class="errors-modal-content">
        <div class="errors-modal-header">
            <h3 id="errors-modal-title">Errors</h3>
            <span class="errors-modal-close" onclick="closeErrorsModal()">&times;</span>
        </div>
        <div class="errors-modal-body" id="errors-modal-body"></div>
    </div>
</div>

<!-- Copy modal -->
<div class="copy-modal" id="copy-modal">
    <div class="copy-modal-content">
        <div class="copy-modal-header">
            <h3>Copy Miner List</h3>
            <span class="copy-modal-close" onclick="closeCopyModal()">&times;</span>
        </div>
        <textarea id="copy-textarea" readonly></textarea>
        <div class="copy-modal-footer">
            <span class="count" id="copy-count">0 items</span>
            <button class="btn" onclick="copyToClipboard()">Copy to Clipboard</button>
        </div>
    </div>
</div>

<script>
const allMiners = {json.dumps(miners_json + offline_json)};
const errorInfo = {json.dumps(error_info)};
const errorDetails = {json.dumps(all_error_details)};
const podMinerCounts = {json.dumps(hunter.pod_miner_counts)};
const podOrder = {json.dumps(pod_order)};
// Tier cutoffs come from misc.dead_miner so the gauge colours, the pill
// filter and the backend score all move together when a weight is tuned.
const DEAD_PULL = {DEAD_PULL_THRESHOLD};
const DEAD_FLOOR = {DEAD_SCORE_FLOOR};

function getLowThreshold() {{
    return parseFloat(document.getElementById('thresh-low').value) || 170;
}}

// State
let selectedPods = new Set();
let selectedErrors = new Set();
let hashrateFilter = 'all';
let confirmedDeadOnly = false;
let confirmedDeadLoaded = false;
let confirmedDeadLoading = false;
let currentSort = {{ key: 'pod', dir: 'asc' }};

// --- Init ---
function init() {{
    // Build pod pills
    const pods = {json.dumps(pod_counts)};
    const podPills = document.getElementById('pod-pills');
    // Use config order for pod pills, then append any pods not in config
    const orderedPods = [...podOrder.filter(p => p in pods), ...Object.keys(pods).filter(p => !podOrder.includes(p))];
    for (const pod of orderedPods) {{
        const count = pods[pod] || 0;
        const pill = document.createElement('div');
        pill.className = 'pill';
        pill.dataset.pod = pod;
        pill.innerHTML = pod + '<span class="pill-count">' + count + '</span>';
        pill.onclick = () => togglePod(pod);
        podPills.appendChild(pill);
    }}

    // Group errors by category, then render accordion
    errorInfo.forEach(e => selectedErrors.add(e.code));
    const grouped = {{}};
    for (const err of errorInfo) {{
        const cat = err.category || 'uncategorized';
        (grouped[cat] = grouped[cat] || []).push(err);
    }}
    // Order categories by descending unique-miner count across allMiners
    const catMinerCount = {{}};
    for (const cat of Object.keys(grouped)) {{
        const codes = new Set(grouped[cat].map(e => e.code));
        let n = 0;
        for (const m of allMiners) {{
            if (m.errors.some(e => codes.has(e) || codeMatchesAny(e, codes))) n++;
        }}
        catMinerCount[cat] = n;
    }}
    const catOrder = Object.keys(grouped).sort((a, b) => catMinerCount[b] - catMinerCount[a]);

    const errorList = document.getElementById('error-list');
    for (const cat of catOrder) {{
        const errs = grouped[cat].sort((a, b) => b.count - a.count);
        const codeCount = errs.length;

        const catRow = document.createElement('div');
        catRow.className = 'cat-row';
        catRow.dataset.cat = cat;
        catRow.innerHTML = `
            <span class="cat-arrow">\\u25B6</span>
            <span class="cat-name">${{cat.replace(/_/g, ' ')}}</span>
            <span class="cat-stats">${{codeCount}} codes \\u00b7 <span class="miner-n" data-cat-count="${{cat}}">${{catMinerCount[cat]}}</span> miners</span>
            <input type="checkbox" checked data-cat-cb="${{cat}}">
        `;
        catRow.querySelector('input').onclick = (e) => e.stopPropagation();
        catRow.querySelector('input').onchange = (e) => toggleCategory(cat, e.target.checked);
        catRow.onclick = () => toggleCategoryExpanded(cat);
        errorList.appendChild(catRow);

        const children = document.createElement('div');
        children.className = 'cat-children';
        children.dataset.catChildren = cat;
        for (const err of errs) {{
            const div = document.createElement('div');
            div.className = 'error-item';
            div.dataset.error = err.code;
            div.dataset.cat = cat;
            div.innerHTML = `
                <input type="checkbox" checked data-err-cb="${{err.code}}">
                <div class="error-item-content">
                    <div class="error-item-header">
                        <span class="error-item-code">${{err.code}}</span>
                        <span class="error-item-count" data-err-count="${{err.code}}">${{err.count}}</span>
                    </div>
                    <div class="error-item-meaning" title="${{err.meaning}}">${{err.meaning}}</div>
                </div>
            `;
            div.onclick = (e) => {{
                if (e.target.tagName === 'INPUT') return;
                const cb = div.querySelector('input[type="checkbox"]');
                cb.checked = !cb.checked;
                toggleError(err.code, cb.checked);
            }};
            div.querySelector('input').onchange = (e) => toggleError(err.code, e.target.checked);
            children.appendChild(div);
        }}
        errorList.appendChild(children);
    }}

    // Error search
    document.getElementById('error-search').addEventListener('input', filterErrorList);

    // Threshold input triggers re-render
    document.getElementById('thresh-low').addEventListener('input', render);

    render();
}}

// --- Filters ---
function togglePod(pod) {{
    if (selectedPods.has(pod)) selectedPods.delete(pod);
    else selectedPods.add(pod);
    document.querySelectorAll('#pod-pills .pill').forEach(p => {{
        p.classList.toggle('active', selectedPods.has(p.dataset.pod));
    }});
    render();
}}

function selectAllPods() {{
    document.querySelectorAll('#pod-pills .pill').forEach(p => {{
        selectedPods.add(p.dataset.pod);
        p.classList.add('active');
    }});
    render();
}}

function selectNoPods() {{
    selectedPods.clear();
    document.querySelectorAll('#pod-pills .pill').forEach(p => p.classList.remove('active'));
    render();
}}

// Helper: does a single emitted code match any pattern in the given set (literal or wildcard)?
function codeMatchesAny(code, patternSet) {{
    if (patternSet.has(code)) return true;
    for (const p of patternSet) {{
        if (p.includes('X')) {{
            if (new RegExp('^' + p.replace(/X/g, '\\\\d') + '$').test(code)) return true;
        }}
    }}
    return false;
}}

function syncCategoryCheckboxes() {{
    // Set parent category checkbox to checked / unchecked / indeterminate based on children.
    document.querySelectorAll('[data-cat-cb]').forEach(cb => {{
        const cat = cb.dataset.catCb;
        const children = document.querySelectorAll(`[data-cat="${{cat}}"][data-error]`);
        let on = 0;
        children.forEach(c => {{ if (selectedErrors.has(c.dataset.error)) on++; }});
        if (on === 0) {{ cb.checked = false; cb.indeterminate = false; }}
        else if (on === children.length) {{ cb.checked = true; cb.indeterminate = false; }}
        else {{ cb.checked = false; cb.indeterminate = true; }}
    }});
}}

function toggleError(code, checked) {{
    if (checked) selectedErrors.add(code);
    else selectedErrors.delete(code);
    syncCategoryCheckboxes();
    render();
}}

function toggleCategory(cat, checked) {{
    document.querySelectorAll(`[data-cat="${{cat}}"][data-error]`).forEach(item => {{
        const code = item.dataset.error;
        if (checked) selectedErrors.add(code);
        else selectedErrors.delete(code);
        const cb = item.querySelector('input[data-err-cb]');
        if (cb) cb.checked = checked;
    }});
    syncCategoryCheckboxes();
    render();
}}

function toggleCategoryExpanded(cat) {{
    const row = document.querySelector(`.cat-row[data-cat="${{cat}}"]`);
    const children = document.querySelector(`[data-cat-children="${{cat}}"]`);
    if (!row || !children) return;
    const open = !row.classList.contains('expanded');
    row.classList.toggle('expanded', open);
    children.classList.toggle('expanded', open);
}}

function selectAllErrors() {{
    selectedErrors.clear();
    errorInfo.forEach(e => selectedErrors.add(e.code));
    document.querySelectorAll('[data-err-cb]').forEach(cb => cb.checked = true);
    syncCategoryCheckboxes();
    render();
}}

function selectNoErrors() {{
    selectedErrors.clear();
    document.querySelectorAll('[data-err-cb]').forEach(cb => cb.checked = false);
    syncCategoryCheckboxes();
    render();
}}

function setHashrateFilter(f) {{
    hashrateFilter = f;
    document.querySelectorAll('[data-hr-filter]').forEach(p => {{
        p.classList.toggle('active', p.dataset.hrFilter === f);
    }});
    render();
}}

async function toggleConfirmedDeadFilter() {{
    if (confirmedDeadLoading) return;
    if (!confirmedDeadLoaded) {{
        confirmedDeadLoading = true;
        const pillCount = document.getElementById('confirmed-dead-pill-count');
        const statCount = document.getElementById('confirmed-dead-count');
        const oldPillText = pillCount.textContent;
        pillCount.textContent = 'scanning…';
        if (statCount) statCount.textContent = 'scanning…';
        try {{
            const resp = await fetch('?action=confirmed_dead', {{
                credentials: 'same-origin',
                headers: {{ 'X-Requested-With': 'XMLHttpRequest' }},
            }});
            if (!resp.ok) throw new Error('HTTP ' + resp.status);
            const data = await resp.json();
            // Merge dead miners into allMiners
            const byMac = new Map();
            for (const m of allMiners) {{
                const mac = (m.mac || '').toUpperCase();
                if (mac) byMac.set(mac, m);
            }}
            for (const dm of (data.dead_miners || [])) {{
                const mac = (dm.mac || '').toUpperCase();
                const existing = byMac.get(mac);
                if (existing) {{
                    existing.dead_score = dm.dead_score;
                    existing.dead_needs_setup = dm.dead_needs_setup;
                    existing.dead_detail = dm.dead_detail;
                    existing.has_hashboard_error = dm.has_hashboard_error;
                    existing.last_hash = dm.last_hash;
                }} else {{
                    allMiners.push(dm);
                    // Make sure its pod is selectable so the filter doesn't hide it
                    if (!selectedPods.has(dm.pod)) {{
                        selectedPods.add(dm.pod);
                        ensurePodPill(dm.pod);
                    }}
                }}
            }}
            confirmedDeadLoaded = true;
            pillCount.textContent = data.count;
            if (statCount) statCount.textContent = data.count;
        }} catch (e) {{
            pillCount.textContent = oldPillText;
            if (statCount) statCount.textContent = 'error';
            confirmedDeadLoading = false;
            return;
        }}
        confirmedDeadLoading = false;
    }}
    confirmedDeadOnly = !confirmedDeadOnly;
    document.getElementById('confirmed-dead-pill').classList.toggle('active', confirmedDeadOnly);
    const statEl = document.getElementById('confirmed-dead-stat');
    if (statEl) statEl.classList.toggle('active', confirmedDeadOnly);
    render();
}}

function ensurePodPill(pod) {{
    if (!pod) return;
    if (document.querySelector(`#pod-pills .pill[data-pod="${{pod}}"]`)) return;
    const pill = document.createElement('div');
    pill.className = 'pill active';
    pill.dataset.pod = pod;
    pill.innerHTML = pod + '<span class="pill-count">0</span>';
    pill.onclick = () => togglePod(pod);
    document.getElementById('pod-pills').appendChild(pill);
}}

function filterErrorList() {{
    const q = document.getElementById('error-search').value.toLowerCase().trim();
    // Track which categories have at least one visible child
    const catVisible = {{}};
    document.querySelectorAll('#error-list .error-item').forEach(item => {{
        const cat = item.dataset.cat;
        if (!q) {{ item.style.display = ''; catVisible[cat] = true; return; }}
        const code = item.dataset.error.toLowerCase();
        const meaning = (item.querySelector('.error-item-meaning')?.textContent || '').toLowerCase();
        const detail = errorDetails[item.dataset.error];
        const solution = detail ? (detail.solution || '').toLowerCase() : '';
        const match = code.includes(q) || meaning.includes(q) || solution.includes(q);
        item.style.display = match ? '' : 'none';
        if (match) catVisible[cat] = true;
    }});
    // Show/hide category rows based on whether they (or their name) match.
    // Auto-expand categories with active matches; collapse all when query cleared.
    document.querySelectorAll('.cat-row').forEach(row => {{
        const cat = row.dataset.cat;
        const nameMatch = !q || cat.toLowerCase().includes(q);
        const hasChildMatch = !!catVisible[cat];
        const visible = !q || nameMatch || hasChildMatch;
        row.style.display = visible ? '' : 'none';
        const children = document.querySelector(`[data-cat-children="${{cat}}"]`);
        if (children) children.style.display = visible ? '' : 'none';
        if (q && hasChildMatch) {{
            row.classList.add('expanded');
            if (children) children.classList.add('expanded');
        }} else if (!q) {{
            row.classList.remove('expanded');
            if (children) children.classList.remove('expanded');
        }}
    }});
}}

// --- Error matching (supports wildcard patterns like 72X) ---
function minerMatchesError(minerErrors, selectedSet) {{
    return minerErrors.some(err => {{
        if (selectedSet.has(err)) return true;
        for (const sel of selectedSet) {{
            if (sel.includes('X')) {{
                const pattern = sel.replace(/X/g, '\\\\d');
                if (new RegExp('^' + pattern + '$').test(err)) return true;
            }}
        }}
        return false;
    }});
}}

// --- Hashrate classification ---
function getHashrateClass(hashrate) {{
    if (hashrate === 0) return 'zero';
    return hashrate < getLowThreshold() ? 'low' : 'ok';
}}

function passesHashrateFilter(miner) {{
    // Offline miners are visible only under the 'offline' filter; online miners are
    // hidden when 'offline' is active.
    if (miner.offline) return hashrateFilter === 'offline';
    if (hashrateFilter === 'offline') return false;
    if (hashrateFilter === 'all') return true;
    const cls = getHashrateClass(miner.hashrate);
    if (hashrateFilter === 'zero') return cls === 'zero';
    if (hashrateFilter === 'low') return cls === 'low';
    if (hashrateFilter === 'hashing') return miner.hashrate > 0;
    return true;
}}

// --- Sorting ---
function sortTable(key) {{
    if (currentSort.key === key) {{
        currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
    }} else {{
        currentSort = {{ key, dir: 'asc' }};
    }}
    // Update sort arrows
    document.querySelectorAll('.sort-arrow').forEach(el => el.textContent = '');
    const arrow = document.getElementById('sort-' + key);
    if (arrow) arrow.textContent = currentSort.dir === 'asc' ? '\\u25B2' : '\\u25BC';
    render();
}}

function sortMiners(miners) {{
    const {{ key, dir }} = currentSort;
    const mult = dir === 'asc' ? 1 : -1;
    return [...miners].sort((a, b) => {{
        let va = a[key], vb = b[key];
        if (key === 'pod') {{
            // Use config order for pods
            const ia = podOrder.indexOf(va), ib = podOrder.indexOf(vb);
            const oa = ia >= 0 ? ia : 9999, ob = ib >= 0 ? ib : 9999;
            if (oa !== ob) return (oa - ob) * mult;
            // Same pod: sort by position
            const pa = parseInt(a.position) || 0, pb = parseInt(b.position) || 0;
            return (pa - pb) * mult;
        }}
        if (key === 'hashrate' || key === 'reboots' || key === 'reboots_life' || key === 'dead_score') {{
            // Treat null (offline) as -1 so it sorts below 0 ascending, above 0 descending.
            const na = (va === null || va === undefined) ? -1 : va;
            const nb = (vb === null || vb === undefined) ? -1 : vb;
            return (na - nb) * mult;
        }}
        if (key === 'position') {{
            const na = parseInt(va) || 0, nb = parseInt(vb) || 0;
            return (na - nb) * mult;
        }}
        va = (va || '').toString().toLowerCase();
        vb = (vb || '').toString().toLowerCase();
        return va < vb ? -1 * mult : va > vb ? 1 * mult : 0;
    }});
}}

// Last-hash cell. last_hash is only computed during the Confirmed Dead scan
// and only for miners on the dead list — so a miner with no last_hash key
// hasn't been scanned ("\\u2014"), vs. one scanned with a null value (no
// genuine hash in 90d+ of history, "never").
function lastHashCell(miner) {{
    if (!('last_hash' in miner)) return '<td data-sort="" style="color:#555">\\u2014</td>';
    const lh = miner.last_hash;
    if (!lh) return '<td data-sort="" style="color:#ff6666" '
        + 'title="No genuine hashrate in 90+ days of history">never</td>';
    const day = String(lh).slice(0, 10);
    return '<td data-sort="' + day + '" style="color:#888">' + day + '</td>';
}}

// Dead-confidence gauge cell. Green <25, amber 25-74, red 75+. Blank below
// floor / unscored. data-sort carries the raw score so the column sorts.
// Hover shows the reasons; click opens the full breakdown modal.
function deadGaugeCell(miner) {{
    const score = Math.round(miner.dead_score || 0);
    if (score <= 0) return '<td data-sort="0"></td>';
    const color = score >= DEAD_PULL ? '#ff4444' : (score >= DEAD_FLOOR ? '#ffaa00' : '#00ff00');
    const d = miner.dead_detail || {{}};
    const reasons = d.reasons || [];
    let tip = 'Dead-confidence ' + score + '/100';
    if (reasons.length) tip += '\\n\\u2022 ' + reasons.join('\\n\\u2022 ');
    else if (miner.dead_needs_setup) tip += '\\n(only error 2000 \\u2014 needs setup)';
    tip += '\\n(click for full breakdown)';
    const mac = (miner.mac || '').replace(/'/g, "\\\\'");
    return `<td data-sort="${{score}}"><div class="dead-gauge" style="cursor:pointer" `
         + `title="${{tip.replace(/"/g, '&quot;')}}" onclick="showDeadModal('${{mac}}')">`
         + `<div class="dead-gauge-fill" style="width:${{score}}%;background:${{color}}"></div>`
         + `<span class="dead-gauge-label">${{score}}</span></div></td>`;
}}

// Open the shared MinerView modal (info + 24h hashrate chart + pools/errors).
// Pass MAC as the modal identifier so the history chart resolves; the live
// scan targets the IP directly when we have one (faster than MAC discovery).
function showMinerModal(mac, ip) {{
    if (!mac && !ip) return;
    MinerView.showModal(mac || ip, {{
        title: 'Miner Information',
        fetchFn: async () => {{
            const res = await fetch('/api/miner/miner_detail', {{
                method: 'POST',
                headers: {{ 'Content-Type': 'application/json' }},
                body: JSON.stringify({{ identifier: ip || mac }})
            }});
            if (!res.ok) {{
                const err = await res.json().catch(() => ({{}}));
                throw new Error(err.error || 'HTTP ' + res.status);
            }}
            return await res.json();
        }},
    }});
}}

// --- Render ---
function render() {{
    // Filter miners
    let filtered = allMiners.filter(m => {{
        if (!selectedPods.has(m.pod)) return false;
        if (confirmedDeadOnly) return (m.dead_score || 0) >= DEAD_PULL;
        if (!passesHashrateFilter(m)) return false;
        if (m.offline) return true;  // offline miners bypass the error-set filter
        if (selectedErrors.size > 0 && !minerMatchesError(m.errors, selectedErrors)) return false;
        if (selectedErrors.size === 0) return false;
        return true;
    }});

    // Sort
    filtered = sortMiners(filtered);

    // Group by pod for display
    const tbody = document.getElementById('miner-table-body');
    tbody.innerHTML = '';

    let currentPod = null;
    let podCount = 0;
    let podGroupRow = null;

    // Pre-count per pod
    const podCounts = {{}};
    filtered.forEach(m => {{ podCounts[m.pod] = (podCounts[m.pod] || 0) + 1; }});

    for (const miner of filtered) {{
        // Pod group header
        if (miner.pod !== currentPod) {{
            currentPod = miner.pod;
            const tr = document.createElement('tr');
            tr.className = 'pod-group-row';
            const total = podMinerCounts[miner.pod] || 0;
            const shown = podCounts[miner.pod];
            const totalSuffix = total ? ' / ' + total + ' total' : '';
            tr.innerHTML = '<td colspan="11">' + miner.pod +
                '<span class="pod-type">' + shown + ' shown' + totalSuffix + '</span></td>';
            tbody.appendChild(tr);
        }}

        const tr = document.createElement('tr');
        const rebootClass = miner.reboots >= 5 ? 'high' : '';
        const lifeClass = miner.reboots_life >= 200 ? 'critical' : (miner.reboots_life >= 50 ? 'high' : '');

        const isDeadPull = (miner.dead_score || 0) >= DEAD_PULL;
        const hashboardTag = (isDeadPull && miner.has_hashboard_error)
            ? '<span class="hashboard-tag" title="Has hashboard-category error code">hashboard</span>'
            : '';
        const gaugeCell = deadGaugeCell(miner);

        if (miner.offline) {{
            tr.innerHTML = `
                <td style="text-align: center;"><input type="checkbox" class="miner-checkbox" data-mac="${{miner.mac || ''}}"></td>
                <td>${{miner.pod}}</td>
                <td>${{miner.position}}</td>
                <td class="mono miner-link" onclick="showMinerModal('${{miner.mac || ''}}','${{miner.ip || ''}}')">${{miner.ip || '\\u2014'}}</td>
                <td class="hashrate-cell zero">&mdash;</td>
                ${{lastHashCell(miner)}}
                ${{gaugeCell}}
                <td>${{hashboardTag}}<span class="error-tag" style="background:#ff4444">OFFLINE</span></td>
                <td class="reboots-cell ${{rebootClass}}">${{miner.reboots || ''}}</td>
                <td class="reboots-cell ${{lifeClass}}">${{miner.reboots_life || ''}}</td>
                <td class="mono">${{miner.serial}}</td>
            `;
        }} else {{
            const hrClass = getHashrateClass(miner.hashrate);
            let errorHtml = '';
            for (const err of miner.errors) {{
                const detail = errorDetails[err];
                const tip = detail ? detail.meaning + ' - ' + detail.solution : '';
                errorHtml += '<span class="error-tag" title="' + tip.replace(/"/g, '&quot;') + '">' + err + '</span>';
            }}
            // Use MAC if present, fall back to serial so the modal can locate the miner.
            const errKey = (miner.mac || miner.serial || '').replace(/'/g, "\\\\'");
            const errCellAttrs = miner.errors.length
                ? `class="errors-cell" onclick="showErrorsModal('${{errKey}}')"`
                : '';
            tr.innerHTML = `
                <td style="text-align: center;"><input type="checkbox" class="miner-checkbox" data-mac="${{miner.mac || ''}}"></td>
                <td>${{miner.pod}}</td>
                <td>${{miner.position}}</td>
                <td class="mono miner-link" onclick="showMinerModal('${{miner.mac || ''}}','${{miner.ip || ''}}')">${{miner.ip}}</td>
                <td class="hashrate-cell ${{hrClass}}">${{miner.hashrate}} TH/s</td>
                ${{lastHashCell(miner)}}
                ${{gaugeCell}}
                <td ${{errCellAttrs}}>${{hashboardTag}}${{errorHtml}}</td>
                <td class="reboots-cell ${{rebootClass}}">${{miner.reboots || ''}}</td>
                <td class="reboots-cell ${{lifeClass}}">${{miner.reboots_life || ''}}</td>
                <td class="mono">${{miner.serial}}</td>
            `;
        }}
        if (miner.is_sleeping) {{
            tr.style.background = 'rgba(74, 158, 255, 0.2)';
            tr.title = 'Currently sleeping';
        }}
        tbody.appendChild(tr);
    }}

    // Update count
    document.querySelector('#result-count span').textContent = filtered.length;

    // Update error counts in sidebar to reflect filtered view
    const errCounts = {{}};
    filtered.forEach(m => m.errors.forEach(e => {{ errCounts[e] = (errCounts[e] || 0) + 1; }}));
    document.querySelectorAll('[data-err-count]').forEach(el => {{
        const code = el.dataset.errCount;
        let count = errCounts[code] || 0;
        // Check wildcard matches
        if (count === 0 && code.includes('X')) {{
            const pattern = code.replace(/X/g, '\\\\d');
            const regex = new RegExp('^' + pattern + '$');
            for (const [e, c] of Object.entries(errCounts)) {{
                if (regex.test(e)) count += c;
            }}
        }}
        el.textContent = count;
    }});

    // Update per-category distinct-miner counts
    document.querySelectorAll('[data-cat-count]').forEach(el => {{
        const cat = el.dataset.catCount;
        const codes = new Set(
            Array.from(document.querySelectorAll(`[data-cat="${{cat}}"][data-error]`))
                 .map(d => d.dataset.error)
        );
        let n = 0;
        for (const m of filtered) {{
            if (m.errors.some(e => codes.has(e) || codeMatchesAny(e, codes))) n++;
        }}
        el.textContent = n;
    }});

    // Update pod pill counts
    const filteredPodCounts = {{}};
    allMiners.filter(m => {{
        if (confirmedDeadOnly) return (m.dead_score || 0) >= DEAD_PULL;
        if (!passesHashrateFilter(m)) return false;
        if (m.offline) return true;
        if (selectedErrors.size > 0 && !minerMatchesError(m.errors, selectedErrors)) return false;
        if (selectedErrors.size === 0) return false;
        return true;
    }}).forEach(m => {{ filteredPodCounts[m.pod] = (filteredPodCounts[m.pod] || 0) + 1; }});
    document.querySelectorAll('#pod-pills .pill').forEach(p => {{
        const count = filteredPodCounts[p.dataset.pod] || 0;
        p.querySelector('.pill-count').textContent = count;
    }});
}}

// --- Copy ---
function toggleAllCheckboxes() {{
    const selectAll = document.getElementById('selectAll');
    if (!selectAll) return;
    document.querySelectorAll('.miner-checkbox').forEach(cb => {{
        cb.checked = selectAll.checked;
    }});
}}

function openCopyModal() {{
    const checked = Array.from(document.querySelectorAll('.miner-checkbox:checked'));
    if (checked.length === 0) {{
        alert('Select miners to include in the copy list.');
        return;
    }}
    const macSet = new Set(checked.map(cb => cb.dataset.mac));
    const lines = allMiners
        .filter(m => macSet.has(m.mac))
        .map(m => m.site + ',' + m.position + ',' + m.serial + ',' + (m.mac || ''));
    document.getElementById('copy-textarea').value = lines.join('\\n');
    document.getElementById('copy-count').textContent = lines.length + ' items';
    document.getElementById('copy-modal').classList.add('show');
}}

function closeCopyModal() {{
    document.getElementById('copy-modal').classList.remove('show');
}}

function copyToClipboard() {{
    const text = document.getElementById('copy-textarea').value;
    navigator.clipboard.writeText(text).then(() => {{
        const btn = document.querySelector('#copy-modal .btn');
        btn.textContent = 'Copied!';
        setTimeout(() => btn.textContent = 'Copy to Clipboard', 1500);
    }});
}}

// Close modal on outside click
document.getElementById('copy-modal').addEventListener('click', (e) => {{
    if (e.target === document.getElementById('copy-modal')) closeCopyModal();
}});

// --- Errors modal ---
function escapeHtml(s) {{
    return String(s == null ? '' : s)
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}}

// Dead-confidence breakdown — reuses the errors-modal shell. Shows the score
// tier, the plain-English reasons, and the raw signal values so a tech can
// see WHY a miner is flagged without troubleshooting it.
function showDeadModal(mac) {{
    const k = String(mac || '').toUpperCase();
    const miner = allMiners.find(m => (m.mac || '').toUpperCase() === k);
    if (!miner) return;
    const d = miner.dead_detail || {{}};
    const score = Math.round(miner.dead_score || 0);
    let tierLabel, tierColor;
    if (score >= DEAD_PULL) {{ tierLabel = 'Confirmed Dead \\u2014 pull for repair'; tierColor = '#ff4444'; }}
    else if (score >= DEAD_FLOOR) {{ tierLabel = 'Suspect \\u2014 worth a look'; tierColor = '#ffaa00'; }}
    else {{ tierLabel = 'Low confidence'; tierColor = '#00ff00'; }}

    document.getElementById('errors-modal-title').innerHTML =
        'Dead Confidence \\u2014 ' + escapeHtml(miner.pod) +
        ' / Pos ' + escapeHtml(miner.position) +
        ' \\u2014 SN ' + escapeHtml(miner.serial);

    let html = '<div class="dead-bd-score" style="color:' + tierColor + '">' +
        score + '<span>/100</span> &mdash; ' + tierLabel + '</div>';

    if (d.auto_dead) {{
        html += '<div class="dead-bd-banner">Auto-confirmed dead: no hashrate across the ' +
            'whole history window, plus heavy reboots or a hardware fault.</div>';
    }}

    const reasons = d.reasons || [];
    if (reasons.length) {{
        html += '<div class="dead-bd-section">Why this score</div><ul class="dead-bd-reasons">' +
            reasons.map(r => '<li>' + escapeHtml(r) + '</li>').join('') + '</ul>';
    }}

    if (d.eval_days != null) {{
        const sig = (label, val) => '<div><span>' + label + '</span><b>' + escapeHtml(String(val)) + '</b></div>';
        html += '<div class="dead-bd-section">Signals</div><div class="dead-bd-signals">' +
            sig('Reboots, 30d', d.reboots_30d != null ? d.reboots_30d : '\\u2014') +
            sig('Reboots, lifetime', miner.reboots_life || '\\u2014') +
            sig('Zero-hash days', (d.zero_days || 0) + ' of ' + d.eval_days) +
            sig('Crash-looping', d.unstable ? 'yes' : 'no') +
            sig('Hardware faults', (d.hw_cats && d.hw_cats.length) ? d.hw_cats.join(', ') : 'none') +
            sig('Awake & zero-hash', (d.awake_zero || 0) + ' of ' + (d.awake_samples || 0) + ' samples') +
            sig('Last genuine hash', d.last_hash ? String(d.last_hash).slice(0, 10) : 'never (90d+ of history)') +
            '</div>';
        if (d.eval_days < 3) {{
            html += '<div class="dead-bd-note">Limited history (' + d.eval_days +
                ' eval-day' + (d.eval_days === 1 ? '' : 's') +
                ') \\u2014 the score will firm up as more snapshots accumulate.</div>';
        }}
    }} else {{
        html += '<div class="dead-bd-note">No breakdown loaded \\u2014 click the ' +
            'Confirmed Dead pill to run the scan.</div>';
    }}

    document.getElementById('errors-modal-body').innerHTML = html;
    document.getElementById('errors-modal').classList.add('show');
}}

function showErrorsModal(key) {{
    if (!key) return;
    const k = String(key).toUpperCase();
    const miner = allMiners.find(m => (m.mac || '').toUpperCase() === k)
               || allMiners.find(m => (m.serial || '').toUpperCase() === k);
    if (!miner) return;

    const title = 'Errors \\u2014 ' + escapeHtml(miner.pod) +
                  ' / Pos ' + escapeHtml(miner.position) +
                  ' \\u2014 SN ' + escapeHtml(miner.serial);
    document.getElementById('errors-modal-title').innerHTML = title;

    const body = document.getElementById('errors-modal-body');
    if (!miner.errors || miner.errors.length === 0) {{
        body.innerHTML = '<div class="errors-modal-empty">No error codes for this miner.</div>';
    }} else {{
        const rows = miner.errors.map(err => {{
            const detail = errorDetails[err] || {{ meaning: 'Unknown error', solution: 'Contact support' }};
            return '<div class="errors-modal-row">' +
                '<span class="error-tag">' + escapeHtml(err) + '</span>' +
                '<div>' +
                    '<div class="meaning">' + escapeHtml(detail.meaning) + '</div>' +
                    '<div class="solution">' + escapeHtml(detail.solution) + '</div>' +
                '</div>' +
            '</div>';
        }}).join('');
        body.innerHTML = rows;
    }}
    document.getElementById('errors-modal').classList.add('show');
}}

function closeErrorsModal() {{
    document.getElementById('errors-modal').classList.remove('show');
}}

document.getElementById('errors-modal').addEventListener('click', (e) => {{
    if (e.target === document.getElementById('errors-modal')) closeErrorsModal();
}});

document.addEventListener('keydown', (e) => {{
    if (e.key === 'Escape') {{
        closeErrorsModal();
        closeCopyModal();
    }}
}});

// Init on load
init();
// Set default sort arrow
document.getElementById('sort-pod').textContent = '\\u25B2';
</script>
<script>
{generate_dropdown_js()}
</script>
</body>
</html>""")


if __name__ == "__main__":
    main()
