o
    F<¡jƒ€ ã                   @   s.  d Z ddlZddlZej dd¡ ej dd¡ ddlmZmZmZ ddl	m
Z
mZmZ e ¡ ZedƒZe e¡\ZZZer\edƒ erHeeƒ ed	ƒ erSed
ƒ nedƒ e d¡ edƒ eruedƒ eedƒƒ edƒ e d¡ e ¡ Zedeƒ  d ƒ edƒ ee
eƒƒ edeƒ  d ƒ dS )uù  
Cams page â€” Blink camera viewer.

Renders cameras grouped by network with per-camera freshness so an operator can
tell at a glance whether an image is current before trusting it (e.g. to confirm
a site is clear ahead of a remote gen restart). Cached thumbnails load instantly;
a fresh snap can be taken per-camera or per-site on demand.

All upstream Blink traffic is proxied via /api/blink which in turn hits the field
server (cherub); cloud datacenter IPs are Cloudflare-blocked from Blink directly.
é    Nz/opt/ngon/appsz/var/www/html/ngon)ÚAuthManagerÚgenerate_login_modal_htmlÚgenerate_access_denied_html)Úgenerate_dropdown_htmlÚgenerate_dropdown_cssÚgenerate_dropdown_jsÚcamszContent-Type: application/jsonÚ z6{"success": false, "error": "Authentication required"}z{"success": true}zContent-Type: text/html
z;<!DOCTYPE html><html><head><title>Cams</title></head><body>ÚCamsz</body></html>a  <!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>NGON Cams</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
    <style>
a»I  
        * { box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: #1a1a1a;
            color: #e0e0e0;
            margin: 0;
            padding: 0;
            line-height: 1.4;
        }
        .header {
            background-color: rgba(255,255,255,0.05);
            border-radius: 8px;
            padding: 30px;
            margin: 20px;
            border: 1px solid rgba(255,255,255,0.1);
            display: flex;
            justify-content: space-between;
            align-items: flex-start;
            flex-wrap: wrap;
            gap: 16px;
        }
        .header h1.dropdown-title {
            font-size: 2.2em;
            font-weight: 600;
            margin: 3px 0 0 0;
            line-height: 1.2;
            color: #00ff00;
        }
        .header-meta { color: #888; font-size: 12px; margin-top: 6px; }
        .header-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
        .header-controls label { font-size: 12px; color: #aaa; display: flex; align-items: center; gap: 6px; cursor: pointer; }
        .btn {
            background: #2a2a2a;
            border: 1px solid #3a3a3a;
            color: #e0e0e0;
            padding: 8px 14px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 13px;
        }
        .btn:hover { border-color: #00ff00; }
        .btn-primary { border-color: #00ff00; color: #00ff00; }
        .btn-danger { border-color: #ff4444; color: #ff4444; }
        .btn:disabled { opacity: 0.5; cursor: wait; }

        /* ---- health strip ---- */
        .health-strip {
            display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
            padding: 10px 14px; margin: 0 20px 14px; border-radius: 6px;
            background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
            font-size: 13px;
        }
        .health-strip .pill { padding: 3px 10px; border-radius: 4px; font-weight: 600; white-space: nowrap; }
        .pill.good    { background: rgba(0,255,0,0.15);   color: #00ff00; }
        .pill.warn    { background: rgba(255,170,0,0.18); color: #ffaa00; }
        .pill.bad     { background: rgba(255,68,68,0.18); color: #ff4444; }
        .pill.missing { background: rgba(170,68,255,0.18); color: #c07cff; }
        .pill.dim     { background: rgba(136,136,136,0.15); color: #999; }
        .pill.neutral { background: rgba(255,255,255,0.08); color: #aaa; }
        .health-strip .sep { color: #444; }
        .health-strip .hint { color: #777; font-size: 12px; }

        .content { padding: 0 20px 20px; }
        .net-section { margin-bottom: 28px; }
        .net-header {
            font-size: 15px;
            color: #fff;
            margin: 0 0 10px 0;
            padding: 6px 10px;
            border-left: 3px solid #00ff00;
            background: rgba(255,255,255,0.03);
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 10px;
        }
        .net-header.has-stale { border-left-color: #ff4444; }
        .net-header .net-name { font-weight: 600; }
        .net-header .net-left { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
        .net-header .net-count { font-size: 12px; color: #888; font-weight: 400; }
        .net-header .net-stale { font-size: 11px; color: #ff6666; font-weight: 600; }
        .net-header .net-offline { font-size: 11px; color: #ff6666; font-weight: 700;
            background: rgba(255,68,68,0.18); padding: 1px 7px; border-radius: 3px; }
        .net-header .btn { padding: 5px 10px; font-size: 12px; }
        .net-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
            gap: 14px;
        }
        .tile {
            background: rgba(255,255,255,0.04);
            border: 1px solid rgba(255,255,255,0.1);
            border-radius: 6px;
            overflow: hidden;
            display: flex;
            flex-direction: column;
        }
        .tile.fresh { border-color: rgba(0,255,0,0.30); }
        .tile.aging { border-color: rgba(255,170,0,0.45); }
        .tile.stale, .tile.never { border-color: rgba(255,68,68,0.55); }
        /* Missing from Blink's inventory: a different failure class from "old
           photo" -- we can't even see the camera, so it gets its own colour. */
        .tile.missing { border-color: rgba(170,68,255,0.65); background: rgba(170,68,255,0.05); }
        /* Second live record for one camera: labelled, not hidden -- both look
           alive, so either may be the one that answers a snap. */
        .tile.dup { border-color: #444; }
        .tile-img-wrap {
            position: relative;
            background: #0e0e0e;
            aspect-ratio: 16 / 9;
            display: flex;
            align-items: center;
            justify-content: center;
            overflow: hidden;
        }
        .tile-img-wrap img {
            width: 100%;
            height: 100%;
            object-fit: cover;
            display: block;
        }
        /* Stale images read as untrustworthy: washed out. */
        .tile.stale .tile-img-wrap img,
        .tile.never .tile-img-wrap img { filter: grayscale(0.5) brightness(0.6); }
        .tile-img-wrap .placeholder {
            color: #666;
            font-size: 12px;
            text-align: center;
            padding: 12px;
            display: flex;
            flex-direction: column;
            gap: 3px;
            align-items: center;
            justify-content: center;
        }
        .tile-img-wrap .placeholder.stale-ph .ph-icon { font-size: 24px; color: #ff5555; opacity: 0.85; margin-bottom: 2px; }
        .tile-img-wrap .placeholder .ph-title { font-size: 13px; color: #ddd; font-weight: 600; }
        .tile-img-wrap .placeholder .ph-reason { font-size: 12px; color: #ff8888; }
        .tile-img-wrap .placeholder .ph-sub { font-size: 11px; color: #888; }
        .tile-img-wrap .placeholder .ph-hint { font-size: 10px; color: #666; font-style: italic; }
        .tile-img-wrap.busy::after {
            content: '';
            position: absolute;
            inset: 0;
            background: rgba(0,0,0,0.5) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20" fill="none" stroke="%2300ff00" stroke-width="4" stroke-dasharray="80" stroke-dashoffset="60"><animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/></circle></svg>') center / 40px no-repeat;
        }
        /* freshness badge, top-left of the image */
        .age-badge {
            position: absolute; top: 6px; left: 6px;
            padding: 2px 7px; border-radius: 3px;
            font-size: 10px; font-weight: 700; letter-spacing: 0.02em;
            background: rgba(0,0,0,0.65);
        }
        .age-badge.fresh { color: #00ff00; }
        .age-badge.aging { color: #ffaa00; }
        .age-badge.stale { color: #ff5555; }
        .age-badge.never { color: #ff5555; }
        .age-badge.missing { color: #c07cff; }
        .chip.missing { background: rgba(170,68,255,0.18); color: #c07cff; }
        .chip.dup { background: rgba(136,136,136,0.18); color: #aaa; }
        .chip.net { background: rgba(255,255,255,0.06); color: #888; }
        .chip.ptz { background: rgba(0,255,0,0.14); color: #00ff00; font-weight: 600; }
        .ptz-inner { width: min(1200px, 96vw); max-height: 94vh; display: flex; flex-direction: column;
                     background: #111; border: 1px solid #333; border-radius: 8px; padding: 10px; gap: 8px; }
        .ptz-stage { position: relative; background: #000; border-radius: 6px; overflow: hidden;
                     display: flex; align-items: center; justify-content: center; min-height: 300px; }
        .ptz-stage video, .ptz-stage video-stream { width: 100%; max-height: 68vh; display: block; }
        .ptz-overlay { position: absolute; inset: 0; display: flex; align-items: center;
                       justify-content: center; background: rgba(0,0,0,0.55); }
        .ptz-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
        .ptz-pad { display: grid; grid-template-columns: repeat(3, 38px); grid-template-rows: repeat(3, 34px);
                   gap: 3px; }
        .ptz-pad button:nth-child(1) { grid-area: 1 / 2; }
        .ptz-pad button:nth-child(2) { grid-area: 2 / 1; }
        .ptz-pad button:nth-child(3) { grid-area: 2 / 2; }
        .ptz-pad button:nth-child(4) { grid-area: 2 / 3; }
        .ptz-pad button:nth-child(5) { grid-area: 3 / 2; }
        .ptz-pad button, .ptz-zoom button {
            background: #1e1e1e; border: 1px solid #3a3a3a; color: #ddd; border-radius: 5px;
            cursor: pointer; font-size: 13px; padding: 6px 10px;
        }
        .ptz-pad button:hover, .ptz-zoom button:hover { background: #2b2b2b; border-color: #00ff00; color: #00ff00; }
        .ptz-pad button:active, .ptz-zoom button:active { background: #0a3a0a; }
        .ptz-zoom { display: flex; flex-direction: column; gap: 3px; }
        .ptz-speed { font-size: 11px; color: #888; display: flex; align-items: center; gap: 6px; }
        .ptz-speed input { width: 90px; }
        .ptz-presets { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
        .ptz-presets .none { font-size: 11px; color: #666; font-style: italic; }
        .ptz-status { font-size: 11px; color: #888; min-height: 15px; }
        .ptz-status .err { color: #ff5555; }
        .ptz-mode, .ptz-lamp { display: flex; gap: 4px; }
        .ptz-mode .btn.active, .ptz-lamp .btn.active { border-color: #00ff00; color: #00ff00; }
        .ptz-stage img.frames { width: 100%; max-height: 68vh; display: block; object-fit: contain; }
        .tile.ptz-tile { border-color: rgba(0,255,0,0.45); }
        .chip.renamed { background: rgba(74,158,255,0.15); color: #4a9eff; }
        .net-renamed { font-size: 11px; color: #888; font-style: italic; margin-left: 6px; }
        .net-subs { font-size: 11px; color: #777; margin-left: 8px; }
        .missing-row {
            display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
            margin: 4px 0 10px; padding: 6px 10px; font-size: 12px;
            border: 1px solid rgba(170,68,255,0.35); border-radius: 6px;
            background: rgba(170,68,255,0.06); color: #c07cff;
        }
        .missing-row .mr-names { color: #999; }
        .missing-row .mr-toggle {
            margin-left: auto; cursor: pointer; text-decoration: underline;
            color: #4a9eff; user-select: none;
        }
        .tile-img-wrap .placeholder.missing-ph .ph-icon { color: #c07cff; }
        .tile-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
        .tile-name { font-size: 14px; color: #fff; font-weight: 600; }
        .tile-meta {
            font-size: 11px;
            color: #888;
            display: flex;
            gap: 8px;
            align-items: center;
            flex-wrap: wrap;
        }
        .chip { padding: 1px 6px; border-radius: 3px; font-size: 10px; }
        .chip.battery.ok { background: rgba(0,255,0,0.15); color: #00ff00; }
        .chip.battery.watch { background: rgba(255,204,0,0.18); color: #ffcc00; }
        .chip.battery.low { background: rgba(255,102,0,0.18); color: #ff6600; }
        .chip.battery.other { background: rgba(255,255,255,0.08); color: #aaa; }
        .chip.nolink { background: rgba(255,68,68,0.18); color: #ff6666; }
        .chip.offline { background: rgba(255,68,68,0.18); color: #ff6666; }
        .chip.sync { background: rgba(255,255,255,0.08); color: #999; }
        .chip.sync.stale { background: rgba(255,170,0,0.16); color: #ffaa00; }
        .fail-reason {
            font-size: 10px; color: #ff8888;
            background: rgba(255,68,68,0.08);
            border-left: 2px solid rgba(255,68,68,0.5);
            padding: 3px 6px; border-radius: 2px;
            word-break: break-word;
        }
        .tile-actions { display: flex; gap: 6px; margin-top: 4px; }
        .tile-actions .btn { padding: 5px 10px; font-size: 12px; flex: 1; }
        .tile-img-wrap .source-pill {
            position: absolute;
            bottom: 6px;
            right: 6px;
            background: rgba(0,0,0,0.55);
            color: #ccc;
            padding: 2px 6px;
            border-radius: 3px;
            font-size: 10px;
        }
        /* ---- lightbox ---------------------------------------------------
           Tiles crop to 16:9 with object-fit:cover, so the grid hides the
           edges of the frame. The lightbox shows the whole 1280x720 capture
           uncropped and undesaturated -- that's the view you actually want
           when checking whether a site is clear. */
        .tile-img-wrap.has-frame { cursor: zoom-in; }
        .lightbox {
            position: fixed; inset: 0; z-index: 9000;
            background: rgba(0,0,0,0.88);
            display: flex; align-items: center; justify-content: center;
            padding: 20px;
        }
        .lb-inner {
            display: flex; flex-direction: column;
            max-width: 1320px; max-height: 100%; width: 100%;
            gap: 10px;
        }
        .lb-top {
            display: flex; align-items: center; justify-content: space-between;
            gap: 12px; flex-wrap: wrap;
        }
        .lb-title { font-size: 17px; color: #fff; font-weight: 600; }
        .lb-net { color: #888; font-size: 13px; font-weight: 400; margin-left: 10px; }
        .lb-tools { display: flex; gap: 8px; }
        .lb-stage {
            position: relative;
            flex: 1; min-height: 0;
            display: flex; align-items: center; justify-content: center;
            background: #0a0a0a;
            border: 1px solid rgba(255,255,255,0.10);
            border-radius: 6px;
            overflow: hidden;
        }
        .lb-stage img {
            max-width: 100%; max-height: 78vh;
            object-fit: contain;   /* whole frame, no crop */
            display: block;
        }
        .lb-stage.busy::after {
            content: '';
            position: absolute; inset: 0;
            background: rgba(0,0,0,0.5) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20" fill="none" stroke="%2300ff00" stroke-width="4" stroke-dasharray="80" stroke-dashoffset="60"><animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/></circle></svg>') center / 48px no-repeat;
        }
        .lb-nav {
            position: absolute; top: 50%; transform: translateY(-50%);
            background: rgba(0,0,0,0.55);
            border: 1px solid rgba(255,255,255,0.15);
            color: #ddd; font-size: 22px; line-height: 1;
            padding: 14px 12px; border-radius: 4px;
            cursor: pointer;
        }
        .lb-nav:hover { background: rgba(0,0,0,0.8); color: #fff; }
        .lb-nav.prev { left: 10px; }
        .lb-nav.next { right: 10px; }
        .lb-meta {
            font-size: 12px; color: #999;
            display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
        }
        .lb-sep { color: #444; }
        .lb-err { color: #ff8888; }
        .lb-hint { color: #666; font-size: 11px; margin-left: auto; }
        @media (max-width: 700px) {
            .lightbox { padding: 10px; }
            .lb-nav { padding: 10px 8px; font-size: 18px; }
        }
        .empty-state { color: #888; padding: 40px; text-align: center; }
        .error-banner {
            background: rgba(255,68,68,0.12);
            border: 1px solid rgba(255,68,68,0.4);
            color: #ff8888;
            padding: 12px 18px;
            border-radius: 6px;
            margin: 0 20px 14px;
        }

        /* ---- arrange (reorder sites) modal ---- */
        .arrange-modal {
            position: fixed; inset: 0; z-index: 9500;
            background: rgba(0,0,0,0.75);
            display: flex; align-items: center; justify-content: center;
            padding: 20px;
        }
        .arrange-box {
            background: #161616;
            border: 1px solid rgba(255,255,255,0.12);
            border-radius: 8px;
            width: 100%; max-width: 460px; max-height: 85vh;
            display: flex; flex-direction: column;
            box-shadow: 0 10px 40px rgba(0,0,0,0.6);
        }
        .arrange-head {
            display: flex; align-items: center; justify-content: space-between;
            padding: 16px 18px; border-bottom: 1px solid rgba(255,255,255,0.08);
        }
        .arrange-title { font-size: 17px; font-weight: 600; color: #fff; }
        .arrange-hint { color: #888; font-size: 12px; padding: 10px 18px 4px; }
        .arrange-list {
            list-style: none; margin: 0; padding: 8px 12px;
            overflow-y: auto; flex: 1;
        }
        .arrange-row {
            display: flex; align-items: center; gap: 10px;
            background: rgba(255,255,255,0.04);
            border: 1px solid rgba(255,255,255,0.08);
            border-radius: 6px; padding: 9px 10px; margin-bottom: 7px;
            cursor: grab; user-select: none;
        }
        .arrange-row.dragging { opacity: 0.4; }
        .arrange-row.drop-target { border-color: #00ff00; }
        .arrange-row .grip { color: #666; font-size: 14px; cursor: grab; }
        .arrange-row .arrange-name { flex: 1; color: #eee; font-size: 14px; }
        .arrange-row .region-tag {
            font-size: 10px; font-weight: 700; letter-spacing: 0.03em;
            padding: 1px 6px; border-radius: 3px;
            background: rgba(255,255,255,0.08); color: #aaa;
        }
        .arrange-row .region-tag.nd { background: rgba(0,255,0,0.14); color: #00ff00; }
        .arrange-row .region-tag.tx { background: rgba(74,158,255,0.16); color: #4a9eff; }
        .arrange-row .region-tag.office { background: rgba(255,170,0,0.16); color: #ffaa00; }
        .arrange-row .move { display: flex; flex-direction: column; gap: 1px; }
        .arrange-row .move button {
            background: #2a2a2a; border: 1px solid #3a3a3a; color: #ccc;
            width: 22px; height: 15px; line-height: 1; font-size: 9px;
            border-radius: 3px; cursor: pointer; padding: 0;
        }
        .arrange-row .move button:hover:not(:disabled) { border-color: #00ff00; color: #00ff00; }
        .arrange-row .move button:disabled { opacity: 0.3; cursor: default; }
        .arrange-actions {
            display: flex; align-items: center; gap: 8px;
            padding: 14px 18px; border-top: 1px solid rgba(255,255,255,0.08);
        }
        .arrange-actions .spacer { flex: 1; }
z­    </style>
</head>
<body>
<div class="header">
    <div class="dropdown">
        <h1 class="dropdown-title">NGON Mining - Cams</h1>
        <div class="dropdown-content">um.         </div>
        <div class="header-meta" id="headerMeta">Loadingâ€¦</div>
    </div>
    <div class="header-controls">
        <label><input type="checkbox" id="problemsOnly" onchange="render()"> Problems only</label>
        <button class="btn" onclick="openArrange()" id="btnArrange"><i class="fas fa-sort"></i> Arrange</button>
        <button class="btn" onclick="refreshAll()" id="btnRefresh"><i class="fas fa-sync"></i> Refresh List</button>
    </div>
</div>

<div id="healthStrip" class="health-strip" style="display:none;"></div>
<div class="content">
    <div id="errorBanner" style="display:none;" class="error-banner"></div>
    <div id="grid"></div>
</div>

<div id="arrangeModal" class="arrange-modal" style="display:none;">
    <div class="arrange-box">
        <div class="arrange-head">
            <div class="arrange-title">Arrange sites</div>
            <button class="btn" id="arrangeClose"><i class="fas fa-times"></i></button>
        </div>
        <div class="arrange-hint">Drag the rows (or use the â–²â–¼ arrows) to set the site order. Saved for everyone.</div>
        <ul id="arrangeList" class="arrange-list"></ul>
        <div class="arrange-actions">
            <button class="btn" id="arrangeReset" title="Discard the manual order and follow the status page's site order">Reset to auto</button>
            <div class="spacer"></div>
            <button class="btn" id="arrangeCancel">Cancel</button>
            <button class="btn btn-primary" id="arrangeSave">Save order</button>
        </div>
    </div>
</div>

<div id="ptzView" class="lightbox" style="display:none;">
    <div class="ptz-inner">
        <div class="lb-top">
            <div class="lb-title"><span id="ptzName"></span><span class="lb-net" id="ptzSite"></span></div>
            <button class="btn" id="ptzClose"><i class="fas fa-times"></i></button>
        </div>
        <div class="ptz-stage" id="ptzStage">
            <div id="ptzOverlay" class="ptz-overlay" style="display:none;">
                <button class="btn btn-primary" id="ptzPlay"><i class="fas fa-play"></i> Play live</button>
            </div>
        </div>
        <div class="ptz-controls">
            <div class="ptz-pad">
                <button data-pan="0"    data-tilt="1"  title="Up"><i class="fas fa-chevron-up"></i></button>
                <button data-pan="-1"   data-tilt="0"  title="Left"><i class="fas fa-chevron-left"></i></button>
                <button data-stop="1"   title="Stop"><i class="fas fa-stop"></i></button>
                <button data-pan="1"    data-tilt="0"  title="Right"><i class="fas fa-chevron-right"></i></button>
                <button data-pan="0"    data-tilt="-1" title="Down"><i class="fas fa-chevron-down"></i></button>
            </div>
            <div class="ptz-zoom">
                <button data-zoom="1" title="Zoom in"><i class="fas fa-search-plus"></i></button>
                <button data-zoom="-1" title="Zoom out"><i class="fas fa-search-minus"></i></button>
            </div>
            <div class="ptz-speed">
                speed <input type="range" id="ptzSpeed" min="10" max="100" value="50">
            </div>
            <button id="ptzWiper" class="btn" title="Sweep the lens wiper once">
                <i class="fas fa-tint"></i> Wiper
            </button>
            <div class="ptz-lamp" id="ptzIrcut">
                <button class="btn" data-ir="auto" title="Camera decides (default)">Auto</button>
                <button class="btn" data-ir="colour" title="Force colour â€” IR-cut filter in place">Colour</button>
                <button class="btn" data-ir="ir" title="Force IR night mode â€” monochrome">IR</button>
            </div>
            <div class="ptz-mode">
                <button id="ptzModeFrames" class="btn">Frames</button>
                <button id="ptzModeLive" class="btn">HD live</button>
            </div>
            <div class="spacer"></div>
            <div id="ptzPresets" class="ptz-presets"></div>
        </div>
        <div id="ptzStatus" class="ptz-status"></div>
    </div>
</div>

<div id="lightbox" class="lightbox" style="display:none;">
    <div class="lb-inner">
        <div class="lb-top">
            <div class="lb-title"><span id="lbName"></span><span class="lb-net" id="lbNet"></span></div>
            <div class="lb-tools">
                <button class="btn btn-primary" id="lbSnap"><i class="fas fa-camera"></i> Snap</button>
                <button class="btn" id="lbClose"><i class="fas fa-times"></i> Close</button>
            </div>
        </div>
        <div class="lb-stage" id="lbStage">
            <button class="lb-nav prev" id="lbPrev" title="Previous camera (â†�)">&#10094;</button>
            <img id="lbImg" alt="">
            <button class="lb-nav next" id="lbNext" title="Next camera (â†’)">&#10095;</button>
        </div>
        <div class="lb-meta" id="lbMeta"></div>
    </div>
</div>

<script>
// Freshness thresholds (seconds). Daily snap cadence means a healthy image can
// legitimately be up to ~a day old, so "fresh" spans a full daily cycle.
const FRESH_MAX_SEC = 26 * 3600;   // green: within the daily cycle
const AGING_MAX_SEC = 50 * 3600;   // amber: missed ~1 daily run

const grid = document.getElementById('grid');
const headerMeta = document.getElementById('headerMeta');
const errorBanner = document.getElementById('errorBanner');
const healthStrip = document.getElementById('healthStrip');
const problemsOnly = document.getElementById('problemsOnly');

let LAST_DATA = null;  // cache last /cameras payload so filter toggle re-renders without a fetch
let RENDERED_CAMS = [];  // cameras currently on screen, in display order (drives lightbox nav)
let CUSTOM_ORDER = [];  // operator-defined site order (network names); empty = auto geographic sort

function esc(s) {
    return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
}

function showError(msg) {
    errorBanner.textContent = msg;
    errorBanner.style.display = 'block';
}
function clearError() { errorBanner.style.display = 'none'; }

function fmtAge(sec) {
    if (sec == null) return 'â€”';
    sec = Math.max(0, Math.floor(sec));
    if (sec < 90) return sec + 's';
    if (sec < 3600) return Math.round(sec / 60) + 'm';
    if (sec < 86400) return (sec / 3600).toFixed(sec < 36000 ? 1 : 0) + 'h';
    return (sec / 86400).toFixed(sec < 864000 ? 1 : 0) + 'd';
}

// True age of the frame the camera actually captured. Prefer capture_ts (the
// camera's ?ts) over snap_age_sec (when WE downloaded it).
function trueAgeSec(cam) {
    if (isPtz(cam)) return cam.snap_age_sec;   // fetched live; download time == capture time
    if (cam.capture_age_sec != null) return cam.capture_age_sec;
    // No capture timestamp. Download age (snap_age_sec) only proxies the photo's
    // real age if the camera could actually be producing fresh frames right now.
    // If it's offline or its last attempt failed, the frame on disk predates the
    // outage and its true age is UNKNOWN â€” don't dress a download time up as a
    // photo age (that's the "winter photo labeled 2.9d old" bug).
    if (isOffline(cam) || isFailing(cam) || isMissing(cam)) return null;
    return cam.snap_age_sec;
}

// Freshness level. A null age with an image on disk = untrusted stale frame
// (offline/failing, unknown age); a null age with no image = never captured.
function levelOf(cam) {
    if (isPtz(cam)) {
        // Judged on reachability, not on a daily-snap cycle: if we can pull a
        // frame it's current by definition, and 26h/50h thresholds are
        // meaningless here.
        if (!cam.ip) return 'stale';
        const a = cam.snap_age_sec;
        if (a == null) return 'never';
        return a < 15 * 60 ? 'fresh' : (a < 60 * 60 ? 'aging' : 'stale');
    }
    const a = trueAgeSec(cam);
    if (a == null) return cam.last_snap_ts ? 'stale' : 'never';
    if (a > AGING_MAX_SEC) return 'stale';
    if (a > FRESH_MAX_SEC) return 'aging';
    return 'fresh';
}

// A capture attempt that happened AND failed more recently than the last good
// image lands â€” the tile is not just old, it's actively failing to update.
function isFailing(cam) {
    if (!cam.last_error) return false;
    const la = cam.last_attempt_age_sec;
    if (la == null) return false;
    return cam.snap_age_sec == null || la < cam.snap_age_sec;
}

// Blink's own per-camera status. 'offline' = the camera isn't reachable for a
// live capture right now â€” THE reason a tile can't get a fresh frame even
// though its battery/sync still report fine. This is a direct signal from
// Blink, not something we infer.
function isOffline(cam) {
    if (isPtz(cam)) return !cam.ip;   // no DHCP lease on the pod LAN = unreachable
    return (cam.status || '').toLowerCase() === 'offline';
}

// The camera has stopped appearing in the field agent's Blink inventory. Almost
// always a sync module that was down when blinkpy built its camera list (whole
// sites drop out at once), occasionally a camera genuinely removed from the
// account. Either way we know NOTHING current about it -- worse than offline,
// because Blink isn't even telling us it exists. The cloud retains it for
// MISSING_DROP_AFTER_SEC precisely so it can't quietly leave the denominator.
function isMissing(cam) { return !!cam.missing_from_inventory; }

// A second Blink record for a camera we already show elsewhere -- Blink keeps
// returning the old record after a camera is re-onboarded onto another sync
// module. The cloud DELETES the twin outright once one record is plainly dead
// (gone from Blink's homescreen / far staler / offline while the other is
// fine), so a chip here means the ambiguous case: both records still look
// alive, so we show both rather than guessing. Matched by serial, the only id
// that survives renames and re-onboarding.
function isDuplicate(cam) { return !!cam.duplicate_of; }

// ONVIF PTZ cameras (ptz_api, port 5024). Nothing like a Blink cam: wired PoE
// on the pod LAN, reachable straight over the tunnel, so there's no inventory
// to go missing, no sync module, no battery, and a frame is always one request
// away. They ride in the same grid because operators want ONE page that
// answers "is it safe to restart this site".
function isPtz(cam) { return !!cam.is_ptz; }

// How often a visible PTZ tile re-pulls its frame. These are always-on and
// hard-wired, and the pods barely touch their Starlink bandwidth, so this is
// about how live the picture should look rather than what the link can take.
const PTZ_REFRESH_MS = 60 * 1000;
function ptzVersion() { return Math.floor(Date.now() / PTZ_REFRESH_MS); }
function ptzSnapUrl(cam, force) {
    return '/api/ptz/snapshot/' + encodeURIComponent(cam.mac)
        + (force ? '?refresh=1&t=' + Date.now() : '?v=' + ptzVersion());
}

// How long to keep saying "was: <old name>" on a tile after a rename. Long
// enough to cover a move week, short enough that names eventually settle.
const RENAME_SHOW_SEC = 14 * 24 * 3600;
function renamedFrom(cam) {
    if (!cam.previous_name) return null;
    if (cam.renamed_age_sec != null && cam.renamed_age_sec > RENAME_SHOW_SEC) return null;
    return cam.previous_name;
}

const SEV = { missing: 5, never: 4, stale: 3, aging: 2, fresh: 1 };
function severity(cam) {
    let s = SEV[levelOf(cam)] || 0;
    if (isFailing(cam) && s < SEV.stale) s = SEV.stale;  // failing bubbles up
    if (isOffline(cam) && s < SEV.stale) s = SEV.stale;  // Blink-offline bubbles up
    if (isMissing(cam)) s = SEV.missing;                 // ... and missing tops all
    if (isDuplicate(cam)) s = 0;                        // stale twin: sort last, never a problem
    return s;
}
function isProblem(cam) { return severity(cam) >= SEV.stale; }  // stale/never/failing/offline

// Battery thresholds on Blink's battery_voltage (populated fleet-wide;
// battery_level is always null, so voltage is the only numeric we get).
//
// Blink's own battery field is a binary ok/low that only flips at the very end
// -- useless for scheduling a battery swap. These cuts are anchored on observed
// fleet data (2026-07-21): the single camera Blink called "low" read 126, the
// next lowest healthy one read 139, and a fresh unit reads ~175. So we warn at
// 142 -- well before Blink would -- to give a window to plan a site visit.
// Tune these as we learn the real discharge curve.
const BATT_LOW_MAX = 132;    // <= this: replace now
const BATT_WATCH_MAX = 142;  // <= this: schedule a swap

function batteryClass(cam) {
    const v = cam.battery_voltage;
    if (v != null) {
        if (v <= BATT_LOW_MAX) return 'low';
        if (v <= BATT_WATCH_MAX) return 'watch';
        return 'ok';
    }
    // No voltage -- fall back to Blink's coarse flag.
    if (cam.battery === 'ok') return 'ok';
    if (cam.battery === 'low') return 'low';
    return 'other';
}

function batteryLabel(cam) {
    const cls = batteryClass(cam);
    const v = cam.battery_voltage;
    if (v == null) return 'batt: ' + (cam.battery || '?');
    const word = cls === 'low' ? 'LOW' : (cls === 'watch' ? 'watch' : 'ok');
    return 'batt: ' + word + ' (' + v + ')';
}

// Geographic ordering of Blink networks so the page reads top-to-bottom like
// the real fleet: North Dakota sites first, then West Texas sites, then the
// Hearne office last. Matched on the Blink network name (case-insensitive
// substring) -- add a keyword here when a new site's sync module comes online.
// Canonical fleet order, served from master_config -- the same key order the
// status page renders sites in, so both pages read top-left to bottom-right in
// the same sequence. Sites we can't place (Office, anything new in Blink but
// not yet in master_config) fall to the end.
let SITE_ORDER = [];
function siteRank(name) {
    const i = SITE_ORDER.indexOf(name);
    return i >= 0 ? i : Infinity;
}

const ND_SITES = ['gn', 'goodnight', 'will', 'willer', 'stout', 'nate', 'dan', 'ww'];
const TX_SITES = ['john', 'osprey', 'fortson', 'walker', 'butz', 'ellyson', 'west tx'];
function regionRank(networkName) {
    const n = (networkName || '').toLowerCase();
    if (n.includes('office')) return 3;               // office -> bottom
    if (ND_SITES.some(k => n.includes(k))) return 0;  // North Dakota -> top
    if (TX_SITES.some(k => n.includes(k))) return 1;  // West Texas -> middle
    return 2;                                          // unrecognized -> above office
}

// Pod number out of a camera name ('POD 4 inside', 'Behind pods 1-3', 'Dan 2').
// Sync-module names churn but pod numbering doesn't, so this is what makes
// similarly-named cameras line up instead of scattering alphabetically.
// The grouping key for a camera: our site, falling back to the raw Blink
// network name for anything master_config doesn't know about.
function siteKey(cam) { return cam.site || cam.network_name || '(no network)'; }

// Sites served by more than one sync module -- only there is it worth putting
// the module name on every tile. Recomputed each render.
let MULTI_MODULE_SITES = new Set();

function podNum(name) {
    const n = (name || '').toLowerCase().trim();
    // "Outdoor 4 - BS8U" is Blink's factory name for an un-renamed camera; its
    // 4 is a model label, not a pod. Treat those as pod-less so they sort to
    // the end instead of colliding with the real pod 4.
    if (/^outdoor\s*\d/.test(n)) return Infinity;
    const m = n.match(/pods?\s*(\d+)/) || n.match(/(\d+)/);
    return m ? parseInt(m[1], 10) : Infinity;
}

// Group by OUR site (resolved server-side from master_config), not by the
// Blink sync-module name -- one site is routinely spread across two or three
// sync modules whose names change whenever crews reshuffle pods.
function groupBySite(cams) {
    const groups = {};
    for (const c of cams) {
        (groups[siteKey(c)] = groups[siteKey(c)] || []).push(c);
    }
    const out = Object.entries(groups).map(([name, all]) => {
        // Cameras Blink can't see get folded into one line below the grid, so
        // a site mid-move doesn't open with a wall of dead tiles.
        const list = all.filter(c => !isMissing(c));
        const gone = all.filter(isMissing);
        // Pod order, NOT severity order. A grid where tiles reshuffle whenever a
        // camera's health changes is unnavigable -- you learn where pod 3 lives
        // and it moves. Problems are already obvious (tile colour, the site's
        // "N need attention" count, the Problems-only filter); position should
        // stay put. Cameras with no pod in their name (gas supply, entrance,
        // gen views) sort to the end of the site.
        const bySort = (a, b) =>
            // The PTZ camera is the one you drive to check the yard before a
            // restart, so it leads its site regardless of pod numbering.
            (isPtz(b) ? 1 : 0) - (isPtz(a) ? 1 : 0) ||
            podNum(a.name) - podNum(b.name) ||
            a.name.trim().localeCompare(b.name.trim(), undefined, {numeric: true});
        list.sort(bySort);
        gone.sort(bySort);
        return {
            site_name: name,
            cameras: list,
            missing: gone,
            // Which sync modules serve this site -- still the failure domain,
            // so it stays visible even though it no longer drives grouping.
            networks: [...new Set(all.map(c => c.network_name).filter(Boolean))],
            worst: all.reduce((m, c) => Math.max(m, severity(c)), 0),
            stale_count: list.filter(isProblem).length,
            offline_count: list.filter(c => isOffline(c) && !isDuplicate(c)).length,
        };
    });
    // A saved manual order (Arrange modal) wins; otherwise the fleet order from
    // master_config. Deliberately NOT sorted by severity any more: sites that
    // jump around as health changes are the reason this page felt chaotic, and
    // problems are already called out by the health strip, the per-site counts
    // and the Problems-only filter.
    const rank = n => { const i = CUSTOM_ORDER.indexOf(n); return i >= 0 ? i : Infinity; };
    out.sort((a, b) =>
        rank(a.site_name) - rank(b.site_name) ||
        siteRank(a.site_name) - siteRank(b.site_name) ||
        a.site_name.localeCompare(b.site_name, undefined, {numeric: true}));
    return out;
}

function renderTile(cam) {
    const level = levelOf(cam);
    const failing = isFailing(cam);
    const missing = isMissing(cam);
    const dup = isDuplicate(cam);
    const tile = document.createElement('div');
    tile.className = 'tile ' + level + (missing ? ' missing' : '') + (dup ? ' dup' : '')
        + (isPtz(cam) ? ' ptz-tile' : '');
    tile.dataset.cameraId = cam.camera_id;

    const imgWrap = document.createElement('div');
    imgWrap.className = 'tile-img-wrap';

    // Only a trustworthy-fresh frame (fresh/aging) renders as the tile image.
    // Stale/unknown/offline frames are hidden behind a placeholder so an old
    // winter shot never reads as a current view â€” the frame is still one click
    // away (lightbox) for anyone who wants the last-known picture.
    const trusted = (level === 'fresh' || level === 'aging') && !missing;
    const ptz = isPtz(cam);
    if (cam.last_snap_ts) imgWrap.classList.add('has-frame');  // clickable -> lightbox

    if (ptz) {
        // Always render: the frame is pulled on demand, so there's no such
        // thing as a stale cached thumbnail to protect the operator from.
        const img = document.createElement('img');
        img.alt = cam.name;
        img.loading = 'lazy';
        img.src = ptzSnapUrl(cam, false);
        img.addEventListener('error', () => {
            imgWrap.innerHTML = '<div class="placeholder stale-ph">'
                + '<div class="ph-icon"><i class="fas fa-video-slash"></i></div>'
                + '<div class="ph-title">No image</div>'
                + '<div class="ph-reason">' + esc(cam.last_error || 'camera unreachable') + '</div></div>';
        });
        imgWrap.appendChild(img);
        imgWrap.classList.add('has-frame');
    } else if (cam.last_snap_ts && trusted) {
        const img = document.createElement('img');
        img.alt = cam.name;
        img.loading = 'lazy';
        // Version the URL by capture time so a new snap busts the browser cache.
        const v = Math.floor(cam.last_snap_ts);
        img.src = `/api/blink/thumbnail/${cam.camera_id}?cached=1&v=${v}`;
        img.dataset.fallback = '0';
        img.addEventListener('error', () => {
            if (img.dataset.fallback === '0') {
                img.dataset.fallback = '1';
                img.src = `/api/blink/thumbnail/${cam.camera_id}`;
            } else {
                imgWrap.innerHTML = '<div class="placeholder">No preview available<br>(click Snap to capture)</div>';
            }
        });
        imgWrap.appendChild(img);
    } else if (cam.last_snap_ts) {
        // We HAVE a frame, but it's stale/offline/unknown-age â€” don't show it as
        // if it were current. Say why, and how old the last good frame was.
        const reason = missing ? 'Not in Blink inventory'
                     : isOffline(cam) ? 'Camera offline'
                     : isFailing(cam) ? 'Last capture failed'
                     : 'Image too old to trust';
        const lastGood = cam.capture_ts ? fmtAge(cam.capture_age_sec) + ' ago' : 'unknown';
        const ph = document.createElement('div');
        ph.className = 'placeholder stale-ph' + (missing ? ' missing-ph' : '');
        ph.innerHTML = '<div class="ph-icon"><i class="fas fa-' + (missing ? 'question-circle' : 'video-slash') + '"></i></div>'
            + '<div class="ph-title">No current image</div>'
            + '<div class="ph-reason">' + esc(reason) + '</div>'
            + '<div class="ph-sub">last good frame: ' + esc(lastGood) + '</div>'
            + '<div class="ph-hint">click to see last frame</div>';
        imgWrap.appendChild(ph);
    } else {
        const ph = document.createElement('div');
        ph.className = 'placeholder';
        ph.innerHTML = missing
            ? 'Not in Blink inventory<br>(no image ever captured)'
            : 'No image ever captured<br>(click Snap to try)';
        imgWrap.appendChild(ph);
    }

    // Freshness badge â€” the TRUE age of the frame, or "age unknown" when we
    // can't vouch for it; never the download time dressed up as a photo age.
    const ta = trueAgeSec(cam);
    const badge = document.createElement('div');
    badge.className = 'age-badge ' + (missing ? 'missing' : level);
    if (ptz) {
        badge.textContent = cam.ip ? 'LIVE' : 'UNREACHABLE';
        badge.title = cam.ip
            ? 'Wired PTZ camera at ' + cam.ip + ' â€” this frame is pulled on demand and '
              + 'refreshes every ' + Math.round(PTZ_REFRESH_MS / 1000) + 's while this page is open.'
              + (cam.snap_age_sec != null ? '\nCurrent frame: ' + fmtAge(cam.snap_age_sec) + ' old.' : '')
            : 'No DHCP lease for this camera on the pod LAN â€” check power/PoE and the switch port.';
    } else if (missing) badge.textContent = 'MISSING';
    else if (level === 'never') badge.textContent = 'NO IMAGE';
    else if (ta == null) badge.textContent = 'age unknown';
    else badge.textContent = fmtAge(ta) + ' old';
    if (missing) {
        badge.title = "This camera has stopped appearing in Blink's camera list "
            + 'for us' + (cam.missing_age_sec != null ? ' (' + fmtAge(cam.missing_age_sec) + ' ago)' : '')
            + '. Usually its Sync Module was down when the field agent built its '
            + 'inventory. Try Refresh List; if it stays missing, check the Sync Module.';
    } else if (level === 'never') {
        badge.title = 'No image has ever been captured for this camera';
    } else if (ta == null) {
        badge.title = 'We have a frame on disk but cannot vouch for when it was '
            + 'taken â€” the camera is offline/failing, so the frame predates the outage.'
            + (cam.last_snap_ts ? '\nDownloaded by us: ' + new Date(cam.last_snap_ts * 1000).toLocaleString() : '');
    } else if (cam.capture_ts) {
        // We know when the camera actually took it.
        badge.title = 'Photo taken: ' + new Date(cam.capture_ts * 1000).toLocaleString()
            + (cam.last_snap_ts
                ? '\nFetched by us: ' + new Date(cam.last_snap_ts * 1000).toLocaleString()
                : '');
    } else {
        // Legacy image with no capture time â€” age is our download time.
        badge.title = (cam.last_snap_ts
            ? 'Fetched by us: ' + new Date(cam.last_snap_ts * 1000).toLocaleString()
            : '') + '\n(no camera capture time recorded â€” showing download age)';
    }
    imgWrap.appendChild(badge);

    const body = document.createElement('div');
    body.className = 'tile-body';

    const name = document.createElement('div');
    name.className = 'tile-name';
    name.textContent = cam.name.trim() || '(unnamed)';
    body.appendChild(name);

    const meta = document.createElement('div');
    meta.className = 'tile-meta';
    if (ptz) {
        const pc = document.createElement('span');
        pc.className = 'chip ptz';
        pc.textContent = 'PTZ' + (cam.ip ? ' Â· ' + cam.ip : '');
        pc.title = (cam.model || 'PTZ camera') + (cam.ip ? ' at ' + cam.ip : '')
            + '\nWired PoE, reached directly over the VPN â€” no sync module involved.';
        meta.appendChild(pc);
    }
    // A duplicate record explains itself and nothing else on the tile matters:
    // point at the tile that IS the camera now.
    if (dup) {
        const dc = document.createElement('span');
        dc.className = 'chip dup';
        dc.textContent = 'also listed as "' + (cam.duplicate_of.name || '?') + '"'
            + (cam.duplicate_of.network_name ? ' Â· ' + cam.duplicate_of.network_name : '');
        dc.title = 'Same physical camera (serial ' + (cam.serial || '?') + ') as "'
            + (cam.duplicate_of.name || '?') + '" â€” Blink is returning two records for it '
            + 'after a move. Both still look live, so both are shown; once one goes '
            + 'stale it is removed automatically. Not a fault.';
        meta.appendChild(dc);
    }
    // Renamed in the Blink app: same camera record, new label. Without this a
    // rename looks exactly like a camera appearing out of nowhere.
    const wasNamed = renamedFrom(cam);
    if (wasNamed && !dup) {
        const rc = document.createElement('span');
        rc.className = 'chip renamed';
        rc.textContent = 'was: ' + wasNamed;
        rc.title = (cam.moved_from_camera_id
                ? 'Re-onboarded onto this Sync Module (matched by serial '
                  + (cam.serial || '?') + '); its history followed it here.'
                : 'Renamed in the Blink app')
            + (cam.renamed_age_sec != null ? ' ' + fmtAge(cam.renamed_age_sec) + ' ago' : '');
        meta.appendChild(rc);
    }
    // Which hub this camera hangs off, but only where a site has more than one
    // -- that's when "the whole site is dark" needs narrowing to one module.
    if (MULTI_MODULE_SITES.has(siteKey(cam)) && cam.network_name) {
        const nc = document.createElement('span');
        nc.className = 'chip net';
        nc.textContent = cam.network_name;
        nc.title = 'Blink Sync Module this camera reports through';
        meta.appendChild(nc);
    }
    // "Missing" outranks everything else on the tile: the fields below it are
    // last-known values from before the camera dropped out of the inventory.
    if (missing) {
        const ms = document.createElement('span');
        ms.className = 'chip missing';
        ms.textContent = 'missing from Blink'
            + (cam.missing_age_sec != null ? ' Â· ' + fmtAge(cam.missing_age_sec) : '');
        ms.title = "Not in the camera list the field agent gets from Blink. Everything "
            + 'shown below is the last known value. Most often the Sync Module is offline.';
        meta.appendChild(ms);
    }
    // Blink-offline is the headline status: put it first, ahead of battery.
    if (isOffline(cam)) {
        const off = document.createElement('span');
        off.className = 'chip offline';
        off.textContent = 'offline';
        off.title = "Blink reports this camera offline â€” it can't be reached for a "
            + "fresh capture right now. Battery/sync below are its last known values.";
        meta.appendChild(off);
    }
    const bat = document.createElement('span');
    bat.className = 'chip battery ' + batteryClass(cam);
    if (ptz) bat.style.display = 'none';   // mains/PoE â€” no battery to report
    // When the camera is offline/stale-sync, the battery is a LAST-KNOWN value,
    // not live â€” say so instead of implying it's current.
    const staleSync = cam.last_sync_age_sec != null && cam.last_sync_age_sec > 3 * 3600;
    bat.textContent = batteryLabel(cam) + ((isOffline(cam) || staleSync) ? ' (last known)' : '');
    bat.title = cam.last_sync_age_sec != null
        ? 'Battery/status reading last refreshed by Blink ' + fmtAge(cam.last_sync_age_sec)
          + ' ago (record freshness, not camera liveness)'
        : 'Reading age unknown';
    meta.appendChild(bat);
    // "status X ago" chip: freshness of Blink's status/battery RECORD â€” NOT the
    // photo, and NOT proof the camera is reachable. Deliberately not "synced":
    // that read as "the sync module talked to the camera", but Blink keeps this
    // record current on its own (and our snap attempts poke it) even while the
    // camera is offline â€” so a recent value here means nothing about liveness.
    if (cam.last_sync_age_sec != null && !ptz) {
        const sy = document.createElement('span');
        sy.className = 'chip sync' + (cam.last_sync_age_sec > 3 * 3600 ? ' stale' : '');
        sy.textContent = 'status ' + fmtAge(cam.last_sync_age_sec) + ' ago';
        sy.title = "How long since Blink last refreshed this camera's status/battery "
            + "record. This is NOT when a photo was taken, and not proof the camera "
            + "can be reached for a capture â€” Blink keeps this record current (and our "
            + "own snap attempts bump it) even while the camera is offline.";
        meta.appendChild(sy);
    }
    // Flag a camera with no sync-module link (sentinel wifi values).
    if (cam.wifi_strength != null && cam.wifi_strength <= -200 && !ptz) {
        const nl = document.createElement('span');
        nl.className = 'chip nolink';
        nl.textContent = 'no link';
        meta.appendChild(nl);
    }
    body.appendChild(meta);

    // Show the failure reason on any tile that's stale/failing so operators
    // know why it isn't updating (asleep / offline / low battery). Skip it when
    // the camera already shows the "offline" chip â€” that's the reason, and a
    // second red line just adds noise.
    if (cam.last_error && !isOffline(cam) && (level !== 'fresh' || failing)) {
        const fr = document.createElement('div');
        fr.className = 'fail-reason';
        const when = cam.last_attempt_age_sec != null ? fmtAge(cam.last_attempt_age_sec) + ' ago' : '';
        fr.textContent = 'last attempt failed' + (when ? ' ' + when : '') + ': ' + cam.last_error;
        body.appendChild(fr);
    }

    const actions = document.createElement('div');
    actions.className = 'tile-actions';
    const snapBtn = document.createElement('button');
    snapBtn.className = 'btn btn-primary';
    if (ptz) {
        snapBtn.innerHTML = '<i class="fas fa-sync"></i> Refresh';
        snapBtn.title = 'Pull a new frame from the camera right now';
        snapBtn.onclick = () => {
            const img = imgWrap.querySelector('img');
            if (!img) return;
            snapBtn.disabled = true;
            imgWrap.classList.add('busy');
            img.onload = img.onerror = () => {
                imgWrap.classList.remove('busy');
                snapBtn.disabled = false;
            };
            img.src = ptzSnapUrl(cam, true);
        };
    } else {
        snapBtn.innerHTML = '<i class="fas fa-camera"></i> Snap';
        snapBtn.onclick = () => snap(cam.camera_id, tile);
    }
    if (missing) {
        // The agent can't address a camera that isn't in its inventory, so the
        // snap would just fail -- point at the action that can actually help.
        snapBtn.disabled = true;
        snapBtn.title = "Camera is not in the field agent's Blink inventory â€” "
            + 'use Refresh List, or check the Sync Module';
    }
    actions.appendChild(snapBtn);
    body.appendChild(actions);

    tile.appendChild(imgWrap);
    tile.appendChild(body);
    return tile;
}

// Snap one camera. Returns true on success. `tile` may be null (called from
// snap-site before tiles are re-rendered).
async function snap(cameraId, tile) {
    const imgWrap = tile ? tile.querySelector('.tile-img-wrap') : null;
    const snapBtn = tile ? tile.querySelector('.btn-primary') : null;
    if (snapBtn) snapBtn.disabled = true;
    if (imgWrap) imgWrap.classList.add('busy');
    try {
        const r = await fetch(`/api/blink/snap/${cameraId}`, { method: 'POST' });
        if (!r.ok) {
            const body = await r.text();
            throw new Error(`HTTP ${r.status}: ${body.slice(0, 140)}`);
        }
        if (tile) {
            tile.className = 'tile fresh';
            imgWrap.innerHTML = '';
            imgWrap.classList.add('has-frame');
            const img = document.createElement('img');
            img.alt = 'snap';
            img.src = `/api/blink/thumbnail/${cameraId}?cached=1&t=${Date.now()}`;
            imgWrap.appendChild(img);
            const badge = document.createElement('div');
            badge.className = 'age-badge fresh';
            badge.textContent = 'now';
            imgWrap.appendChild(badge);
            const pill = document.createElement('div');
            pill.className = 'source-pill';
            pill.textContent = 'fresh';
            imgWrap.appendChild(pill);
        }
        return true;
    } catch (e) {
        if (tile) alert('Snap failed: ' + e.message);
        return false;
    } finally {
        if (snapBtn) snapBtn.disabled = false;
        if (imgWrap) imgWrap.classList.remove('busy');
    }
}

// Snap every camera in one network, sequentially (gentle on Blink's rate
// limits). The go-to "verify this site is clear before I restart it" action.
async function snapSite(siteName, btn) {
    // Skip cameras missing from the inventory: the agent has no handle to snap
    // them with, so including them would just book guaranteed failures against
    // the site (and this is the verify-before-restart button).
    // PTZ cams are excluded: "Snap site" drives the Blink job queue, and a PTZ
    // frame is a single direct fetch (its own Refresh button) rather than a
    // queued capture.
    const group = (LAST_DATA.cameras || []).filter(
        c => siteKey(c) === siteName && !isMissing(c) && !isPtz(c));
    if (!group.length) return;
    const orig = btn.innerHTML;
    btn.disabled = true;
    let ok = 0, fail = 0;
    for (let i = 0; i < group.length; i++) {
        btn.innerHTML = `<i class="fas fa-camera"></i> Snapping ${i + 1}/${group.length}â€¦`;
        const tile = grid.querySelector(`.tile[data-camera-id="${group[i].camera_id}"]`);
        const good = await snap(group[i].camera_id, tile);
        good ? ok++ : fail++;
    }
    btn.innerHTML = orig;
    btn.disabled = false;
    // Pull fresh metadata so ages/badges reflect the new snaps.
    await loadCameras();
    if (fail) showError(`Snap site "${siteName}": ${ok} ok, ${fail} failed â€” see the failing tiles for reasons.`);
}

function renderHealthStrip(data) {
    // Duplicate records are the same physical cameras counted twice, so they
    // stay out of the coverage math entirely -- in the denominator they'd
    // permanently depress "N/M fresh" for cameras that are in fact covered.
    const cams = (data.cameras || []).filter(c => !isDuplicate(c));
    const dupes = (data.cameras || []).length - cams.length;
    const total = cams.length;
    let fresh = 0, aging = 0, stale = 0, never = 0, failing = 0;
    let battLow = 0, battWatch = 0, offline = 0, missing = 0;
    const offlineSites = new Set();
    const missingSites = new Set();
    for (const c of cams) {
        // A camera we can't even see doesn't get to count as fresh: it stays in
        // the denominator (so coverage never silently shrinks) but out of every
        // freshness/battery bucket, since all we have is last-known values.
        if (isMissing(c)) {
            missing++;
            missingSites.add(siteKey(c));
            continue;
        }
        const l = levelOf(c);
        if (l === 'fresh') fresh++; else if (l === 'aging') aging++;
        else if (l === 'stale') stale++; else never++;
        if (isFailing(c)) failing++;
        if (isOffline(c)) { offline++; offlineSites.add(siteKey(c)); }
        const b = batteryClass(c);
        if (b === 'low') battLow++; else if (b === 'watch') battWatch++;
    }
    const parts = [];
    const freshCls = fresh === total ? 'good' : (fresh >= total * 0.7 ? 'warn' : 'bad');
    parts.push(`<span class="pill ${freshCls}">${fresh}/${total} fresh</span>`);
    if (aging) parts.push(`<span class="pill warn">${aging} aging</span>`);
    if (stale || never) parts.push(`<span class="pill bad">${stale + never} stale${never ? ` (${never} never captured)` : ''}</span>`);
    if (failing) parts.push(`<span class="pill bad">${failing} failing to update</span>`);
    if (offline) parts.push(`<span class="pill bad" title="Blink reports these cameras offline â€” unreachable for a fresh capture">${offline} offline${offlineSites.size > 1 ? ` across ${offlineSites.size} sites` : ''}</span>`);
    // Kept separate from "offline": these cameras aren't in Blink's list for us
    // at all, so they're unaccounted for rather than merely unreachable.
    if (dupes) parts.push(`<span class="pill dim" title="Blink is returning two records for the same physical camera after a move (matched by serial), and both still look live â€” so both are shown and neither counts twice toward coverage. Dead duplicates are removed automatically.">${dupes} double-listed</span>`);
    if (missing) parts.push(`<span class="pill missing" title="Not in the camera list Blink returns to the field agent â€” usually a Sync Module that was down when the agent built its inventory. Try Refresh List.">${missing} missing from Blink${missingSites.size > 1 ? ` (${missingSites.size} sites)` : ''}</span>`);

    // Battery is a scheduling signal, not an outage -- keep it visually distinct
    // from the freshness pills so a swap-due camera doesn't read as a fault.
    if (battLow) parts.push(`<span class="pill bad">${battLow} battery LOW</span>`);
    if (battWatch) parts.push(`<span class="pill warn">${battWatch} battery due soon</span>`);

    parts.push('<span class="sep">|</span>');
    if (data.field_online) {
        parts.push('<span class="pill good">field online</span>');
    } else {
        const age = data.last_heartbeat_age_sec != null ? ' (last check-in ' + fmtAge(data.last_heartbeat_age_sec) + ' ago)' : '';
        parts.push(`<span class="pill bad">field OFFLINE${age}</span>`);
        if (data.field_init_error) {
            parts.push(`<span class="hint">Blink connect error: ${data.field_init_error}</span>`);
        }
    }
    // A site with no cameras at all in Blink's inventory leaves no tiles to
    // flag, so it can only be surfaced here.
    const emptyNets = data.empty_networks || [];
    if (emptyNets.length) {
        parts.push(`<span class="pill missing" title="Blink returns no cameras at all for ${esc(emptyNets.join(', '))} â€” the Sync Module was almost certainly down when the field agent built its inventory. Refresh List re-scans; if a site stays here, check its Sync Module.">${emptyNets.length} site${emptyNets.length > 1 ? 's' : ''} with no cameras: ${esc(emptyNets.join(', '))}</span>`);
    }
    if (data.last_daily_date) {
        parts.push(`<span class="hint">last daily run: ${data.last_daily_date}</span>`);
    }
    parts.push(`<span class="hint">green â‰¤26h Â· amber â‰¤50h Â· red older/never Â· batt low â‰¤${BATT_LOW_MAX} Â· due â‰¤${BATT_WATCH_MAX}</span>`);
    healthStrip.innerHTML = parts.join(' ');
    healthStrip.style.display = 'flex';
}

// Render from LAST_DATA (no fetch) so the "Problems only" toggle is instant.
// Wrapper so every path (filter toggle, 60s auto-refresh, early returns below)
// also re-points an open lightbox at the refreshed data.
function render() {
    renderGrid();
    syncLightbox();
}

function renderGrid() {
    if (!LAST_DATA) return;
    const data = LAST_DATA;
    const onlyProblems = problemsOnly.checked;

    let cams = data.cameras || [];
    if (onlyProblems) cams = cams.filter(isProblem);
    const groups = groupBySite(cams);
    MULTI_MODULE_SITES = new Set(groups.filter(g => g.networks.length > 1).map(g => g.site_name));
    // Include the folded-away ghosts: their tiles still exist (hidden behind the
    // "show" toggle) and clicking one must still open the lightbox.
    RENDERED_CAMS = groups.flatMap(g => [...g.cameras, ...g.missing]);

    grid.innerHTML = '';
    if ((data.cameras || []).length === 0) {
        grid.innerHTML = '<div class="empty-state">No cameras found.</div>';
        return;
    }
    if (cams.length === 0) {
        grid.innerHTML = '<div class="empty-state">No cameras match the current filter. Everything looks fresh. ðŸŽ‰</div>';
        return;
    }
    for (const g of groups) {
        const section = document.createElement('section');
        section.className = 'net-section';

        const header = document.createElement('div');
        header.className = 'net-header' + (g.stale_count ? ' has-stale' : '');

        const left = document.createElement('div');
        left.className = 'net-left';
        const nameEl = document.createElement('span');
        nameEl.className = 'net-name';
        nameEl.textContent = g.site_name;
        left.appendChild(nameEl);
        // The sync module(s) behind this site. Grouping no longer follows them,
        // but they're the failure domain -- when a whole site goes dark it's
        // usually one hub, so keep them one glance away.
        if (g.networks.length) {
            const sm = document.createElement('span');
            sm.className = 'net-subs';
            sm.textContent = g.networks.length === 1
                ? g.networks[0]
                : g.networks.length + ' sync modules';
            sm.title = 'Blink sync module' + (g.networks.length > 1 ? 's' : '')
                + ' serving this site:\n' + g.networks.join('\n');
            left.appendChild(sm);
        }
        const wasNet = (g.cameras.find(c => c.network_previous_name) || {}).network_previous_name;
        if (wasNet) {
            const rn = document.createElement('span');
            rn.className = 'net-renamed';
            rn.textContent = '(was "' + wasNet + '")';
            rn.title = 'This Sync Module was renamed in the Blink app';
            left.appendChild(rn);
        }
        const countEl = document.createElement('span');
        countEl.className = 'net-count';
        countEl.textContent = `${g.cameras.length} cam${g.cameras.length === 1 ? '' : 's'}`;
        left.appendChild(countEl);
        // "need attention" counts only problems that AREN'T offline â€” the
        // offline ones get their own chip below, so don't red-flag them twice.
        const otherProblems = g.stale_count - g.offline_count;
        if (otherProblems > 0) {
            const st = document.createElement('span');
            st.className = 'net-stale';
            st.textContent = `${otherProblems} need attention`;
            left.appendChild(st);
        }
        // Site-level offline roll-up: a whole site's worth of offline cameras
        // usually means one Sync Module / site-network problem, not N camera
        // faults â€” surface it so field crews check the hub first.
        if (g.offline_count) {
            const off = document.createElement('span');
            off.className = 'net-offline';
            off.textContent = `${g.offline_count} offline`;
            off.title = g.offline_count === g.cameras.length
                ? 'EVERY camera at this site is offline â€” likely a Sync Module or '
                  + 'site-network problem, not individual cameras. Check the hub first.'
                : `${g.offline_count} of ${g.cameras.length} cameras offline in Blink.`;
            left.appendChild(off);
        }
        header.appendChild(left);

        const siteBtn = document.createElement('button');
        siteBtn.className = 'btn btn-primary';
        siteBtn.innerHTML = '<i class="fas fa-camera"></i> Snap site';
        siteBtn.title = 'Take a fresh snapshot of every camera at this site';
        siteBtn.onclick = () => snapSite(g.site_name, siteBtn);
        header.appendChild(siteBtn);

        section.appendChild(header);

        const sectionGrid = document.createElement('div');
        sectionGrid.className = 'net-grid';
        for (const c of g.cameras) sectionGrid.appendChild(renderTile(c));
        section.appendChild(sectionGrid);

        // Cameras Blink no longer lists: one line, expandable. They're a site
        // problem (usually one Sync Module), not N camera problems, and during
        // a move week they'd otherwise crowd out the cameras that do work.
        if (g.missing.length) {
            const row = document.createElement('div');
            row.className = 'missing-row';
            const names = g.missing.map(c => c.name.trim() || '(unnamed)').join(', ');
            row.innerHTML = '<i class="fas fa-question-circle"></i> <b>'
                + g.missing.length + ' not in Blink inventory</b> â€” '
                + '<span class="mr-names">' + esc(names) + '</span>'
                + '<span class="mr-toggle">show</span>';
            row.title = "Blink isn't returning these cameras to us at all â€” usually a "
                + 'Sync Module that was down when the agent built its inventory. '
                + 'Refresh List re-scans; they come back on their own if the hub does.';
            const ghosts = document.createElement('div');
            ghosts.className = 'net-grid';
            ghosts.style.display = 'none';
            for (const c of g.missing) ghosts.appendChild(renderTile(c));
            row.querySelector('.mr-toggle').onclick = () => {
                const open = ghosts.style.display === 'none';
                ghosts.style.display = open ? '' : 'none';
                row.querySelector('.mr-toggle').textContent = open ? 'hide' : 'show';
            };
            section.appendChild(row);
            section.appendChild(ghosts);
        }

        grid.appendChild(section);
    }
}

// ---- lightbox -------------------------------------------------------------
// Click any thumbnail for the full uncropped capture. Arrow keys / on-screen
// arrows walk the cameras in display order (problem sites first), so you can
// sweep a whole site without going back to the grid. Esc closes.

const lb = document.getElementById('lightbox');
const lbImg = document.getElementById('lbImg');
const lbStage = document.getElementById('lbStage');
const lbName = document.getElementById('lbName');
const lbNet = document.getElementById('lbNet');
const lbMeta = document.getElementById('lbMeta');
const lbSnapBtn = document.getElementById('lbSnap');

let LB_CAM_ID = null;   // camera currently shown (survives re-renders)
let LB_SNAP_TS = null;  // capture ts of the loaded image, so we only reload when it changes

function lbCam() {
    return RENDERED_CAMS.find(c => String(c.camera_id) === String(LB_CAM_ID)) || null;
}

function lbMetaHtml(cam) {
    if (isPtz(cam)) {
        const bits = [`<span class="age-badge ${levelOf(cam)}" style="position:static">`
            + (cam.ip ? 'LIVE' : 'UNREACHABLE') + '</span>'];
        if (cam.ip) bits.push('frame pulled just now from ' + esc(cam.ip));
        else bits.push('<span class="lb-err">no DHCP lease on the pod LAN â€” check power/PoE</span>');
        if (cam.model) bits.push(esc(cam.model));
        if (cam.pod) bits.push(esc(cam.pod));
        if (cam.last_error) bits.push(`<span class="lb-err">${esc(cam.last_error)}</span>`);
        return bits.join('<span class="lb-sep">Â·</span>')
            + '<span class="lb-hint">â†� â†’ to browse Â· Esc to close</span>';
    }
    const lvl = levelOf(cam);
    const ta = trueAgeSec(cam);
    const bits = [];
    bits.push(`<span class="age-badge ${lvl}" style="position:static">`
        + (lvl === 'never' ? 'NO IMAGE' : (ta == null ? 'age unknown' : fmtAge(ta) + ' old')) + '</span>');
    if (isOffline(cam)) {
        bits.push('<span class="lb-err">camera offline â€” this is the last frame we have, not a current view</span>');
    }
    if (cam.capture_ts) {
        bits.push('photo taken ' + esc(new Date(cam.capture_ts * 1000).toLocaleString()));
    } else if (cam.last_snap_ts) {
        bits.push('downloaded ' + esc(new Date(cam.last_snap_ts * 1000).toLocaleString()) + ' Â· camera capture time unknown');
    }
    bits.push(`<span class="chip battery ${batteryClass(cam)}">${esc(batteryLabel(cam))}</span>`);
    if (cam.wifi_strength != null && cam.wifi_strength <= -200) {
        bits.push('<span class="chip nolink">no link</span>');
    }
    if (cam.last_error) {
        const when = cam.last_attempt_age_sec != null ? ' ' + fmtAge(cam.last_attempt_age_sec) + ' ago' : '';
        bits.push(`<span class="lb-err">last attempt failed${when}: ${esc(cam.last_error)}</span>`);
    }
    return bits.join('<span class="lb-sep">Â·</span>')
        + '<span class="lb-hint">â†� â†’ to browse Â· Esc to close</span>';
}

function showLightbox(cam, forceReload) {
    LB_CAM_ID = cam.camera_id;
    lbName.textContent = cam.name.trim() || '(unnamed)';
    lbNet.textContent = cam.network_name || '';
    lbMeta.innerHTML = lbMetaHtml(cam);
    lbSnapBtn.disabled = false;

    // Only touch src when the capture actually changed -- otherwise the 60s
    // auto-refresh would re-fetch and flash the image every minute.
    const ts = cam.last_snap_ts || null;
    if (forceReload || ts !== LB_SNAP_TS) {
        LB_SNAP_TS = ts;
        if (isPtz(cam)) {
            lbImg.style.display = '';
            lbImg.alt = cam.name;
            // Full-size view of a live camera: always pull a current frame.
            lbImg.src = ptzSnapUrl(cam, true);
        } else if (ts) {
            lbImg.style.display = '';
            lbImg.alt = cam.name;
            lbImg.src = `/api/blink/thumbnail/${cam.camera_id}?cached=1&v=${Math.floor(ts)}`;
        } else {
            lbImg.style.display = 'none';
            lbImg.removeAttribute('src');
        }
    }
    const multi = RENDERED_CAMS.length > 1;
    document.getElementById('lbPrev').style.display = multi ? '' : 'none';
    document.getElementById('lbNext').style.display = multi ? '' : 'none';

    lb.style.display = 'flex';
    document.body.style.overflow = 'hidden';
}

function openLightbox(cameraId) {
    const cam = RENDERED_CAMS.find(c => String(c.camera_id) === String(cameraId));
    if (!cam) return;
    LB_SNAP_TS = null;  // force a load on open
    showLightbox(cam, true);
}

function closeLightbox() {
    lb.style.display = 'none';
    document.body.style.overflow = '';
    LB_CAM_ID = null;
    LB_SNAP_TS = null;
}

function lbStep(delta) {
    if (!RENDERED_CAMS.length) return;
    const i = RENDERED_CAMS.findIndex(c => String(c.camera_id) === String(LB_CAM_ID));
    const next = RENDERED_CAMS[((i < 0 ? 0 : i + delta) + RENDERED_CAMS.length) % RENDERED_CAMS.length];
    LB_SNAP_TS = null;
    showLightbox(next, true);
}

// Called after every render so an open lightbox picks up fresh ages/errors.
// If the current camera got filtered out (Problems only), just close it.
function syncLightbox() {
    if (!LB_CAM_ID || lb.style.display === 'none') return;
    const cam = lbCam();
    if (!cam) { closeLightbox(); return; }
    showLightbox(cam, false);
}

// Delegated so it survives re-renders and the DOM swap inside snap().
grid.addEventListener('click', (e) => {
    // The whole image area is clickable whenever a frame exists â€” the live photo
    // OR the last-known frame hidden behind a stale/offline placeholder.
    const wrap = e.target.closest('.tile-img-wrap.has-frame');
    if (!wrap) return;
    const tile = wrap.closest('.tile');
    if (!tile) return;
    // A PTZ camera opens the live view with controls, not the still lightbox â€”
    // for these the point is to look around, not at one frame.
    const cam = (LAST_DATA?.cameras || []).find(
        c => String(c.camera_id) === String(tile.dataset.cameraId));
    if (cam && isPtz(cam)) { openPtzView(cam); return; }
    openLightbox(tile.dataset.cameraId);
});

document.getElementById('lbClose').onclick = closeLightbox;
document.getElementById('lbPrev').onclick = () => lbStep(-1);
document.getElementById('lbNext').onclick = () => lbStep(1);
// Backdrop click closes; clicks inside the panel don't.
lb.addEventListener('click', (e) => { if (e.target === lb) closeLightbox(); });

document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && ptzView.style.display !== 'none') { closePtzView(); return; }
    if (lb.style.display === 'none') return;
    if (e.key === 'Escape') closeLightbox();
    else if (e.key === 'ArrowLeft') lbStep(-1);
    else if (e.key === 'ArrowRight') lbStep(1);
});

// Snap straight from the lightbox -- the verify-before-restart action without
// having to close, find the tile, and re-open.
lbSnapBtn.onclick = async () => {
    const cam = lbCam();
    if (!cam) return;
    if (isPtz(cam)) {
        lbSnapBtn.disabled = true;
        lbStage.classList.add('busy');
        lbImg.onload = lbImg.onerror = () => {
            lbStage.classList.remove('busy');
            lbSnapBtn.disabled = false;
        };
        lbImg.src = ptzSnapUrl(cam, true);
        return;
    }
    lbSnapBtn.disabled = true;
    lbStage.classList.add('busy');
    try {
        const tile = grid.querySelector(`.tile[data-camera-id="${cam.camera_id}"]`);
        const ok = await snap(cam.camera_id, tile);
        if (ok) {
            LB_SNAP_TS = null;
            lbImg.style.display = '';
            lbImg.src = `/api/blink/thumbnail/${cam.camera_id}?cached=1&t=${Date.now()}`;
            loadCameras();  // pull the new timestamp/meta
        }
    } finally {
        lbStage.classList.remove('busy');
        lbSnapBtn.disabled = false;
    }
};

// ---- arrange (reorder sites) modal ----------------------------------------
// Operators set the site order by hand here; it's persisted server-side (shared
// with everyone) and overrides the automatic geographic sort. Unlisted/new
// sites fall to the bottom in geographic order until someone arranges them.
const arrangeModal = document.getElementById('arrangeModal');
const arrangeList = document.getElementById('arrangeList');

function regionMeta(name) {
    const r = regionRank(name);
    if (r === 0) return {cls: 'nd', label: 'ND'};
    if (r === 1) return {cls: 'tx', label: 'TX'};
    if (r === 3) return {cls: 'office', label: 'OFFICE'};
    return {cls: '', label: ''};
}

// Every known site (network) name, in the order it currently displays.
function currentSiteOrder() {
    const cams = (LAST_DATA && LAST_DATA.cameras) || [];
    return groupBySite(cams).map(g => g.site_name);
}

function refreshMoveButtons() {
    const rows = [...arrangeList.children];
    rows.forEach((r, i) => {
        r.querySelector('.up').disabled = (i === 0);
        r.querySelector('.down').disabled = (i === rows.length - 1);
    });
}

function buildArrangeList() {
    arrangeList.innerHTML = '';
    currentSiteOrder().forEach(name => {
        const li = document.createElement('li');
        li.className = 'arrange-row';
        li.draggable = true;
        li.dataset.net = name;
        const rm = regionMeta(name);
        li.innerHTML =
            '<span class="grip"><i class="fas fa-grip-vertical"></i></span>'
            + '<span class="arrange-name"></span>'
            + (rm.label ? '<span class="region-tag ' + rm.cls + '">' + rm.label + '</span>' : '')
            + '<span class="move">'
            +   '<button type="button" class="up" title="Move up">â–²</button>'
            +   '<button type="button" class="down" title="Move down">â–¼</button>'
            + '</span>';
        li.querySelector('.arrange-name').textContent = name;
        arrangeList.appendChild(li);
    });
    refreshMoveButtons();
}

// â–²â–¼ buttons
arrangeList.addEventListener('click', (e) => {
    const btn = e.target.closest('button');
    if (!btn) return;
    const row = btn.closest('.arrange-row');
    if (btn.classList.contains('up') && row.previousElementSibling) {
        arrangeList.insertBefore(row, row.previousElementSibling);
    } else if (btn.classList.contains('down') && row.nextElementSibling) {
        arrangeList.insertBefore(row.nextElementSibling, row);
    }
    refreshMoveButtons();
});

// drag-and-drop
let dragRow = null;
arrangeList.addEventListener('dragstart', (e) => {
    dragRow = e.target.closest('.arrange-row');
    if (dragRow) dragRow.classList.add('dragging');
});
arrangeList.addEventListener('dragend', () => {
    if (dragRow) dragRow.classList.remove('dragging');
    dragRow = null;
    refreshMoveButtons();
});
arrangeList.addEventListener('dragover', (e) => {
    e.preventDefault();
    if (!dragRow) return;
    const over = e.target.closest('.arrange-row');
    if (!over || over === dragRow) return;
    const rect = over.getBoundingClientRect();
    const after = (e.clientY - rect.top) > rect.height / 2;
    arrangeList.insertBefore(dragRow, after ? over.nextElementSibling : over);
});

function openArrange() {
    if (!LAST_DATA || !(LAST_DATA.cameras || []).length) {
        alert('Cameras are still loading â€” try again in a moment.');
        return;
    }
    buildArrangeList();
    arrangeModal.style.display = 'flex';
    document.body.style.overflow = 'hidden';
}
function closeArrange() {
    arrangeModal.style.display = 'none';
    document.body.style.overflow = '';
}

async function saveOrder(order) {
    const r = await fetch('/api/blink/cam_order', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({order}),
    });
    if (!r.ok) throw new Error('HTTP ' + r.status);
    CUSTOM_ORDER = order;
}

async function loadOrder() {
    try {
        const r = await fetch('/api/blink/cam_order');
        if (r.ok) {
            const d = await r.json();
            if (Array.isArray(d.order)) CUSTOM_ORDER = d.order;
        }
    } catch (e) { /* non-fatal: fall back to the automatic sort */ }
}

document.getElementById('arrangeClose').onclick = closeArrange;
document.getElementById('arrangeCancel').onclick = closeArrange;
arrangeModal.addEventListener('click', (e) => { if (e.target === arrangeModal) closeArrange(); });

document.getElementById('arrangeSave').onclick = async () => {
    const btn = document.getElementById('arrangeSave');
    btn.disabled = true;
    try {
        await saveOrder([...arrangeList.children].map(r => r.dataset.net));
        render();
        closeArrange();
    } catch (e) {
        alert('Failed to save order: ' + e.message);
    } finally {
        btn.disabled = false;
    }
};

document.getElementById('arrangeReset').onclick = async () => {
    const btn = document.getElementById('arrangeReset');
    btn.disabled = true;
    try {
        await saveOrder([]);       // clear -> revert to geographic auto sort
        buildArrangeList();        // reflect the auto order in the still-open modal
        render();
    } catch (e) {
        alert('Failed to reset: ' + e.message);
    } finally {
        btn.disabled = false;
    }
};

document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && arrangeModal.style.display !== 'none') closeArrange();
});

async function loadCameras() {
    clearError();
    headerMeta.textContent = 'Loadingâ€¦';
    try {
        const r = await fetch('/api/blink/cameras');
        if (!r.ok) throw new Error('HTTP ' + r.status);
        const data = await r.json();
        if (!data.ready) {
            showError('Field-server agent not ready: ' + (data.error || 'unknown'));
            headerMeta.textContent = 'Not ready';
            return;
        }
        // Attach network names for grouping.
        const netMap = {}, netPrev = {};
        (data.networks || []).forEach(n => {
            netMap[n.network_id] = n.name;
            // Sync modules get renamed in the field too; carry the old name so
            // the site header can say what this section used to be called.
            if (n.previous_name && (n.renamed_at == null
                    || (Date.now() / 1000 - n.renamed_at) < RENAME_SHOW_SEC)) {
                netPrev[n.network_id] = n.previous_name;
            }
        });
        data.cameras = (data.cameras || []).map(c => ({
            ...c,
            network_name: netMap[c.network_id] || '(no network)',
            network_previous_name: netPrev[c.network_id] || null,
        }));

        // PTZ cameras come from a different service and can fail independently
        // -- a Blink outage must not hide them, and vice versa.
        try {
            const pr = await fetch('/api/ptz/cams');
            if (pr.ok) {
                const pd = await pr.json();
                const ptz = (pd.cams || []).map(c => ({
                    ...c,
                    is_ptz: true,
                    camera_id: 'ptz:' + c.mac,
                    network_name: c.pod || c.model || 'PTZ',
                    capture_ts: c.last_snap_ts,
                    capture_age_sec: c.snap_age_sec,
                }));
                data.cameras = data.cameras.concat(ptz);
            }
        } catch (e) {
            console.warn('PTZ cams unavailable:', e);
        }

        SITE_ORDER = data.site_order || SITE_ORDER;
        LAST_DATA = data;
        renderHealthStrip(data);
        render();

        const stale = data.cameras.filter(isProblem).length;
        const dupes = data.cameras.filter(isDuplicate).length;
        const ts = data.last_refresh_ts
            ? new Date(data.last_refresh_ts * 1000).toLocaleString()
            : 'unknown';
        headerMeta.textContent = `${data.cameras.length - dupes} cameras`
            + (dupes ? ` (+${dupes} double-listed)` : '')
            + (stale ? ` Â· ${stale} need attention` : ' Â· all fresh')
            + ` Â· list refreshed: ${ts}`;
    } catch (e) {
        showError('Failed to load cameras: ' + e.message);
        headerMeta.textContent = 'Error';
    }
}

// Refresh List asks the field agent to re-run Blink discovery from scratch
// (networks -> sync modules -> cameras). That's how a site that dropped out of
// blinkpy's inventory comes back -- but it fans out per sync module and can
// outrun the browser hold, so the cloud may answer 202 "still running" and the
// real answer lands in a later poll. Re-check a couple of times instead of
// leaving the operator looking at the same stale list.
const RESCAN_RECHECK_MS = [15000, 45000, 90000];
async function refreshAll() {
    const btn = document.getElementById('btnRefresh');
    const label = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="fas fa-sync fa-spin"></i> Re-scanningâ€¦';
    try {
        let pending = false;
        try {
            const r = await fetch('/api/blink/refresh', { method: 'POST' });
            pending = (r.status === 202);
        } catch (e) {
            showError('Re-scan request failed: ' + e.message);
        }
        await loadCameras();
        if (pending) {
            headerMeta.textContent += ' Â· re-scan still runningâ€¦';
            RESCAN_RECHECK_MS.forEach(ms => setTimeout(loadCameras, ms));
        }
    } finally {
        btn.disabled = false;
        btn.innerHTML = label;
    }
}

// ---- PTZ live view --------------------------------------------------------
// Deliberately NOT the camera's own web page: that ships a 2.26MB ffmpeg.wasm
// and decodes video on the browser's main thread, which is why PTZ presses
// there take ~10s to reach the camera. Here the browser hardware-decodes and
// PTZ commands go straight to ptz_api (measured: 220ms to the API, camera
// moving inside 1s).
const ptzView = document.getElementById('ptzView');
const ptzStage = document.getElementById('ptzStage');
const ptzOverlay = document.getElementById('ptzOverlay');
let ptzPlayer = null;          // go2rtc <video-stream>, or a plain <video> in MP4 fallback

// go2rtc's own player element. It speaks the MSE-over-WebSocket protocol,
// keeps to the live edge and reconnects â€” none of which a plain <video> fed an
// endless MP4 does, which is why that froze. Loaded lazily so the cams grid
// costs nothing for operators who never open a PTZ view.
let ptzPlayerLoaded = null;
function ptzLoadPlayer() {
    if (!ptzPlayerLoaded) ptzPlayerLoaded = import('/go2rtc/video-stream.js');
    return ptzPlayerLoaded;
}
const ptzStatusEl = document.getElementById('ptzStatus');
let PTZ_CAM = null;
let PTZ_MODE = 'frames';       // 'frames' (default, bulletproof) or 'live' (MSE/MP4)

function ptzSetMode(mode) {
    PTZ_MODE = mode;
    document.getElementById('ptzModeFrames').classList.toggle('active', mode === 'frames');
    document.getElementById('ptzModeLive').classList.toggle('active', mode === 'live');
    if (!PTZ_CAM) return;
    ptzDetachPlayer();
    ptzOverlay.style.display = 'none';
    if (mode === 'frames') ptzStartFrames(PTZ_CAM);
    else ptzAttachPlayer(PTZ_CAM);
}

function ptzSay(msg, isErr) {
    ptzStatusEl.innerHTML = msg ? (isErr ? `<span class="err">${esc(msg)}</span>` : esc(msg)) : '';
}

async function ptzPost(path, body) {
    try {
        const r = await fetch(path, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify(body || {}),
        });
        const d = await r.json().catch(() => ({}));
        if (!r.ok) { ptzSay(d.error || ('HTTP ' + r.status), true); return null; }
        return d;
    } catch (e) {
        ptzSay('command failed: ' + e.message, true);
        return null;
    }
}

// Press-and-hold: start moving on press, stop on release. `duration` is the
// dead-man's switch â€” if this page dies mid-press the camera stops itself
// rather than panning until it hits the end stop.
function ptzStartMove(vec) {
    if (!PTZ_CAM) return;
    const speed = (parseInt(document.getElementById('ptzSpeed').value, 10) || 50) / 100;
    const body = {duration: 4};
    if (vec.pan) body.pan = vec.pan * speed;
    if (vec.tilt) body.tilt = vec.tilt * speed;
    if (vec.zoom) body.zoom = vec.zoom * speed;
    ptzSay('movingâ€¦');
    ptzPost(`/api/ptz/move/${encodeURIComponent(PTZ_CAM.mac)}`, body);
}

function ptzStopMove() {
    if (!PTZ_CAM) return;
    ptzPost(`/api/ptz/stop/${encodeURIComponent(PTZ_CAM.mac)}`).then(() => ptzSay(''));
}

function ptzBindHold(btn, vec) {
    const start = e => { e.preventDefault(); ptzStartMove(vec); };
    const stop = e => { e.preventDefault(); ptzStopMove(); };
    btn.addEventListener('mousedown', start);
    btn.addEventListener('touchstart', start, {passive: false});
    ['mouseup', 'mouseleave', 'touchend', 'touchcancel'].forEach(ev =>
        btn.addEventListener(ev, stop));
}

async function ptzLoadPresets() {
    const box = document.getElementById('ptzPresets');
    box.innerHTML = '';
    let presets = [];
    try {
        const r = await fetch(`/api/ptz/presets/${encodeURIComponent(PTZ_CAM.mac)}`);
        presets = (await r.json()).presets || [];
    } catch (e) { /* fall through to the empty state */ }
    if (!presets.length) {
        box.innerHTML = '<span class="none">no presets yet â€” point the camera, then save one per generator</span>';
        return;
    }
    presets.forEach(p => {
        const b = document.createElement('button');
        b.className = 'btn';
        b.textContent = p.name || p.token;
        b.title = 'Go to preset ' + (p.token);
        b.onclick = async () => {
            ptzSay('going to ' + (p.name || p.token) + 'â€¦');
            const ok = await ptzPost(`/api/ptz/preset/${encodeURIComponent(PTZ_CAM.mac)}/goto/${encodeURIComponent(p.token)}`);
            // Focus is MANUAL on this camera and depends on where it's pointed,
            // so a preset recall may land sharp or soft â€” say so rather than
            // implying the view is verified.
            if (ok) ptzSay('at ' + (p.name || p.token) + ' â€” check focus');
        };
        box.appendChild(b);
    });
}

function openPtzView(cam) {
    PTZ_CAM = cam;
    document.getElementById('ptzName').textContent = cam.name || cam.mac;
    document.getElementById('ptzSite').textContent = [cam.site, cam.ip].filter(Boolean).join(' Â· ');
    ptzSay('');
    ptzOverlay.style.display = 'none';
    ptzView.style.display = 'flex';
    document.body.style.overflow = 'hidden';
    ptzSetMode(PTZ_MODE);
    ptzLoadPresets();
}

// go2rtc stream name for a camera. Config-driven so a second camera doesn't
// need a code change; falls back to the site name lowercased.
function ptzStreamName(cam) {
    return cam.stream || ((cam.site || '').toLowerCase() + '_ptz');
}

// Tear the player out of the DOM on close. This is what actually drops the
// connection to go2rtc â€” and that matters: the camera tolerates only a handful
// of concurrent RTSP sessions, and a leaked viewer keeps one pinned open.
function ptzDetachPlayer() {
    if (ptzFrameTimer) { clearInterval(ptzFrameTimer); ptzFrameTimer = null; }
    if (!ptzPlayer) return;
    try {
        if (ptzPlayer.tagName === 'VIDEO') { ptzPlayer.pause(); ptzPlayer.removeAttribute('src'); ptzPlayer.load(); }
    } catch (e) { /* nothing useful to do */ }
    ptzPlayer.remove();
    ptzPlayer = null;
}

// Snapshot polling. No MediaSource, no codec negotiation, no autoplay policy,
// no player library â€” an <img> we re-point a couple of times a second. It is
// the least clever option available and the only one that has never failed
// here: MSE stalled, progressive MP4 froze, and the player element locked up,
// all while the server measured clean. For "is anyone standing near that gen"
// a 2fps view is entirely sufficient, and it responds to PTZ within ~1s.
const PTZ_FRAME_MS = 500;
let ptzFrameTimer = null;

function ptzStartFrames(cam) {
    const img = document.createElement('img');
    img.className = 'frames';
    img.alt = cam.name;
    ptzStage.appendChild(img);
    ptzPlayer = img;
    let inFlight = false;
    const tick = () => {
        if (inFlight) return;                       // never queue behind a slow frame
        inFlight = true;
        const next = new Image();
        next.onload = () => {
            inFlight = false;
            if (ptzPlayer === img) img.src = next.src;   // swap only when decoded: no flicker
        };
        next.onerror = () => { inFlight = false; ptzSay('frame fetch failed', true); };
        next.src = `/api/ptz/snapshot/${encodeURIComponent(cam.mac)}?max_age=0&t=${Date.now()}`;
    };
    tick();
    ptzFrameTimer = setInterval(tick, PTZ_FRAME_MS);
    ptzSay('frames Â· ~2/sec Â· always works');
}

async function ptzAttachPlayer(cam, forceMp4) {
    ptzDetachPlayer();
    const name = ptzStreamName(cam);
    if (!forceMp4) {
        try {
            await ptzLoadPlayer();
            const el = document.createElement('video-stream');
            el.mode = 'mse';                       // WebRTC is disabled server-side
            el.background = false;
            ptzStage.appendChild(el);
            el.src = '/go2rtc/api/ws?src=' + encodeURIComponent(name);
            ptzPlayer = el;
            ptzSay('live Â· MSE');
            return;
        } catch (e) {
            ptzSay('live player failed (' + e.message + '), falling back to MP4', true);
        }
    }
    // Fallback: progressive MP4. Higher latency and it can stall on a long
    // session, but it plays in anything.
    const v = document.createElement('video');
    v.muted = true; v.autoplay = true; v.playsInline = true; v.controls = true;
    v.src = '/go2rtc/api/stream.mp4?src=' + encodeURIComponent(name);
    ptzStage.appendChild(v);
    ptzPlayer = v;
    const pr = v.play();
    if (pr && pr.catch) pr.catch(() => { ptzOverlay.style.display = 'flex'; });
    ptzSay('live Â· MP4 fallback');
}

function closePtzView() {
    ptzView.style.display = 'none';
    document.body.style.overflow = '';
    ptzDetachPlayer();
    if (PTZ_CAM) ptzStopMove();
    PTZ_CAM = null;
}

document.getElementById('ptzClose').onclick = closePtzView;
// Wiper. Confirmed working on the Dan camera (the blade visibly sweeps), and
// it's the thing you'll want on an ND winter morning. Behind a confirm because
// wiping a DRY, dusty lens can smear it or score the glass â€” this should be a
// deliberate act, not something you hit while reaching for the zoom.
document.getElementById('ptzWiper').onclick = async () => {
    if (!PTZ_CAM) return;
    if (!confirm('Sweep the lens wiper?\n\nOnly useful on a wet or iced lens â€” '
               + 'wiping dry grit can scratch the glass.')) return;
    ptzSay('wipingâ€¦');
    const ok = await ptzPost(`/api/ptz/aux/${encodeURIComponent(PTZ_CAM.mac)}/wiper_on`);
    if (!ok) return;
    // The camera runs a fixed sweep and stops on its own; send Off anyway so a
    // failed cycle can't leave the blade parked across the lens.
    setTimeout(() => ptzPost(`/api/ptz/aux/${encodeURIComponent(PTZ_CAM.mac)}/wiper_off`)
        .then(() => ptzSay('wiper done')), 8000);
};

// Day/night mode. This is the one ONVIF write on this camera that demonstrably
// works â€” the picture really does switch between mono IR and colour. Left on
// Auto normally; Colour is useful at night IF the site has its own lighting,
// since colour is far easier to judge a person by than monochrome IR.
document.getElementById('ptzIrcut').addEventListener('click', async e => {
    const btn = e.target.closest('button[data-ir]');
    if (!btn || !PTZ_CAM) return;
    ptzSay('setting ' + btn.dataset.ir + 'â€¦');
    const r = await ptzPost(`/api/ptz/ircut/${encodeURIComponent(PTZ_CAM.mac)}/${btn.dataset.ir}`);
    if (r) {
        ptzSay('day/night mode: ' + r.mode);
        [...e.currentTarget.querySelectorAll('button')].forEach(b =>
            b.classList.toggle('active', b === btn));
    }
});

document.getElementById('ptzModeFrames').onclick = () => ptzSetMode('frames');
document.getElementById('ptzModeLive').onclick = () => ptzSetMode('live');
document.getElementById('ptzPlay').onclick = () => {
    ptzOverlay.style.display = 'none';
    if (ptzPlayer && ptzPlayer.play) ptzPlayer.play();
};
ptzView.addEventListener('click', e => { if (e.target === ptzView) closePtzView(); });
document.querySelectorAll('.ptz-pad button, .ptz-zoom button').forEach(btn => {
    if (btn.dataset.stop) { btn.onclick = ptzStopMove; return; }
    ptzBindHold(btn, {
        pan: parseFloat(btn.dataset.pan || 0),
        tilt: parseFloat(btn.dataset.tilt || 0),
        zoom: parseFloat(btn.dataset.zoom || 0),
    });
});

// ---- auto-refresh ---------------------------------------------------------
// The /cameras poll is metadata only -- it reads the cloud's disk cache and
// never touches Blink, so it carries no rate-limit risk. But images only change
// once a day, so a flat 60s poll spent 23 hours refreshing numbers that move in
// hours. What IS live: the field agent's 30s heartbeat (the "field online"
// pill), and the 08:00 daily run landing camera-by-camera over ~18 minutes with
// retry rounds ~45m behind it. So poll fast only when something is moving.
const REFRESH_ACTIVE_MS = 60 * 1000;
const REFRESH_IDLE_MS = 5 * 60 * 1000;

function refreshIntervalMs() {
    const d = LAST_DATA;
    if (!d) return REFRESH_ACTIVE_MS;
    if (!d.field_online) return REFRESH_ACTIVE_MS;   // watch for it coming back
    const cams = d.cameras || [];
    if (cams.some(isProblem)) return REFRESH_ACTIVE_MS;
    // A capture landed in the last half hour => a daily run or site snap is
    // still in progress. Derived from the data, not the clock, so it doesn't
    // care whether the browser shares cherub's timezone.
    if (cams.some(c => c.snap_age_sec != null && c.snap_age_sec < 1800)) return REFRESH_ACTIVE_MS;
    return REFRESH_IDLE_MS;
}

let refreshTimer = null;
function scheduleRefresh() {
    clearTimeout(refreshTimer);
    if (document.hidden) return;   // a backgrounded tab polls for nobody
    refreshTimer = setTimeout(() => { loadCameras().then(scheduleRefresh); }, refreshIntervalMs());
}

// Pause while hidden, and catch up the moment the tab comes back so you never
// look at a stale page thinking it's current.
document.addEventListener('visibilitychange', () => {
    if (document.hidden) { clearTimeout(refreshTimer); return; }
    loadCameras().then(scheduleRefresh);
});

loadOrder().then(loadCameras).then(scheduleRefresh);
</script>
<script>z</script>
</body>
</html>)Ú__doc__ÚcgiÚsysÚpathÚinsertÚmanagers.auth_managerr   r   r   Úlinksr   r   r   ÚFieldStorageÚformÚauthÚrequire_authÚauth_requiredÚshould_exitÚheadersÚprintÚexitÚget_user_accessÚ_user_access© r   r   ú /var/www/html/ngon/cams/index.pyÚ<module>   sº   


øø   	              � � � � � � � � � � � � �ø              � � � � � � � � � � � � �
ø