diff --git a/server/public/assets/app.css b/server/public/assets/app.css index 1ed9ca5..7844268 100644 --- a/server/public/assets/app.css +++ b/server/public/assets/app.css @@ -1,97 +1,246 @@ -/* Base layout ------------------------------------------------------- */ +/* ================================================================== + MOVE·DETECT dashboard — light, calm, minimal. + A safety board is calm most of the time and urgent rarely, so it stays + near-monochrome with ONE interactive accent (indigo) and spends its only + real color on the unacknowledged-critical alarm. Soft cards, generous + whitespace, big tabular numbers. + Air-gapped (폐쇄망): system fonts only, no external assets fetched at runtime. + ================================================================== */ :root { color-scheme: light; - --border: #d1d5db; - --text: #1f2937; + + --bg:#f5f6f8; --card:#ffffff; --line:#e7e9ee; + --ink:#161a20; --ink-2:#5b636e; --ink-3:#949aa4; + + --accent:#4f46e5; /* indigo — only where you act (nav, links, focus) */ + --ok:#16a34a; /* "감시 중" live-monitoring green */ + + /* Severity (reserved, semantic — never reused as a decorative color). Identity is + always carried by a text label too (NF9: never color alone). */ + --crit:#dc2626; --high:#ea580c; --warn:#ca8a04; --info:#64748b; + --crit-bg:#fdf3f3; --crit-line:#f4c9c9; + + --border:var(--line); /* back-compat alias */ + --text:var(--ink); + + --font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif; + --mono:ui-monospace,"Cascadia Mono","Cascadia Code",Consolas,"D2Coding","Roboto Mono",monospace; + --shadow:0 1px 2px rgba(20,24,31,.04), 0 8px 24px -18px rgba(20,24,31,.5); } -* { box-sizing: border-box; } +* { box-sizing:border-box; } body { - margin: 0; - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; - color: var(--text); - background: #f9fafb; + margin:0; + font-family:var(--font); + color:var(--ink); + background:var(--bg); + line-height:1.45; + -webkit-font-smoothing:antialiased; } + +/* ---- Header ------------------------------------------------------ */ .app-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - background: #111827; - color: #fff; + display:flex; align-items:center; gap:1rem; + padding:.85rem 1.6rem; + background:var(--card); border-bottom:1px solid var(--line); } -.app-nav a { color: #fff; margin-right: 1rem; text-decoration: none; } -.app-nav a:hover, .app-nav a:focus { text-decoration: underline; } -.app-session { display: flex; align-items: center; gap: 0.5rem; } -.app-session a { color: #fff; } -.app-main { padding: 1rem; max-width: 960px; margin: 0 auto; } +.app-brand { display:flex; align-items:center; gap:.55rem; font-weight:700; font-size:.98rem; letter-spacing:-.01em; } +/* Live-monitoring dot — the board is watching. Calm green, not an alarm. */ +.live-dot { width:8px; height:8px; border-radius:50%; background:var(--ok); box-shadow:0 0 0 3px rgba(22,163,74,.14); flex:none; } +.brand-name { color:var(--ink); } +.brand-sub { display:none; } /* the mono wordmark is gone; keep the hook harmless */ -.login-form, .zone-controls { display: flex; flex-direction: column; gap: 0.5rem; max-width: 320px; } -.zone-controls { flex-direction: row; align-items: end; flex-wrap: wrap; max-width: none; } -.zone-controls label { display: flex; flex-direction: column; font-size: 0.85rem; } -.form-error { color: #991b1b; } - -#zone-canvas { - border: 1px solid var(--border); - background: #111827 center / contain no-repeat; - cursor: crosshair; - max-width: 100%; +.app-nav { display:flex; gap:.2rem; margin-left:1.8rem; margin-right:auto; } +.app-nav a { + position:relative; + color:var(--ink-2); text-decoration:none; + font-size:.87rem; font-weight:500; + padding:.45rem .75rem; border-radius:8px; } - -button { - cursor: pointer; - padding: 0.4rem 0.8rem; - border: 1px solid var(--border); - border-radius: 4px; - background: #fff; +.app-nav a:hover, .app-nav a:focus-visible { background:#f1f2f5; color:var(--ink); text-decoration:none; } +.app-nav a.is-active { color:var(--accent); } +.app-nav a.is-active::after { + content:""; position:absolute; left:.75rem; right:.75rem; bottom:-.86rem; height:2px; + background:var(--accent); border-radius:2px; } -button:hover { background: #f3f4f6; } +.app-session { display:flex; align-items:center; gap:.7rem; font-size:.85rem; color:var(--ink-2); } +.app-session .who { color:var(--ink); } +.app-session a { color:var(--accent); text-decoration:none; font-weight:500; } +.app-session a:hover { text-decoration:underline; text-underline-offset:3px; } -/* Event list ---------------------------------------------------------- */ +/* ---- Buttons / fields -------------------------------------------- */ +button, .btn { + font:inherit; font-size:.83rem; font-weight:500; cursor:pointer; + padding:.42rem .8rem; + color:var(--ink); background:#fff; + border:1px solid var(--line); border-radius:9px; + transition:transform .1s, background .15s, border-color .15s; +} +button:hover, .btn:hover { background:#f7f8fa; border-color:#dcdfe4; } +button:active, .btn:active { transform:scale(.98); } +:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } + +input, select { + font:inherit; font-size:.9rem; color:var(--ink); + padding:.5rem .65rem; + background:#fff; border:1px solid var(--line); border-radius:9px; +} +input::placeholder { color:var(--ink-3); } +input:focus, select:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); } +label { color:var(--ink-2); font-size:.85rem; } + +/* ---- Page shell -------------------------------------------------- */ +.app-main { max-width:1000px; margin:0 auto; padding:2rem 1.6rem 4rem; } +.page-head { display:flex; align-items:baseline; gap:.8rem; margin:0 0 .25rem; flex-wrap:wrap; } +h1 { font-size:1.5rem; font-weight:750; letter-spacing:-.025em; margin:.1rem 0 0; } +/* Live watch pill (set by app.js from poll health): green when polling, muted when stale. + The dot is a ::before so app.js can freely overwrite the text via textContent. */ +.watch { font-size:.82rem; font-weight:600; color:var(--ok); display:inline-flex; align-items:center; gap:.35rem; } +.watch::before { content:""; width:6px; height:6px; border-radius:50%; background:currentColor; flex:none; } +.watch.is-stale { color:var(--ink-3); } +.page-sub { color:var(--ink-3); font-size:.88rem; margin:.15rem 0 1.8rem; } + +/* ---- Status strip: KPI tiles ------------------------------------- */ +/* Unacknowledged counts per severity, computed client-side from the same /api/events + array the list renders (no extra endpoint). Identity is a colored dot + a text label, + never color alone (NF9). A zero tile dims but never hides, so the strip never reflows. */ +.status-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:.85rem; margin:0 0 2rem; } +.stat-tile { + background:var(--card); border:1px solid var(--line); border-radius:14px; + padding:1.05rem 1.15rem; box-shadow:var(--shadow); +} +.stat-tile .stat-label { display:flex; align-items:center; gap:.45rem; font-size:.79rem; font-weight:500; color:var(--ink-2); } +.stat-tile .stat-label .led { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dot,#c4c9d0); } +.stat-tile .stat-label .icon { display:none; } /* the dot carries the color; the emoji is dropped in this theme */ +.stat-tile .stat-count { + display:block; margin-top:.5rem; + font-size:2.3rem; font-weight:730; letter-spacing:-.04em; line-height:1; + color:var(--ink); font-variant-numeric:tabular-nums; +} +.stat-tile.is-zero { opacity:.62; } +.stat-tile.is-zero .stat-count { color:var(--ink-3); } +.tile-critical { --dot:var(--crit); } +.tile-high { --dot:var(--high); } +.tile-warning { --dot:var(--warn); } +.tile-info { --dot:var(--info); } +/* The one moment of color: an unacknowledged critical tints its tile. */ +.tile-critical:not(.is-zero) { background:var(--crit-bg); border-color:var(--crit-line); } +.tile-critical:not(.is-zero) .stat-count { color:var(--crit); } + +/* ---- Device / camera health panel -------------------------------- */ +/* Surfaces the heartbeat telemetry the edge already reports (online/offline, fps, temp, + throttle, config drift). A dead camera is a safety blind spot; an offline card takes the + same red the critical alarm does. State is never color alone — the "오프라인/응답 없음" + text carries it too (NF9). */ +.device-strip { display:flex; flex-wrap:wrap; gap:.6rem; margin:0 0 2rem; } +.device-strip.is-empty { color:var(--ink-3); font-size:.85rem; } +.device-card { + flex:1 1 11rem; min-width:11rem; + background:var(--card); border:1px solid var(--line); border-radius:12px; + padding:.7rem .85rem; box-shadow:var(--shadow); +} +.device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.9rem; } +.device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; } +.device-card .device-meta { margin-top:.3rem; font-family:var(--mono); font-size:.76rem; color:var(--ink-3); } +.device-card .device-warn { margin-top:.35rem; font-size:.72rem; font-weight:600; color:var(--warn); } +.device-card.is-offline { background:var(--crit-bg); border-color:var(--crit-line); } +.device-card.is-offline .device-name .dot { background:var(--crit); } +/* Device poll went stale (500 / WAF): dim the last-known cards and say so, never freeze silently. */ +.device-strip.is-stale { opacity:.6; } +.device-stale-note { flex-basis:100%; font-size:.72rem; color:var(--ink-3); } +/* "new events arrived while paging" reload affordance — honest about a possible gap in the list. */ +.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .8rem; padding:.5rem .75rem; + font-size:.8rem; color:var(--ink-2); background:var(--card); border:1px solid var(--line); border-radius:8px; } +.page-gap-hint[hidden] { display:none; } + +/* ---- Event list: soft cards -------------------------------------- */ .event-row { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.6rem 0.75rem; - border: 1px solid var(--border); - border-radius: 6px; - margin-bottom: 0.5rem; - background: #fff; + display:flex; align-items:center; gap:1rem; + background:var(--card); border:1px solid var(--line); border-radius:14px; + padding:.95rem 1.15rem; margin-bottom:.6rem; box-shadow:var(--shadow); } -.event-row.is-unacknowledged { border-left: 4px solid #4b5563; } -.event-meta { flex: 1; } -.event-meta .event-type { font-weight: 600; } -.event-meta .event-time { color: #4b5563; font-size: 0.85rem; } +/* An unacknowledged critical is the only row that takes color; the tint + red badge + + "확인 필요" chip make it unmissable without shouting. */ +.event-row.is-unacknowledged.sev-row-critical { background:var(--crit-bg); border-color:var(--crit-line); } -/* Severity badges (NF9): color-blind-safe — icon + text label + color. - Never rely on color alone. Pastel background + dark text pairs are - chosen for WCAG-AA contrast (>= 4.5:1 for normal-size text). */ -.severity-badge { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.15rem 0.5rem; - border-radius: 999px; - font-weight: 700; - font-size: 0.85rem; - white-space: nowrap; +/* Evidence thumbnail (newest snapshot) — a small camera still on the left of the row. */ +.event-thumb { width:64px; height:44px; object-fit:cover; border-radius:8px; border:1px solid var(--line); background:#0f1216; flex:none; } + +.severity-badge { display:inline-flex; align-items:center; gap:.45rem; flex:none; min-width:66px; font-size:.84rem; font-weight:600; white-space:nowrap; } +/* app.js/detail.js build the badge as (emoji)text. + In this theme the icon becomes a colored dot (emoji hidden) and the label carries identity. */ +.severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; } +.sev-critical { color:var(--crit); } +.sev-high { color:var(--high); } +.sev-warning { color:var(--warn); } +.sev-info { color:var(--info); } + +.event-meta { flex:1; min-width:0; } +.event-type { font-weight:650; font-size:1rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; } +.event-time { margin-top:.2rem; font-family:var(--mono); font-size:.79rem; color:var(--ink-3); } +.event-detail-link { color:inherit; text-decoration:none; } +.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; } +/* "확인 필요" chip, appended by app.js to an unacknowledged critical row. */ +.chip { font-size:.72rem; font-weight:600; color:var(--crit); background:#fbe2e2; padding:.12rem .5rem; border-radius:999px; } + +.ack-btn { margin-left:auto; flex:none; } +.ack-btn:disabled { cursor:default; color:var(--ink-3); background:none; border:none; box-shadow:none; font-family:var(--mono); font-size:.8rem; } + +/* ---- Section labels --------------------------------------------- */ +.section-label { font-size:.79rem; font-weight:600; color:var(--ink-3); letter-spacing:.04em; margin:0 0 .7rem; } + +/* ---- List toolbar: filters + "더 보기" --------------------------- */ +.list-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.7rem; } +.list-head .section-label { margin:0; } +.list-filters { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; } +.list-filters select { font-size:.82rem; padding:.35rem .5rem; } +.filter-check { display:inline-flex; align-items:center; gap:.35rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; } +.filter-check input { accent-color:var(--accent); } +.list-more { display:flex; justify-content:center; margin-top:.5rem; } +.list-more button[hidden] { display:none; } + +/* ---- Login ------------------------------------------------------- */ +.login-form { + display:flex; flex-direction:column; gap:.65rem; max-width:340px; margin:3.5rem auto 0; + background:var(--card); border:1px solid var(--line); border-radius:16px; padding:1.8rem; + box-shadow:var(--shadow); } -.severity-badge .icon { font-size: 1rem; line-height: 1; } +.login-form h1 { margin:.1rem 0 .5rem; } +.form-error { color:var(--crit); font-size:.85rem; } -.sev-critical { background: #fee2e2; color: #991b1b; border: 1px solid #991b1b; } -.sev-high { background: #ffedd5; color: #9a3412; border: 1px solid #9a3412; } -.sev-warning { background: #fef9c3; color: #854d0e; border: 1px solid #854d0e; } -.sev-info { background: #e5e7eb; color: #374151; border: 1px solid #374151; } +/* ---- Zone editor ------------------------------------------------- */ +.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; } +.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; } +#zone-canvas { + border:1px solid var(--line); border-radius:12px; + background:#0f1216 center / contain no-repeat; /* dark canvas: a camera frame reads best on dark */ + cursor:crosshair; max-width:100%; + box-shadow:var(--shadow); +} +#zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; } +#zone-list li { + display:flex; align-items:center; justify-content:space-between; gap:.75rem; + max-width:360px; padding:.5rem .75rem; + background:var(--card); border:1px solid var(--line); border-radius:10px; + font-size:.85rem; color:var(--ink-2); +} +#zone-status { font-family:var(--mono); font-size:.82rem; color:var(--ink-3); } -.ack-btn { margin-left: auto; } -.ack-btn:disabled { opacity: 0.6; cursor: default; } +/* ---- Incident detail -------------------------------------------- */ +.detail-row { margin:.35rem 0; } +.detail-row .detail-label { color:var(--ink-3); font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.05em; } +#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; } +.detail-snapshot { + max-width:100%; height:auto; + border:1px solid var(--line); border-radius:12px; background:#fff; padding:4px; + box-shadow:var(--shadow); +} -/* Dashboard row -> incident detail link (keyboard-focusable affordance). */ -.event-detail-link { color: inherit; text-decoration: none; } -.event-detail-link:hover, -.event-detail-link:focus-visible { text-decoration: underline; } - -/* Incident detail view (event_detail.php + detail.js). */ -.detail-row { margin: 0.25rem 0; } -.detail-row .detail-label { color: #4b5563; font-weight: 600; } -#event-detail-snapshots { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; } -.detail-snapshot { max-width: 100%; height: auto; border: 1px solid #d1d5db; border-radius: 4px; } +/* ---- Responsive -------------------------------------------------- */ +@media (max-width:720px) { + .status-strip { grid-template-columns:repeat(2,1fr); } + .app-nav { margin-left:0; } + .app-main { padding:1.5rem 1.1rem 3rem; } +} +@media (max-width:420px) { + .status-strip { grid-template-columns:1fr 1fr; } +} diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 7aea0a5..f5de3ae 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -25,6 +25,15 @@ var root = document.getElementById('events-root'); if (!root) return; + // Live-monitoring pill: honest about poll health — green "실시간 감시 중" while events + // are flowing, muted "연결 지연" when a poll fails, so a stale board never looks live. + var watchEl = document.getElementById('watch-status'); + function setWatch(ok) { + if (!watchEl) return; + watchEl.textContent = ok ? '실시간 감시 중' : '연결 지연'; + watchEl.classList.toggle('is-stale', !ok); + } + var csrfToken = null; function loadCsrfToken() { return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } }) @@ -112,8 +121,26 @@ function buildRow(ev) { var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; + // sev-row-* drives the severity-colored left rail (and the critical glow) on + // unacknowledged rows. Constrain to the known tiers so the class can't carry an + // arbitrary server value into the DOM. + var sevRow = SEVERITY_LABELS.hasOwnProperty(ev.severity) ? ev.severity : 'info'; var row = document.createElement('div'); - row.className = 'event-row' + (isAcked ? '' : ' is-unacknowledged'); + row.className = 'event-row sev-row-' + sevRow + (isAcked ? '' : ' is-unacknowledged'); + + // Evidence thumbnail (newest snapshot for this event), when one exists. The goes + // through /snap/{uuid}, which re-checks session + site scope, so the uuid grants nothing. + if (ev.thumb_uuid) { + var thumb = document.createElement('img'); + thumb.className = 'event-thumb'; + thumb.src = B + '/snap/' + encodeURIComponent(ev.thumb_uuid); + thumb.alt = '스냅샷'; + thumb.loading = 'lazy'; + // A snapshot can be purged (PIPA retention) after the row was fetched; drop the + // rather than leave a broken-image box. + thumb.onerror = function () { if (thumb.parentNode) thumb.parentNode.removeChild(thumb); }; + row.appendChild(thumb); + } row.appendChild(buildBadge(ev.severity)); @@ -127,6 +154,14 @@ typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view'; typeLink.textContent = String(ev.event_type || ''); type.appendChild(typeLink); + // The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip — + // secondary encoding alongside the red tint, so it never rides on color alone. + if (!isAcked && ev.severity === 'critical') { + var chip = document.createElement('span'); + chip.className = 'chip'; + chip.textContent = '확인 필요'; + type.appendChild(chip); + } meta.appendChild(type); var sub = document.createElement('div'); @@ -161,6 +196,11 @@ }) .then(function (res) { if (!res.ok) throw new Error('ack_failed:' + res.status); + // Reflect the ack on a "더 보기"-loaded (older-window) row too. loadEvents() only refreshes + // the live window, so without this an event acked from the paged tail snaps straight back + // to unacknowledged on the next render — inviting a duplicate ack that overwrites the + // original acknowledged_by audit record. + patchOlderAck(eventId); return loadEvents(); }) .catch(function () { @@ -168,59 +208,350 @@ }); } - function render(events) { - var sorted = events.slice().sort(function (a, b) { - var aAcked = a.acknowledged_by !== null && a.acknowledged_by !== undefined && a.acknowledged_by !== ''; - var bAcked = b.acknowledged_by !== null && b.acknowledged_by !== undefined && b.acknowledged_by !== ''; - if (aAcked === bAcked) return 0; - return aAcked ? 1 : -1; // unacknowledged first + function patchOlderAck(eventId) { + for (var i = 0; i < olderEvents.length; i++) { + var ev = olderEvents[i]; + if (ev && ev.event_id === eventId && !isAcked(ev)) { + // New object, never mutate the existing one (keeps combinedEvents' dedup predictable). + olderEvents[i] = Object.assign({}, ev, { acknowledged_by: window.APP_USER || '확인' }); + } + } + } + + // Status strip: count UNACKNOWLEDGED events per severity from the same array the + // list renders. Stat tiles (not a chart): small integers a desk operator scans. + // Every tier tile is always drawn (zero -> muted) so the strip never reflows. + var statusRoot = document.getElementById('events-status'); + var STATUS_TIERS = [ + { key: 'critical', cls: 'tile-critical' }, + { key: 'high', cls: 'tile-high' }, + { key: 'warning', cls: 'tile-warning' }, + { key: 'info', cls: 'tile-info' } + ]; + + function isAcked(ev) { + return ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; + } + + function renderStatus(events) { + if (!statusRoot) return; + var counts = { critical: 0, high: 0, warning: 0, info: 0 }; + events.forEach(function (ev) { + if (isAcked(ev)) return; + var sev = counts.hasOwnProperty(ev.severity) ? ev.severity : 'info'; + counts[sev] += 1; }); - updateAlarm(sorted); // before any early return, so an emptied list also silences the alarm + statusRoot.textContent = ''; + var frag = document.createDocumentFragment(); + STATUS_TIERS.forEach(function (tier) { + var info = severityInfo(tier.key); + var n = counts[tier.key]; + + var tile = document.createElement('div'); + tile.className = 'stat-tile ' + tier.cls + (n === 0 ? ' is-zero' : ''); + + var label = document.createElement('span'); + label.className = 'stat-label'; + var led = document.createElement('span'); + led.className = 'led'; + led.setAttribute('aria-hidden', 'true'); + var icon = document.createElement('span'); + icon.className = 'icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = info.icon; + var text = document.createElement('span'); + text.textContent = '미확인 ' + info.text; + label.appendChild(led); + label.appendChild(icon); + label.appendChild(text); + + var count = document.createElement('span'); + count.className = 'stat-count'; + count.textContent = String(n); + + tile.appendChild(label); + tile.appendChild(count); + frag.appendChild(tile); + }); + statusRoot.appendChild(frag); + } + + // --- filter + pagination state --------------------------------------------------- + // Filters are CLIENT-SIDE (they narrow only the displayed list); the status strip and the + // audible alarm keep considering the full live set, so filtering the view can never silence a + // real unacknowledged critical. Pagination is SERVER-SIDE (keyset cursor): "더 보기" pulls + // events older than the live window into an accumulated tail that the 5s poll does not wipe. + var liveEvents = []; // newest window from the poll (drives status + alarm) + var olderEvents = []; // events pulled in by "더 보기", older than the live window + var moreCursor = null; // cursor for the NEXT "더 보기" (null = no older page) + var pagedByUser = false; // once true, polls stop resetting moreCursor to the live boundary + var newestIdAtPaging = null; // newest live event_id at the moment paging began (gap detection) + var pageGapHint = null; // lazily-built "new events arrived while paging" reload affordance + var filters = { severity: '', camera: '', unackedOnly: false }; + var cameraSel = document.getElementById('camera-filter'); + var moreBtn = document.getElementById('load-more'); + + function matchesFilters(ev) { + if (filters.severity && ev.severity !== filters.severity) return false; + if (filters.camera && String(ev.camera_id || '') !== filters.camera) return false; + if (filters.unackedOnly && isAcked(ev)) return false; + return true; + } + + function combinedEvents() { + var seen = {}, out = []; + liveEvents.concat(olderEvents).forEach(function (ev) { + if (seen[ev.event_id]) return; // dedup: a poll may overlap the paged tail by an event + seen[ev.event_id] = true; + out.push(ev); + }); + return out; + } + + // Populate the camera dropdown from the camera_ids actually present, rebuilding only when that + // set changes so a poll never resets the operator's current selection. + function refreshCameraOptions(events) { + if (!cameraSel) return; + var cams = {}; + events.forEach(function (ev) { if (ev.camera_id) cams[String(ev.camera_id)] = true; }); + var list = Object.keys(cams).sort(); + var want = list.join('|'); + if (cameraSel.getAttribute('data-opts') === want) return; + cameraSel.setAttribute('data-opts', want); + var current = cameraSel.value; + cameraSel.textContent = ''; + var all = document.createElement('option'); + all.value = ''; all.textContent = '전체 카메라'; + cameraSel.appendChild(all); + list.forEach(function (c) { + var o = document.createElement('option'); + o.value = c; o.textContent = c; + cameraSel.appendChild(o); + }); + // Preserve the operator's active camera filter even if that camera has no event in the current + // window. Silently resetting to "전체 카메라" floods the list with other cameras' events, which + // reads as the filtered camera's activity. Keep the selection as an option so the filter stays + // applied (the list then honestly shows "none" for a quiet camera). + if (current && !cams[current]) { + var keep = document.createElement('option'); + keep.value = current; keep.textContent = current + ' (최근 이벤트 없음)'; + cameraSel.appendChild(keep); + } + cameraSel.value = current; // always preserve the selection ('' = 전체 카메라 is preserved too) + } + + function renderList() { + renderStatus(liveEvents); // counts always from the full live window, never the filtered view + updateAlarm(liveEvents); // alarm too — before any early return + + var all = combinedEvents(); + refreshCameraOptions(all); + + var shown = all.filter(matchesFilters).sort(function (a, b) { + var aA = isAcked(a), bA = isAcked(b); + if (aA !== bA) return aA ? 1 : -1; // unacknowledged first + var at = Number(a.occurred_at) || 0, bt = Number(b.occurred_at) || 0; + if (at !== bt) return bt - at; // then newest first + return (b.event_id || 0) - (a.event_id || 0); + }); root.textContent = ''; root.removeAttribute('aria-busy'); - if (sorted.length === 0) { - root.textContent = '표시할 이벤트가 없습니다.'; - return; + if (shown.length === 0) { + var anyFilter = filters.severity || filters.camera || filters.unackedOnly; + root.textContent = anyFilter ? '조건에 맞는 이벤트가 없습니다.' : '표시할 이벤트가 없습니다.'; + } else { + var frag = document.createDocumentFragment(); + shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); }); + root.appendChild(frag); } - var frag = document.createDocumentFragment(); - sorted.forEach(function (ev) { frag.appendChild(buildRow(ev)); }); - root.appendChild(frag); + if (moreBtn) moreBtn.hidden = (moreCursor === null); + updatePageGapHint(); + } + + function updatePageGapHint() { + // A gap exists once new events have arrived since the operator paged: some events have slid out + // of the live window into a band that was never fetched. Surface it honestly with a reload, + // rather than let the operator scroll a timeline with a silent hole in the middle. + var gap = pagedByUser && liveEvents.length > 0 && newestIdAtPaging !== null + && (liveEvents[0].event_id || 0) > newestIdAtPaging; + if (!pageGapHint) { + pageGapHint = document.createElement('div'); + pageGapHint.className = 'page-gap-hint'; + var span = document.createElement('span'); + span.textContent = '페이징 이후 새 이벤트가 도착해 목록 일부가 밀렸을 수 있습니다.'; + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = '새로고침'; + btn.addEventListener('click', function () { window.location.reload(); }); + pageGapHint.appendChild(span); + pageGapHint.appendChild(btn); + if (root.parentNode) root.parentNode.insertBefore(pageGapHint, root); // sibling above the list, survives root clears + } + pageGapHint.hidden = !gap; } function loadEvents() { - // Request the full server page cap (MAX_LIMIT=200), not the default 50: the audible alarm - // and the "unacknowledged first" pinning only consider the rows fetched here. During an event - // burst, a default page of 50 could push an unacknowledged critical off the fetched set, and - // updateAlarm() would then clear the alarm while a critical fall is still unacknowledged. + // Newest window (server MAX_LIMIT=200). The audible alarm and the "unacknowledged first" + // pinning only consider this set, so it stays the full cap — during an event burst a smaller + // page could push an unacknowledged critical off it and updateAlarm() would clear the alarm + // while a fall is still unacknowledged. return fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } }) .then(function (res) { - if (res.status === 401) { - window.location.href = B + '/login'; - return null; - } + if (res.status === 401) { window.location.href = B + '/login'; return null; } if (!res.ok) throw new Error('list_failed:' + res.status); return res.json(); }) .then(function (data) { - if (data && Array.isArray(data.events)) render(data.events); + if (data && Array.isArray(data.events)) { + liveEvents = data.events; + // Track the live boundary as the next-page cursor only until the operator starts paging; + // after that "더 보기" advances moreCursor itself and a poll must not reset it. + if (!pagedByUser) moreCursor = data.next_cursor || null; + renderList(); + } + setWatch(true); }) .catch(function () { - // Leave the last rendered state on transient poll failure. + // Leave the last rendered state on transient poll failure, but flag the staleness. + setWatch(false); }); } - var logoutBtn = document.getElementById('logout-btn'); - if (logoutBtn) { - logoutBtn.addEventListener('click', function () { - fetch(B + '/logout', { method: 'POST' }).then(function () { window.location.href = B + '/login'; }); + function loadMore() { + if (moreCursor === null) return; + if (moreBtn) { moreBtn.disabled = true; moreBtn.textContent = '불러오는 중...'; } + fetch(B + '/api/events?limit=200&cursor=' + encodeURIComponent(moreCursor), { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { window.location.href = B + '/login'; return null; } + if (!res.ok) throw new Error('more_failed:' + res.status); + return res.json(); + }) + .then(function (data) { + if (data && Array.isArray(data.events)) { + // Capture the newest live id at the moment paging begins. If newer events arrive after + // this, the live window (newest 200) slides forward and events between its new bottom and + // the frozen paged boundary belong to neither array — combinedEvents() drops them + // silently. We cannot re-fetch that middle band cheaply, so we at least SURFACE it: the + // gap hint (renderList) tells the operator to reload for the complete timeline. + if (!pagedByUser) newestIdAtPaging = (liveEvents[0] && liveEvents[0].event_id) || 0; + pagedByUser = true; + olderEvents = olderEvents.concat(data.events); + moreCursor = data.next_cursor || null; + renderList(); + } + }) + .catch(function () { /* leave state; the button re-enables below */ }) + .then(function () { + if (moreBtn) { moreBtn.disabled = false; moreBtn.textContent = '더 보기'; } + }); + } + + // Logout is wired in the shared layout footer (src/View/layout.php) so it works on every page, + // not just this one — app.js bails early on pages without #events-root. + + // --- device / camera health panel ------------------------------------------------- + // GET /api/devices surfaces the heartbeat telemetry the edge already reports (online/offline, + // fps, temp, throttle, config drift). A dead camera is a safety blind spot, so this is the + // panel that turns "the board looks calm" into "the board is actually watching". + var devicesRoot = document.getElementById('devices-root'); + + function fmtAgo(secs) { + if (secs === null || secs === undefined) return '응답 없음'; + if (secs < 60) return secs + '초 전'; + if (secs < 3600) return Math.floor(secs / 60) + '분 전'; + return Math.floor(secs / 3600) + '시간 전'; + } + + function renderDevices(devices) { + if (!devicesRoot) return; + devicesRoot.textContent = ''; + if (!devices || devices.length === 0) { + devicesRoot.className = 'device-strip is-empty'; + devicesRoot.textContent = '등록된 장치가 없습니다.'; + return; + } + devicesRoot.className = 'device-strip'; + var frag = document.createDocumentFragment(); + devices.forEach(function (d) { + var card = document.createElement('div'); + card.className = 'device-card' + (d.online ? '' : ' is-offline'); + + var name = document.createElement('div'); + name.className = 'device-name'; + var dot = document.createElement('span'); + dot.className = 'dot'; + dot.setAttribute('aria-hidden', 'true'); + var nm = document.createElement('span'); + nm.textContent = d.name || d.device_id; // never color alone: the "오프라인" text below carries state too + name.appendChild(dot); + name.appendChild(nm); + card.appendChild(name); + + var meta = document.createElement('div'); + meta.className = 'device-meta'; + var parts = []; + if (d.online) parts.push(fmtAgo(d.seconds_since)); + else if (d.seconds_since === null) parts.push('응답 없음'); + else parts.push('오프라인 · ' + fmtAgo(d.seconds_since)); + if (d.fps !== null && d.fps !== undefined) parts.push((Math.round(d.fps * 10) / 10) + ' fps'); + if (d.temp !== null && d.temp !== undefined && d.temp > 0) parts.push(Math.round(d.temp) + '°'); + meta.textContent = parts.join(' · '); + card.appendChild(meta); + + if (d.throttle || d.config_drift) { + var warn = document.createElement('div'); + warn.className = 'device-warn'; + var w = []; + if (d.throttle) w.push('과열 스로틀'); + if (d.config_drift) w.push('설정 미적용'); + warn.textContent = w.join(' · '); + card.appendChild(warn); + } + frag.appendChild(card); }); + devicesRoot.appendChild(frag); + } + + function markDevicesStale() { + // The device panel's whole job is a trustworthy online/offline verdict, so a silently frozen + // panel (poll 500 / WAF-blocked while events still flow) is worse than an empty one: a camera + // that dies afterwards keeps showing "온라인". Flag the staleness instead of hiding it. Cleared + // automatically on the next successful poll (renderDevices resets the class + note). + if (!devicesRoot) return; + devicesRoot.classList.add('is-stale'); + if (!devicesRoot.querySelector('.device-stale-note')) { + var note = document.createElement('div'); + note.className = 'device-stale-note'; + note.textContent = '상태 갱신 지연 — 아래는 마지막으로 확인된 상태입니다'; + devicesRoot.insertBefore(note, devicesRoot.firstChild); + } + } + + function loadDevices() { + return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { window.location.href = B + '/login'; return null; } + if (!res.ok) throw new Error('devices_failed:' + res.status); + return res.json(); + }) + .then(function (data) { if (data && Array.isArray(data.devices)) renderDevices(data.devices); }) + .catch(function () { markDevicesStale(); }); // keep the last state, but say it is stale } document.addEventListener('click', unlockAudio); + // Filter bar (client-side) + "더 보기" (server-side cursor). + var sevSel = document.getElementById('severity-filter'); + if (sevSel) sevSel.addEventListener('change', function () { filters.severity = sevSel.value; renderList(); }); + if (cameraSel) cameraSel.addEventListener('change', function () { filters.camera = cameraSel.value; renderList(); }); + var unackedChk = document.getElementById('unacked-only'); + if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); }); + if (moreBtn) moreBtn.addEventListener('click', loadMore); + loadCsrfToken(); loadEvents(); - setInterval(loadEvents, POLL_INTERVAL_MS); + loadDevices(); + setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS); })(); diff --git a/server/public/index.php b/server/public/index.php index a6365a7..8bf75c8 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -45,7 +45,8 @@ use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterR use App\Ingest\IngestController; use App\Api\{ConfigController, HeartbeatController}; use App\Auth\{AuthController, CsrfController}; -use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController}; +use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController, + DeviceStatusController}; // Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so // an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off @@ -89,10 +90,11 @@ $config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate); $heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate); $auth = new AuthController($users, $audit, $rate); $csrfCtl = new CsrfController(); -$list = new EventListController($events, $sess); +$list = new EventListController($events, $snaps, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); +$deviceStatus = new DeviceStatusController($devices, $sess); $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; @@ -107,6 +109,7 @@ $r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess)); $r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); $r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); +$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); diff --git a/server/src/Api/HeartbeatController.php b/server/src/Api/HeartbeatController.php index 4e974dc..1df5577 100644 --- a/server/src/Api/HeartbeatController.php +++ b/server/src/Api/HeartbeatController.php @@ -35,9 +35,15 @@ final class HeartbeatController } $this->nonces->remember($nonce, $now + $this->cfg->nonceTtl()); + // config_version is null when the device did not report one (older edge builds). Storing a + // fake 0 made the dashboard's config-drift check (reported !== published) fire on EVERY + // device forever, since published versions start at 1. Keep it null until a real version + // arrives so drift only ever means genuine drift. + $reportedVersion = isset($b['config_version']) ? (int)$b['config_version'] : null; $this->devices->touchHeartbeat($deviceId, [ 'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0), - 'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0), + 'throttle' => (bool)($b['throttle'] ?? false), + 'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null, 'at' => $now, ]); return Response::json(['ok' => true], 200); diff --git a/server/src/Dashboard/DeviceStatusController.php b/server/src/Dashboard/DeviceStatusController.php new file mode 100644 index 0000000..b29207b --- /dev/null +++ b/server/src/Dashboard/DeviceStatusController.php @@ -0,0 +1,59 @@ +sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + + // Same scoping rule as EventListController: only a genuinely null scope is global; an + // empty-string scope is a misconfiguration that must match nothing (fail closed). + $scope = $this->sess->get('site_scope'); + $site = is_string($scope) ? $scope : null; + + $devices = array_map(function (array $d) use ($now): array { + $hb = $d['last_heartbeat_at']; + $secondsSince = $hb !== null ? max(0, $now - $hb) : null; + $online = $secondsSince !== null && $secondsSince <= self::OFFLINE_AFTER_S; + return [ + 'device_id' => $d['device_id'], + 'name' => $d['name'], + 'online' => $online, + 'seconds_since'=> $secondsSince, // null = never reported + 'fps' => $d['last_fps'], + 'temp' => $d['last_temp'], + 'throttle' => $d['last_throttle'], + // The device is running an older config than the server has published for it. + 'config_drift' => $d['reported_config_version'] !== null + && $d['reported_config_version'] !== $d['config_version'], + ]; + }, $this->devices->listStatus($site)); + + return Response::json(['devices' => $devices], 200); + } +} diff --git a/server/src/Dashboard/EventListController.php b/server/src/Dashboard/EventListController.php index 7b720bc..84d5e88 100644 --- a/server/src/Dashboard/EventListController.php +++ b/server/src/Dashboard/EventListController.php @@ -4,14 +4,14 @@ namespace App\Dashboard; use App\Http\{Request, Response}; use App\Auth\SessionStore; -use App\Repository\EventRepository; +use App\Repository\{EventRepository, SnapshotRepository}; final class EventListController { private const DEFAULT_LIMIT = 50; private const MAX_LIMIT = 200; - public function __construct(private EventRepository $events, private SessionStore $sess) {} + public function __construct(private EventRepository $events, private SnapshotRepository $snaps, private SessionStore $sess) {} public function json(Request $req): Response { @@ -28,6 +28,16 @@ final class EventListController $site = is_string($scope) ? $scope : null; $rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site); + // Attach a thumbnail uuid (newest snapshot) per event, batched in one query — the list + // shows evidence images inline instead of only on the detail page. The image itself still + // goes through /snap/{uuid}, which re-checks session + site scope, so exposing the uuid + // here grants nothing the row's owner could not already fetch. + if ($rows !== []) { + $thumbs = $this->snaps->thumbUuidsFor(array_map(static fn ($r) => (int)$r['event_id'], $rows)); + foreach ($rows as &$row) { $row['thumb_uuid'] = $thumbs[(int)$row['event_id']] ?? null; } + unset($row); + } + // Advertise a next cursor only on a full page — a short page means we hit the end. // The cursor is ":" of the last (oldest) row on this page. $next = null; diff --git a/server/src/Dashboard/SnapshotProxy.php b/server/src/Dashboard/SnapshotProxy.php index 1dea062..ae4bbc4 100644 --- a/server/src/Dashboard/SnapshotProxy.php +++ b/server/src/Dashboard/SnapshotProxy.php @@ -30,6 +30,12 @@ final class SnapshotProxy return Response::json(['error' => 'not_found'], 404); } } - return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200); + // A snapshot is immutable per uuid, so let the browser cache it. Without this the event + // list — which rebuilds every on each 5s poll — re-downloads every + // visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open + // dashboard, tens of GB/day that can trip Cafe24 shared-hosting traffic caps. `private` so + // a shared proxy never caches one operator's site's worker image for another (PIPA). + return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200, + ['Cache-Control' => 'private, max-age=86400, immutable']); } } diff --git a/server/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php index 41bb472..740ba38 100644 --- a/server/src/Dashboard/ZoneConfigController.php +++ b/server/src/Dashboard/ZoneConfigController.php @@ -75,6 +75,15 @@ final class ZoneConfigController return Response::json(['error' => 'invalid_zone'], 400); } $deviceId = (string)($b['device_id'] ?? ''); + // The zone MUST name a real device: it is delivered to the edge only via + // GET /api/config -> zones->forDevice($deviceId), so a zone saved with a blank or mistyped + // device_id is enforced by nobody while the dashboard still shows it as active (a silent + // safety hole). A global (null-scope) operator's inScope() check below passes for an unknown + // device (siteFor()->null, allows(null,null) true), so it cannot catch this — check existence + // explicitly. 400: it is a client input error, not a hidden/cross-site resource. + if ($deviceId === '' || !$this->devices->exists($deviceId)) { + return Response::json(['error' => 'unknown_device'], 400); + } // Scope BEFORE the write: a scoped operator must not create or overwrite a zone on another // site's device. For an update, the EXISTING zone's device must also be in scope, so a // caller cannot re-home a cross-site zone under one of their own device ids. diff --git a/server/src/Http/Response.php b/server/src/Http/Response.php index 455151e..bcc18c3 100644 --- a/server/src/Http/Response.php +++ b/server/src/Http/Response.php @@ -12,8 +12,9 @@ final class Response public static function text(string $s, int $status = 200): self { return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s); } - public static function bytes(string $b, string $contentType, int $status = 200): self { - return new self($status, ['Content-Type' => $contentType], $b); + public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self { + // Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden. + return new self($status, ['Content-Type' => $contentType] + $headers, $b); } public static function redirect(string $to): self { return new self(302, ['Location' => $to], ''); diff --git a/server/src/Repository/DeviceRepository.php b/server/src/Repository/DeviceRepository.php index 430b21f..39bd372 100644 --- a/server/src/Repository/DeviceRepository.php +++ b/server/src/Repository/DeviceRepository.php @@ -5,8 +5,19 @@ namespace App\Repository; interface DeviceRepository { public function secretFor(string $deviceId): ?string; + /** True if a device row with this id is provisioned. Distinct from siteFor(), which returns + * null for BOTH an unknown device and a known-but-unassigned one — a zone save needs to tell + * those apart so it never persists a zone against a device_id no device will ever fetch. */ + public function exists(string $deviceId): bool; /** The device's access-control site (devices.site), or null if unassigned/unknown. */ public function siteFor(string $deviceId): ?string; public function touchHeartbeat(string $deviceId, array $meta): void; public function configVersion(string $deviceId): int; + + /** All devices with their health telemetry, for the dashboard's device-status panel. + * Site-scoped: a non-null $site returns only that site's devices (a scoped operator); + * null returns every device (a global operator). Each row: + * [device_id, name, site, last_fps, last_temp, last_throttle, config_version, + * reported_config_version, last_heartbeat_at]. Read-only. */ + public function listStatus(?string $site): array; } diff --git a/server/src/Repository/Pdo/PdoDeviceRepository.php b/server/src/Repository/Pdo/PdoDeviceRepository.php index 720802d..fca4fc6 100644 --- a/server/src/Repository/Pdo/PdoDeviceRepository.php +++ b/server/src/Repository/Pdo/PdoDeviceRepository.php @@ -17,6 +17,13 @@ final class PdoDeviceRepository implements DeviceRepository return $secret === false ? null : (string)$secret; } + public function exists(string $deviceId): bool + { + $st = $this->pdo->prepare('SELECT 1 FROM move_devices WHERE device_id = ? LIMIT 1'); + $st->execute([$deviceId]); + return $st->fetchColumn() !== false; + } + public function siteFor(string $deviceId): ?string { $st = $this->pdo->prepare('SELECT site FROM move_devices WHERE device_id = ? LIMIT 1'); @@ -39,11 +46,14 @@ final class PdoDeviceRepository implements DeviceRepository reported_config_version = ?, last_heartbeat_at = ? WHERE device_id = ?' ); + // config_version stays NULL (not 0) until the device actually reports one, so the + // dashboard's config-drift check does not fire on every device (see HeartbeatController). + $reportedVersion = $meta['config_version'] ?? null; $st->execute([ (float)($meta['fps'] ?? 0.0), (float)($meta['temp'] ?? 0.0), !empty($meta['throttle']) ? 1 : 0, - (int)($meta['config_version'] ?? 0), + $reportedVersion !== null ? (int)$reportedVersion : null, (int)($meta['at'] ?? time()), $deviceId, ]); @@ -56,4 +66,31 @@ final class PdoDeviceRepository implements DeviceRepository $version = $st->fetchColumn(); return $version === false ? 1 : (int)$version; } + + public function listStatus(?string $site): array + { + // Site-scope access control, same rule as the event list: a non-null scope filters to + // that site; a global operator (null) sees every device. Pushed into the WHERE. + $sql = 'SELECT device_id, name, site, last_fps, last_temp, last_throttle, + config_version, reported_config_version, last_heartbeat_at + FROM move_devices'; + $binds = []; + if ($site !== null) { $sql .= ' WHERE site = ?'; $binds[] = $site; } + $sql .= ' ORDER BY device_id'; + $st = $this->pdo->prepare($sql); + $st->execute($binds); + return array_map(static function (array $r): array { + return [ + 'device_id' => (string)$r['device_id'], + 'name' => $r['name'] !== null ? (string)$r['name'] : null, + 'site' => $r['site'] !== null ? (string)$r['site'] : null, + 'last_fps' => $r['last_fps'] !== null ? (float)$r['last_fps'] : null, + 'last_temp' => $r['last_temp'] !== null ? (float)$r['last_temp'] : null, + 'last_throttle' => $r['last_throttle'] !== null ? (bool)(int)$r['last_throttle'] : null, + 'config_version' => (int)$r['config_version'], + 'reported_config_version' => $r['reported_config_version'] !== null ? (int)$r['reported_config_version'] : null, + 'last_heartbeat_at' => $r['last_heartbeat_at'] !== null ? (int)$r['last_heartbeat_at'] : null, + ]; + }, $st->fetchAll()); + } } diff --git a/server/src/Repository/Pdo/PdoSnapshotRepository.php b/server/src/Repository/Pdo/PdoSnapshotRepository.php index 75e9627..4f93c5a 100644 --- a/server/src/Repository/Pdo/PdoSnapshotRepository.php +++ b/server/src/Repository/Pdo/PdoSnapshotRepository.php @@ -28,6 +28,31 @@ final class PdoSnapshotRepository implements SnapshotRepository return $st->fetchAll(); } + public function thumbUuidsFor(array $eventIds): array + { + // Sanitize to positive ints and build the IN list from their COUNT — never interpolate + // caller values into SQL. Empty -> no query. + $ids = array_values(array_filter(array_map('intval', $eventIds), static fn (int $i): bool => $i > 0)); + if ($ids === []) return []; + $in = implode(',', array_fill(0, count($ids), '?')); + // Newest snapshot per event: the grouped subquery picks MAX(snapshot_id) per event (the + // server's own insert sequence, not the device clock), then joins back to read that row's + // uuid. One round-trip, rides ix_event_snapshots_event — no N+1 across the page. + $sql = "SELECT s.event_id, s.file_uuid + FROM move_event_snapshots s + JOIN (SELECT event_id, MAX(snapshot_id) AS mx + FROM move_event_snapshots + WHERE event_id IN ($in) + GROUP BY event_id) m + ON m.event_id = s.event_id AND m.mx = s.snapshot_id"; + $st = $this->pdo->prepare($sql); + foreach ($ids as $i => $v) $st->bindValue($i + 1, $v, PDO::PARAM_INT); + $st->execute(); + $out = []; + foreach ($st->fetchAll() as $r) $out[(int)$r['event_id']] = (string)$r['file_uuid']; + return $out; + } + public function latestForCamera(string $cameraId): ?array { // Ordered by snapshot_id (insert order), NOT events.occurred_at: occurred_at is the diff --git a/server/src/Repository/SnapshotRepository.php b/server/src/Repository/SnapshotRepository.php index c6c1d7c..dd57e7b 100644 --- a/server/src/Repository/SnapshotRepository.php +++ b/server/src/Repository/SnapshotRepository.php @@ -11,6 +11,11 @@ interface SnapshotRepository * snapshot's site (via the owning event) for access control. */ public function eventIdByUuid(string $uuid): ?int; + /** Newest snapshot file_uuid for each of the given event ids, as [event_id => file_uuid]. + * Batched (one query) so the event list can show a thumbnail per row without an N+1. + * Events with no snapshot are simply absent from the map. */ + public function thumbUuidsFor(array $eventIds): array; + /** Most recent snapshot for a camera, as ['file_uuid' => ..., 'device_id' => ...], or null. * Backs the zone editor's "load the latest frame" button: an operator draws hazard zones * over what the camera actually sees instead of guessing coordinates on a blank canvas. diff --git a/server/src/View/events.php b/server/src/View/events.php index 1c5654a..a5873dd 100644 --- a/server/src/View/events.php +++ b/server/src/View/events.php @@ -2,8 +2,30 @@ require __DIR__ . '/layout.php'; render_header('이벤트'); ?> -

이벤트

+
+

이벤트

+ 실시간 감시 중 +
+

감지 현황 · 5초마다 갱신

+
+ +
+
+ +
+ + + +
+
불러오는 중...
+
no tab lit. + $navEvents = ($title === '이벤트') ? ' is-active' : ''; + $navZones = ($title === '구역 설정') ? ' is-active' : ''; ?> @@ -34,17 +38,24 @@ if (!function_exists('render_header')) { <?= view_e($title) ?> · 이동 감지 대시보드 - +
+
+ + 이동 감지 관제 +
- + 로그인 @@ -60,6 +71,19 @@ if (!function_exists('render_footer')) { function render_footer(): void { ?> + post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000); $this->assertSame(200, $res->status()); $this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']); + $this->assertSame(7, $dev->heartbeats['pi-1']['config_version']); + } + public function testHeartbeatStoresNullConfigVersionWhenNotReported(): void { + // An older edge build sends no config_version. The server must store NULL, not a fake 0 — + // storing 0 made the dashboard's config-drift check fire on every device forever (0 != 1), + // training operators to ignore the device-warning line. + $cfg = Config::fromArray([]); + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $c = new HeartbeatController($cfg, $dev, new InMemoryNonceRepository()); + $body = ['device_id' => 'pi-1', 'fps' => 12.0]; // no config_version reported + $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000); + $this->assertSame(200, $res->status()); + $this->assertNull($dev->heartbeats['pi-1']['config_version']); } public function testHeartbeatRejectsReplayedNonce(): void { $cfg = Config::fromArray([]); diff --git a/server/tests/DashboardTest.php b/server/tests/DashboardTest.php index 937b131..cb66f09 100644 --- a/server/tests/DashboardTest.php +++ b/server/tests/DashboardTest.php @@ -3,8 +3,9 @@ declare(strict_types=1); namespace App\Tests; use App\Http\Request; use App\Auth\ArraySessionStore; -use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy}; -use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository}; +use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, DeviceStatusController}; +use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository, + InMemoryDeviceRepository}; use PHPUnit\Framework\TestCase; final class DashboardTest extends TestCase @@ -14,13 +15,13 @@ final class DashboardTest extends TestCase } public function testListRequiresAuth(): void { $repo = new InMemoryEventRepository(); - $c = new EventListController($repo, new ArraySessionStore()); + $c = new EventListController($repo, new InMemorySnapshotRepository(), new ArraySessionStore()); $this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status()); } public function testListReturnsRecent(): void { $repo = new InMemoryEventRepository(); $repo->insert(['severity' => 'high', 'event_type' => 'fall']); - $c = new EventListController($repo, $this->authed()); + $c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed()); $res = $c->json(new Request('GET', '/api/events', [], '', [], [])); $this->assertSame(200, $res->status()); $this->assertCount(1, json_decode($res->body(), true)['events']); @@ -30,7 +31,7 @@ final class DashboardTest extends TestCase $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 100.0]); // id 1 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 200.0]); // id 2 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3 - $c = new EventListController($repo, $this->authed()); + $c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed()); // Page 1: the newest two (300, 200); a full page hands back a cursor at the oldest row. $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true); $this->assertCount(2, $p1['events']); @@ -49,7 +50,7 @@ final class DashboardTest extends TestCase $repo = new InMemoryEventRepository(); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123456]); // id 1 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2 - $c = new EventListController($repo, $this->authed()); + $c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed()); $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true); $this->assertSame(2, $p1['events'][0]['event_id']); // newest first $p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1', 'cursor' => $p1['next_cursor']], []))->body(), true); @@ -98,12 +99,12 @@ final class DashboardTest extends TestCase $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1.0, 'site' => 'factory-a']); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']); // A factory-a operator sees only the factory-a event. - $scoped = new EventListController($repo, $this->authedSite('factory-a')); + $scoped = new EventListController($repo, new InMemorySnapshotRepository(), $this->authedSite('factory-a')); $rows = json_decode($scoped->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events']; $this->assertCount(1, $rows); $this->assertSame('factory-a', $rows[0]['site']); // A global operator (no site_scope) sees both. - $global = new EventListController($repo, $this->authed('operator')); + $global = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed('operator')); $all = json_decode($global->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events']; $this->assertCount(2, $all); } @@ -128,6 +129,7 @@ final class DashboardTest extends TestCase public function __construct(private string $file, private int $eid, private string $uuid) {} public function add(int $e, string $u, string $h): void {} public function forEvent(int $e): array { return []; } + public function thumbUuidsFor(array $ids): array { return []; } public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; } public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; } public function latestForCamera(string $cameraId): ?array { return null; } @@ -136,7 +138,73 @@ final class DashboardTest extends TestCase $cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a')); $this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status()); $same = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-b')); - $this->assertSame(200, $same->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status()); + $served = $same->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid]); + $this->assertSame(200, $served->status()); + // A snapshot is immutable per uuid, so it is browser-cacheable — without this the event + // list re-downloads every visible JPEG on every 5s poll (a Cafe24 traffic-cap hazard). + // `private` keeps a shared proxy from serving one site's worker image to another (PIPA). + $cache = $served->headers()['Cache-Control'] ?? ''; + $this->assertStringContainsString('private', $cache); + $this->assertStringContainsString('max-age=', $cache); @unlink($file); } + + public function testEventListAttachesNewestSnapshotThumbnail(): void { + $repo = new InMemoryEventRepository(); + $withSnap = $repo->insert(['severity' => 'critical', 'event_type' => 'fall', 'occurred_at' => 2.0]); + $noSnap = $repo->insert(['severity' => 'info', 'event_type' => 'ghost', 'occurred_at' => 1.0]); + $snaps = new InMemorySnapshotRepository(); + $snaps->add($withSnap, 'uuidA', 'hashA'); + $snaps->add($withSnap, 'uuidB', 'hashB'); // newer -> this one wins + $c = new EventListController($repo, $snaps, $this->authed()); + $rows = json_decode($c->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events']; + $byId = []; + foreach ($rows as $r) $byId[$r['event_id']] = $r; + $this->assertSame('uuidB', $byId[$withSnap]['thumb_uuid']); // newest snapshot per event + $this->assertNull($byId[$noSnap]['thumb_uuid']); // event with no snapshot + } + + public function testDeviceStatusRequiresAuth(): void { + $c = new DeviceStatusController(new InMemoryDeviceRepository(), new ArraySessionStore()); + $this->assertSame(401, $c->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->status()); + } + + public function testDeviceStatusComputesLivenessAndDrift(): void { + $now = 10000; + $dev = new InMemoryDeviceRepository([], [], [], [ + ['device_id' => 'pi-online', 'name' => 'A동', 'site' => null, 'last_heartbeat_at' => $now - 30, + 'last_fps' => 12.0, 'last_temp' => 55.0, 'last_throttle' => false, 'config_version' => 3, 'reported_config_version' => 3], + ['device_id' => 'pi-offline', 'name' => 'B동', 'site' => null, 'last_heartbeat_at' => $now - 600, + 'last_fps' => 0.0, 'last_temp' => 0.0, 'last_throttle' => true, 'config_version' => 1, 'reported_config_version' => 1], + ['device_id' => 'pi-new', 'name' => null, 'site' => null, 'last_heartbeat_at' => null, + 'last_fps' => null, 'last_temp' => null, 'last_throttle' => null, 'config_version' => 1, 'reported_config_version' => null], + ['device_id' => 'pi-drift', 'name' => 'C동', 'site' => null, 'last_heartbeat_at' => $now - 10, + 'last_fps' => 10.0, 'last_temp' => 50.0, 'last_throttle' => false, 'config_version' => 5, 'reported_config_version' => 4], + ]); + $out = json_decode((new DeviceStatusController($dev, $this->authed())) + ->json(new Request('GET', '/api/devices', [], '', [], []), $now)->body(), true)['devices']; + $by = []; + foreach ($out as $d) $by[$d['device_id']] = $d; + $this->assertTrue($by['pi-online']['online']); + $this->assertSame(30, $by['pi-online']['seconds_since']); + $this->assertFalse($by['pi-offline']['online']); // 600s past the 120s threshold + $this->assertFalse($by['pi-new']['online']); // never reported + $this->assertNull($by['pi-new']['seconds_since']); + $this->assertFalse($by['pi-online']['config_drift']); + $this->assertTrue($by['pi-drift']['config_drift']); // device on v4, server published v5 + } + + public function testDeviceStatusIsScopedToSite(): void { + $row = fn (string $id, string $site) => ['device_id' => $id, 'name' => null, 'site' => $site, + 'last_heartbeat_at' => null, 'last_fps' => null, 'last_temp' => null, 'last_throttle' => null, + 'config_version' => 1, 'reported_config_version' => null]; + $dev = new InMemoryDeviceRepository([], [], [], [$row('a1', 'factory-a'), $row('b1', 'factory-b')]); + $scoped = json_decode((new DeviceStatusController($dev, $this->authedSite('factory-a'))) + ->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->body(), true)['devices']; + $this->assertCount(1, $scoped); + $this->assertSame('a1', $scoped[0]['device_id']); + $all = json_decode((new DeviceStatusController($dev, $this->authed('operator'))) + ->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->body(), true)['devices']; + $this->assertCount(2, $all); + } } diff --git a/server/tests/IngestControllerTest.php b/server/tests/IngestControllerTest.php index aac6582..a2bf422 100644 --- a/server/tests/IngestControllerTest.php +++ b/server/tests/IngestControllerTest.php @@ -109,6 +109,7 @@ final class IngestControllerTest extends TestCase $throwingSnaps = new class implements \App\Repository\SnapshotRepository { public function add(int $e, string $u, string $h): void { throw new \RuntimeException('db down'); } public function forEvent(int $e): array { return []; } + public function thumbUuidsFor(array $ids): array { return []; } public function pathByUuid(string $u): ?string { return null; } public function eventIdByUuid(string $u): ?int { return null; } public function latestForCamera(string $cameraId): ?array { return null; } diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index 267715d..eb70c5f 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -7,12 +7,23 @@ use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, final class InMemoryDeviceRepository implements DeviceRepository { public array $heartbeats = []; + // $statusRows: full device rows for listStatus() (the status panel tests populate it). Kept a + // trailing optional arg so the many existing 3-arg call sites are unaffected. public function __construct(private array $secrets = [], private array $versions = [], - private array $sites = []) {} + private array $sites = [], public array $statusRows = []) {} public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; } + public function exists(string $d): bool { + // A device is "known" if it appears in any provisioned map the test set up. + return isset($this->secrets[$d]) || isset($this->sites[$d]) || isset($this->versions[$d]); + } public function siteFor(string $d): ?string { return $this->sites[$d] ?? null; } public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; } public function configVersion(string $d): int { return $this->versions[$d] ?? 1; } + public function listStatus(?string $site): array { + $rows = $this->statusRows; + if ($site !== null) $rows = array_filter($rows, fn($d) => ($d['site'] ?? null) === $site); + return array_values($rows); + } } final class InMemoryNonceRepository implements NonceRepository { public array $store = []; @@ -60,6 +71,14 @@ final class InMemorySnapshotRepository implements SnapshotRepository { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); } public function forEvent(int $e): array { return $this->rows[$e] ?? []; } + public function thumbUuidsFor(array $eventIds): array { + $out = []; + foreach ($eventIds as $e) { + $list = $this->rows[(int)$e] ?? []; + if ($list !== []) $out[(int)$e] = end($list)['file_uuid']; // newest = last appended + } + return $out; + } public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; } public function eventIdByUuid(string $u): ?int { foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e; diff --git a/server/tests/ZoneConfigTest.php b/server/tests/ZoneConfigTest.php index 015652d..c54cb8d 100644 --- a/server/tests/ZoneConfigTest.php +++ b/server/tests/ZoneConfigTest.php @@ -15,9 +15,12 @@ final class ZoneConfigTest extends TestCase if ($site !== null) $s->set('site_scope', $site); return $s; } - /** Device repo mapping the fixture devices to their sites (empty = every device unscoped). */ + /** Device repo mapping the fixture devices to their sites (empty = every device unscoped). + * Every fixture device is registered (via secrets) so exists() is true — a zone save now + * requires a real device, so the fixtures must model provisioned devices. */ private function devices(array $sites = []): InMemoryDeviceRepository { - return new InMemoryDeviceRepository([], [], $sites); + $ids = array_unique(array_merge(['pi-1', 'pi-2'], array_keys($sites))); + return new InMemoryDeviceRepository(array_fill_keys($ids, 's'), [], $sites); } public function testPolygonValidation(): void { $this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]])); @@ -70,6 +73,21 @@ final class ZoneConfigTest extends TestCase $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); } + public function testSaveRejectsBlankOrUnknownDevice(): void { + // A zone is delivered to the edge only via /api/config -> forDevice($deviceId); a zone saved + // with a blank or mistyped device_id is enforced by nobody, so the save must be refused + // (400) rather than confirmed to the operator and rendered as if it were active. + $repo = new InMemoryZoneRepository(); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); + $poly = [[0,0],[9,0],[9,9]]; + // 'ghost-pi' is not among the provisioned devices (pi-1/pi-2). + $unknown = $c->save($this->saveReq(['device_id' => 'ghost-pi', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => $poly])); + $this->assertSame(400, $unknown->status()); + // Blank device_id (the operator left the field empty) is rejected too. + $blank = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => $poly])); + $this->assertSame(400, $blank->status()); + $this->assertCount(0, $repo->forCamera('cam-1')); // nothing was persisted + } public function testSaveRejectsBadLevelOrPolygon(): void { $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());