o
    9õ™jã^  ã                   @   sê  d dl Z d dlZej d d¡ d dlmZmZ e  ¡ ZedƒZ	e	 
e¡\ZZZerDedƒ er3eeƒ edƒ eer<dndƒ e d ¡ erYed	ƒ edƒ eed
ƒƒ e d ¡ d dlZd dlZd dlZe ¡  ej d d¡ d dlmZmZmZ d dlZdZdd„ Zeƒ \ZZe dd¡Zej Z!edƒ d "esœdnd¡gZ#e# $d "edkrªdnd¡¡ eD ] Z%e%ev r¸dndZ&e# $dj"e!e%ƒe%ekrÇdnde!e%ƒe&d�¡ q°d 'e#¡Z(edeƒ › dee )¡ ƒ› de(› deƒ › de *e¡› d�ƒ dS )é    Nz/opt/ngon/apps)ÚAuthManagerÚgenerate_login_page_htmlÚbridge_statuszContent-Type: application/jsonÚ z6{"success": false, "error": "Authentication required"}z{"success": true}zContent-Type: text/htmlzWifi Bridge Statusz/var/www/html/ngon)Úgenerate_dropdown_htmlÚgenerate_dropdown_cssÚgenerate_dropdown_jsz http://localhost:5050/api/statusc                  C   sÒ   zt jjtdd��} t | ¡}W d  ƒ n1 sw   Y  W n ty,   g g f Y S w g g }}| d¡p8i  ¡ D ]%\}}| d¡pEi }|sIq;| 	|¡ t
dd„ | ¡ D ƒƒ}|r`| 	|¡ q;t|ƒt|ƒfS )uQ  Return (all_gen_sites, sites_with_bridges) from live status_api.

    A site "has bridges" if any of its gens is reporting a live bridge IP
    (live_stats.live_bridge_ip) â€” the same authoritative signal the endpoint
    keys assignment on, so the dropdown/default track reality rather than the
    sparse config bridge_mac field.
    é
   )ÚtimeoutNÚsitesÚgenerator_groupsc                 s   s<   � | ]}|  d ¡p
i  ¡ D ]}|  d¡pi   d¡V  qqdS )Ú
generatorsÚ
live_statsÚlive_bridge_ipN)ÚgetÚvalues)Ú.0ÚgrpÚgen© r   úbridge_status.pyÚ	<genexpr>:   s   € ýþÿz_load_sites.<locals>.<genexpr>)ÚurllibÚrequestÚurlopenÚ
STATUS_APIÚjsonÚloadÚ	Exceptionr   ÚitemsÚappendÚanyr   Úsorted)ÚrÚcfgÚ	gen_sitesÚwith_bridgesÚsnameÚsiteÚgroupsÚ
has_bridger   r   r   Ú_load_sites'   s*   ÿ€ÿ

þ
€r+   r(   zContent-Type: text/html
u1   <option value=""{}>â€” Select a site â€”</option>z	 selectedz:<option value="all"{}>All sites with live bridges</option>Úallu     â€¢z.<option value="{v}"{sel}>{label}{tag}</option>)ÚvÚselÚlabelÚtagaÛ  <!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>NGON Wifi Bridge Status</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                background: #1a1a1a; color: #e0e0e0; line-height: 1.4; }
        ::-webkit-scrollbar { width: 6px; height: 6px; }
        ::-webkit-scrollbar-thumb { background: #666; border-radius: 3px; }
        .header {
            background-color: rgba(255,255,255,0.05) !important;
            border-radius: 8px !important;
            padding: 30px !important;
            margin: 20px !important;
            border: 1px solid rgba(255,255,255,0.1) !important;
            border-bottom: none !important;
            display: flex !important;
            justify-content: space-between !important;
            align-items: flex-start !important;
        }
        .content { margin: 0 20px 40px; }
        .filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 16px; }
        .filter-bar label { color: #888; font-size: 12px; }
        .filter-bar select {
            background: #2a2a2a; border: 1px solid #3a3a3a; color: #e0e0e0;
            padding: 6px 10px; border-radius: 4px; font-size: 13px;
        }
        .filter-bar select:focus { border-color: #00ff00; outline: none; }
        .filter-group { display: flex; align-items: center; gap: 5px; }
        .btn { background: #2a2a2a; border: 1px solid #3a3a3a; color: #e0e0e0; padding: 6px 14px;
                border-radius: 4px; font-size: 13px; cursor: pointer; }
        .btn:hover { border-color: #00ff00; color: #00ff00; }
        .chips { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
        .chip { background: #222; border: 1px solid #333; border-radius: 8px;
                 padding: 10px 16px; min-width: 110px; text-align: center; }
        .chip .v { font-size: 22px; font-weight: bold; color: #fff; }
        .chip .l { font-size: 11px; color: #888; margin-top: 3px; text-transform: uppercase; letter-spacing: .5px; }
        .chip.online .v { color: #00ff00; }
        .chip.offline .v { color: #ff4444; }
        .chip.unassigned .v { color: #ffaa00; }
        .chip.roamed .v { color: #ff6600; }
        .chip.nobridge .v { color: #aa44ff; }
        .scan-note { color: #888; font-size: 12px; margin-bottom: 14px; }
        .scan-note .warn { color: #ff6600; }
        table { width: 100%; border-collapse: collapse; background: #222;
                 border: 1px solid #333; border-radius: 8px; overflow: hidden; }
        th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #2e2e2e; font-size: 13px; }
        th { background: #262626; color: #888; font-size: 11px; text-transform: uppercase; letter-spacing: .5px;
              position: sticky; top: 0; }
        tr:last-child td { border-bottom: none; }
        td.empty { text-align: center; color: #888; padding: 26px; }
        .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
        a.iplink { color: #4a9eff; text-decoration: none; }
        a.iplink:hover { text-decoration: underline; }
        .badge { display: inline-block; font-size: 11px; font-weight: bold; padding: 2px 8px;
                  border-radius: 10px; white-space: nowrap; }
        .badge.online { background: rgba(0,255,0,.12); color: #00ff00; }
        .badge.offline { background: rgba(255,68,68,.15); color: #ff4444; }
        .badge.unassigned { background: rgba(255,170,0,.15); color: #ffaa00; }
        .badge.nobridge { background: rgba(170,68,255,.15); color: #aa44ff; }
        .badge.wasthere { background: rgba(136,136,136,.15); color: #999; }
        .tag { display: inline-block; font-size: 10px; font-weight: bold; padding: 1px 7px;
                border-radius: 8px; background: rgba(255,102,0,.15); color: #ff6600; }
        .tag.stale { background: rgba(136,136,136,.18); color: #aaa; }
        .sub { font-size: 11px; margin-top: 2px; }
        .sub.st-run { color: #00ff00; }
        .sub.st-down { color: #ff4444; }
        .sub.st-unk { color: #888; }
        .dim { color: #666; }
        .probe-btn { background: #2a2a2a; border: 1px solid #3a5; color: #6c6;
                      padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer;
                      white-space: nowrap; margin: 1px; }
        .probe-btn:hover { border-color: #00ff00; color: #00ff00; }
        .probe-btn:disabled { opacity: .5; cursor: default; }
        .probe-btn.dev { border-color: #46c; color: #7ad; }
        .probe-btn.dev:hover { border-color: #4a9eff; color: #4a9eff; }
        .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.6);
                          z-index: 1000; align-items: flex-start; justify-content: center; }
        .modal-overlay.show { display: flex; }
        .modal-box { background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 10px;
                      margin-top: 12vh; width: 440px; max-width: 92vw; overflow: hidden; }
        .modal-head { display: flex; justify-content: space-between; align-items: center;
                       padding: 14px 18px; border-bottom: 1px solid #333; }
        .modal-head h3 { font-size: 15px; color: #e0e0e0; margin: 0; }
        .modal-x { background: none; border: none; color: #888; font-size: 22px; cursor: pointer; line-height: 1; }
        .modal-x:hover { color: #fff; }
        .modal-body { padding: 18px; font-size: 14px; }
        .modal-body .macbig { font-family: ui-monospace, Menlo, monospace; font-size: 20px;
                               color: #fff; margin: 6px 0 14px; letter-spacing: .5px; }
        .modal-body .res-ok { color: #00ff00; font-weight: bold; }
        .modal-body .res-warn { color: #ffaa00; font-weight: bold; }
        .modal-body .res-bad { color: #ff6600; font-weight: bold; }
        .modal-body .kv { color: #888; margin-top: 8px; }
        .modal-body .kv b { color: #ccc; font-weight: 600; }
        .section-title { color: #e0e0e0; font-size: 16px; margin: 6px 0 10px; }
        z¼
    </style>
</head>
<body>
<div class="header">
    <div class="dropdown">
        <h1 class="dropdown-title">NGON Mining - Wifi Bridge Status</h1>
        <div class="dropdown-content">a  </div>
    </div>
</div>

<div class="content">
    <form method="get" id="filterForm" class="filter-bar">
        <div class="filter-group">
            <label>Site</label>
            <select name="site" onchange="document.getElementById('filterForm').submit()">aê  </select>
        </div>
        <button type="button" class="btn" id="refreshBtn" onclick="load(true)"><i class="fas fa-rotate"></i> Refresh</button>
        <span class="scan-note" id="asOf"></span>
    </form>

    <div class="chips" id="chips"></div>
    <div class="scan-note" id="scanNote"></div>

    <div class="section-title">Gen Bridges</div>
    <table>
        <thead><tr>
            <th>Status</th><th>Gen</th><th>Site / Group</th><th>Bridge MAC</th>
            <th>Bridge IP</th><th>On Peplink</th><th>Name</th><th>Notes</th><th>Probe</th>
        </tr></thead>
        <tbody id="rows">
            <tr><td colspan="9" class="empty">Select a site above to scan its bridges.</td></tr>
        </tbody>
    </table>

    <div class="section-title" style="margin-top:22px">Gens With No Bridge</div>
    <div class="scan-note" id="nbNote"></div>
    <table>
        <thead><tr>
            <th>Status</th><th>Gen</th><th>Site / Group</th><th>Gen MAC</th><th>Notes</th>
        </tr></thead>
        <tbody id="nbRows">
            <tr><td colspan="5" class="empty">Select a site above.</td></tr>
        </tbody>
    </table>
</div>

<div class="modal-overlay" id="probeModal" onclick="if(event.target===this)closeProbe()">
    <div class="modal-box">
        <div class="modal-head">
            <h3 id="probeTitle">Probe</h3>
            <button class="modal-x" onclick="closeProbe()">&times;</button>
        </div>
        <div class="modal-body" id="probeBody"></div>
    </div>
</div>

<script>
z

const SITE = um0  ;

function el(id) { return document.getElementById(id); }
function txt(v) { return (v === null || v === undefined || v === '') ? 'â€”' : String(v); }

function badge(status) {
    if (status === 'online') return '<span class="badge online">ONLINE</span>';
    if (status === 'assigned_missing') return '<span class="badge offline">BRIDGE MISSING</span>';
    return '<span class="badge unassigned">UNASSIGNED</span>';
}

function chip(cls, v, l) {
    return `<div class="chip ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
}

function renderChips(s) {
    el('chips').innerHTML =
        chip('online', s.online, 'Online') +
        chip('unassigned', s.unassigned, 'Unassigned') +
        chip('offline', s.assigned_missing, 'Bridge missing') +
        chip('roamed', s.roamed, 'Roamed') +
        chip('nobridge', s.no_bridge, 'No bridge') +
        chip('', s.gens_live, 'Gens live');
}

function ageStr(sec) {
    if (sec === null || sec === undefined) return '';
    if (sec < 90) return Math.round(sec) + 's';
    if (sec < 5400) return Math.round(sec / 60) + 'm';
    return (sec / 3600).toFixed(1) + 'h';
}

function renderScan(d) {
    const s = d.summary;
    let note = `Scanned ${s.peplinks_total} peplink(s)`;
    if (s.peplinks_unreachable > 0) {
        const bad = (d.peplinks_scanned || []).filter(p => !p.reachable)
            .map(p => p.router_ip + ' (' + p.label + ')').join(', ');
        note += ` Â· <span class="warn">${s.peplinks_unreachable} unreachable: ${bad} â€” bridges there can't be seen</span>`;
    }
    el('scanNote').innerHTML = note;
    el('asOf').textContent = 'as of ' + new Date(d.fetched_at).toLocaleTimeString();
}

function renderNoBridge(list) {
    const tb = el('nbRows');
    if (!list || !list.length) {
        tb.innerHTML = '<tr><td colspan="5" class="empty">Every gen at this site has a working bridge.</td></tr>';
        el('nbNote').innerHTML = '';
        return;
    }
    const never = list.filter(r => r.ever_seen === false).length;
    el('nbNote').innerHTML = `${list.length} gen(s) with no bridge reporting Â· ` +
        `<span class="warn">${never} never had one</span> â€” these are install candidates. ` +
        `Out-of-service gens and the Spares site are excluded.`;
    tb.innerHTML = list.map(r => {
        const badgeHtml = r.ever_seen === false
            ? '<span class="badge nobridge">NO BRIDGE</span>'
            : '<span class="badge wasthere">NOT REPORTING</span>';
        const grp = r.group ? `${txt(r.site)} / ${txt(r.group)}` : txt(r.site);
        const mac = r.missing_mac
            ? '<span class="warn">no mac in config</span>'
            : `<span class="mono">${txt(r.gen_mac)}</span>`;
        const notes = [];
        if (r.ever_seen === false) notes.push('<span class="dim">no telemetry on record â€” bridge likely never fitted</span>');
        else if (r.ever_seen === null) notes.push('<span class="dim">gen_ingest unreachable â€” cannot tell never-fitted from not-reporting</span>');
        else notes.push(`<span class="dim">last reported ${ageStr(r.last_seen_sec)} ago</span>`);
        if (r.missing_mac) notes.push('<span class="warn">set the gen mac or it can never resolve</span>');
        if (r.site_scanned === false) notes.push('<span class="dim">site has no live gen, peps not scanned</span>');
        return `<tr><td>${badgeHtml}</td><td><span class="mono">${txt(r.gen_id)}</span></td>` +
               `<td>${grp}</td><td>${mac}</td><td>${notes.join('<br>')}</td></tr>`;
    }).join('');
}

function renderRows(bridges) {
    if (!bridges || !bridges.length) {
        el('rows').innerHTML = '<tr><td colspan="9" class="empty">No bridges found for this site.</td></tr>';
        return;
    }
    el('rows').innerHTML = bridges.map(b => {
        const ip = b.ip
            ? `<a class="iplink mono" href="http://${b.ip}" target="_blank" rel="noopener" title="Open bridge web admin (VPN only)">${b.ip}</a>`
            : '<span class="dim">â€”</span>';
        const pep = b.peplink_ip
            ? `<span class="mono">${b.peplink_ip}</span> <span class="dim">${txt(b.peplink_label)}</span>`
            : '<span class="dim">â€”</span>';
        const grp = b.group ? `${txt(b.site)} / ${txt(b.group)}` : txt(b.site);
        // Gen cell: id + live run state underneath
        let genCell = '<span class="dim">â€”</span>';
        if (b.gen_id) {
            let st = '';
            if (b.run_state) {
                const cls = b.run_state === 'Running' ? 'st-run'
                          : b.run_state === 'Down' ? 'st-down' : 'st-unk';
                st = `<div class="sub ${cls}">${txt(b.run_state)}</div>`;
            }
            genCell = `<span class="mono">${txt(b.gen_id)}</span>${st}`;
        }
        let notes = [];
        if (b.roamed) notes.push(`<span class="tag">ROAMED off ${txt(b.group)}</span>`);
        if (b.gen_stale) notes.push(`<span class="tag stale">gen telemetry stale ${ageStr(b.gen_age_sec)}</span>`);
        if (b.status === 'assigned_missing') notes.push('<span class="dim">gen is reporting via this bridge IP, but no bridge client was seen on the pep scan â€” its peplink may be unreachable, or the gen telemetry is stale</span>');
        if (b.status === 'unassigned') {
            notes.push('<span class="dim">online at pep, no gen reports this IP</span>');
            if (b.config_gen) notes.push(`<span class="dim">Â· config maps this MAC to ${txt(b.config_gen)}</span>`);
        }
        if (b.connection && b.connection !== 'wireless') notes.push(`<span class="dim">(${b.connection})</span>`);
        const probeBtn = b.ip
            ? `<button class="probe-btn" onclick="probe('${b.ip}')" title="Modbus-read the gen controller MAC (reg 18020) behind this bridge">MAC</button>
               <button class="probe-btn dev" onclick="deviceProbe('${b.ip}')" title="Read the bridge DHCP lease â€” which device is on the bulkhead (Mesa router vs DSE cell adapter)">Device</button>`
            : '<span class="dim">â€”</span>';
        return `<tr>
            <td>${badge(b.status)}</td>
            <td>${genCell}</td>
            <td>${grp}</td>
            <td class="mono">${txt(b.bridge_mac)}</td>
            <td>${ip}</td>
            <td>${pep}</td>
            <td>${txt(b.client_name)}</td>
            <td>${notes.join(' ') || '<span class="dim">â€”</span>'}</td>
            <td>${probeBtn}</td>
        </tr>`;
    }).join('');
}

function closeProbe() { el('probeModal').classList.remove('show'); }

function deviceProbe(ip) {
    const body = el('probeBody');
    el('probeTitle').textContent = 'Gen Device (DHCP lease)';
    el('probeModal').classList.add('show');
    body.innerHTML = `<div class="dim">Reading DHCP lease on bridge <span class="mono">${ip}</span>â€¦</div>`;
    fetch('/api/peplink/bridge_lease?ip=' + encodeURIComponent(ip))
        .then(r => r.json())
        .then(d => {
            if (!d.success) { body.innerHTML = `<div class="res-bad">Error: ${txt(d.error)}</div>`; return; }
            if (!d.bridge_reachable) {
                body.innerHTML = `<div class="kv"><b>Bridge:</b> <span class="mono">${ip}</span></div>
                    <div class="res-warn" style="margin-top:12px">Bridge RouterOS API unreachable</div>
                    <div class="kv">${txt(d.error)} â€” its peplink may be down.</div>`;
                return;
            }
            let verdict;
            if (d.classification === 'mesa')
                verdict = `<div class="res-ok">âœ“ Mesa router â€” correct device</div>`;
            else if (d.classification === 'dse')
                verdict = `<div class="res-bad">âœ— DSE cell adapter â€” MIS-WIRED</div>
                    <div class="kv">The bulkhead is on the old DSE cell adapter, not the Mesa router â€” that's why Modbus (:502) is dead and the gen is dark. Move the bulkhead to the Mesa router.</div>`;
            else if (d.classification === 'none')
                verdict = `<div class="res-warn">âš  Nothing leased on .88.2</div>
                    <div class="kv">No gen-side device is pulling DHCP behind the bridge â€” device off, unplugged, or ether1 link down.</div>`;
            else
                verdict = `<div class="res-warn">âš  Unknown device â€” ${txt(d.label)}</div>`;
            const meta = [];
            if (d.host) meta.push(`<div class="kv"><b>Host-name:</b> ${txt(d.host)}</div>`);
            if (d.mac) meta.push(`<div class="kv"><b>MAC:</b> <span class="mono">${txt(d.mac)}</span></div>`);
            meta.push(`<div class="kv"><b>Lease:</b> ${txt(d.lease_status)} Â· last seen ${txt(d.last_seen)} Â· ARP ${txt(d.arp)}</div>`);
            body.innerHTML = `
                <div class="kv"><b>Bridge:</b> <span class="mono">${ip}</span> â†’ .88.2 device</div>
                <div style="margin:12px 0">${verdict}</div>
                ${meta.join('')}`;
        })
        .catch(() => { body.innerHTML = '<div class="res-bad">Request failed.</div>'; });
}

function probe(ip) {
    const body = el('probeBody');
    el('probeTitle').textContent = 'Probe Gen MAC';
    el('probeModal').classList.add('show');
    body.innerHTML = `<div class="dim">Probing <span class="mono">${ip}</span> â€” Modbus read of reg 18020 through the bridgeâ€¦</div>`;
    fetch('/api/gen_control/probe_mac?ip=' + encodeURIComponent(ip))
        .then(r => r.status === 403 ? {_forbidden: true} : r.json())
        .then(d => {
            if (d._forbidden) {
                body.innerHTML = '<div class="res-bad">Not authorized to probe (needs gen_control or bridge_status access).</div>';
                return;
            }
            if (!d.success) {
                body.innerHTML = `<div class="res-bad">Error: ${txt(d.error)}</div>`;
                return;
            }
            if (!d.reachable) {
                body.innerHTML = `<div class="kv"><b>Bridge:</b> <span class="mono">${ip}</span></div>
                    <div class="res-warn" style="margin-top:12px">No Modbus answer on :502</div>
                    <div class="kv">${txt(d.note)}</div>`;
                return;
            }
            let verdict;
            if (d.match) {
                verdict = `<div class="res-ok">âœ“ Matches configured gen ${txt(d.match.gen_id)}</div>
                    <div class="kv"><b>${txt(d.match.site)} / ${txt(d.match.group)}</b> â€” this bridge is plugged into ${txt(d.match.gen_id)}.</div>`;
            } else {
                verdict = `<div class="res-warn">âš  NOT IN ROSTER</div>
                    <div class="kv">This MAC isn't stored on any configured gen. Whichever gen this bridge feeds has the wrong/changed <b>mac</b> â€” set that gen's <b>mac</b> to this value in Site Manager and it'll resolve on the next config pull.</div>`;
            }
            body.innerHTML = `
                <div class="kv"><b>Bridge:</b> <span class="mono">${ip}</span></div>
                <div class="kv"><b>Controller MAC (reg 18020):</b></div>
                <div class="macbig">${txt(d.mac_pretty || d.mac)}</div>
                ${verdict}`;
        })
        .catch(() => { body.innerHTML = '<div class="res-bad">Request failed.</div>'; });
}

function load(refresh) {
    if (!SITE) return;
    const btn = el('refreshBtn');
    btn.disabled = true;
    const q = new URLSearchParams({ site: SITE });
    if (refresh) q.set('refresh', '1');
    fetch('/api/peplink/gen_bridges?' + q.toString())
        .then(r => r.json())
        .then(d => {
            if (!d.success) {
                el('rows').innerHTML = '<tr><td colspan="9" class="empty">' +
                    (d.error || 'Failed to load') + '</td></tr>';
                el('chips').innerHTML = '';
                el('scanNote').innerHTML = '';
                el('nbRows').innerHTML = '<tr><td colspan="5" class="empty">â€”</td></tr>';
                return;
            }
            renderChips(d.summary);
            renderScan(d);
            renderRows(d.bridges);
            renderNoBridge(d.gens_without_bridge);
        })
        .catch(() => {
            el('rows').innerHTML = '<tr><td colspan="9" class="empty">Error contacting peplink API.</td></tr>';
        })
        .finally(() => { btn.disabled = false; });
}

if (SITE) {
    load(false);
    setInterval(() => load(false), 60000);
} else {
    el('refreshBtn').disabled = true;
}
</script>
</body>
</html>)+ÚcgiÚsysÚpathÚinsertÚmanagers.auth_managerr   r   ÚFieldStorageÚ_formÚ_authÚrequire_authÚ_auth_requiredÚ_should_exitÚ_headersÚprintÚexitÚcgitbÚhtmlr   ÚenableÚlinksr   r   r   Úurllib.requestr   r   r+   Úall_gen_sitesÚsites_with_bridgesÚgetvaluer(   ÚescapeÚescÚformatÚoptsr    Úsr0   ÚjoinÚsite_optionsÚget_user_accessÚdumpsr   r   r   r   Ú<module>   sx   
ÿ



ÿÿ
ÿ
c�
j–rŽ  �â   �à