feat(dashboard): device-status panel + event-list rework, hardened
- Device-status panel (DeviceStatusController): site-scoped liveness over stored heartbeat telemetry. Fix config_drift false-positive by storing NULL until the device actually reports a version (was firing "설정 미적용" on every device). - Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone is never persisted against a device that will never fetch it. - Snapshot proxy sends immutable private cache headers, ending the 5s-poll re-download of every thumbnail (a Cafe24 traffic-cap hazard). - Event list: paged-in acknowledgements stick; honesty affordances for the paging gap, a stale device poll, and a filtered camera aging out; logout wired for every page via the shared layout.
This commit is contained in:
parent
752afc3f00
commit
bf5255e9eb
20 changed files with 951 additions and 134 deletions
|
|
@ -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 {
|
:root {
|
||||||
color-scheme: light;
|
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 {
|
body {
|
||||||
margin: 0;
|
margin:0;
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
font-family:var(--font);
|
||||||
color: var(--text);
|
color:var(--ink);
|
||||||
background: #f9fafb;
|
background:var(--bg);
|
||||||
|
line-height:1.45;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Header ------------------------------------------------------ */
|
||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display:flex; align-items:center; gap:1rem;
|
||||||
justify-content: space-between;
|
padding:.85rem 1.6rem;
|
||||||
align-items: center;
|
background:var(--card); border-bottom:1px solid var(--line);
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: #111827;
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
.app-nav a { color: #fff; margin-right: 1rem; text-decoration: none; }
|
.app-brand { display:flex; align-items:center; gap:.55rem; font-weight:700; font-size:.98rem; letter-spacing:-.01em; }
|
||||||
.app-nav a:hover, .app-nav a:focus { text-decoration: underline; }
|
/* Live-monitoring dot — the board is watching. Calm green, not an alarm. */
|
||||||
.app-session { display: flex; align-items: center; gap: 0.5rem; }
|
.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; }
|
||||||
.app-session a { color: #fff; }
|
.brand-name { color:var(--ink); }
|
||||||
.app-main { padding: 1rem; max-width: 960px; margin: 0 auto; }
|
.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; }
|
.app-nav { display:flex; gap:.2rem; margin-left:1.8rem; margin-right:auto; }
|
||||||
.zone-controls { flex-direction: row; align-items: end; flex-wrap: wrap; max-width: none; }
|
.app-nav a {
|
||||||
.zone-controls label { display: flex; flex-direction: column; font-size: 0.85rem; }
|
position:relative;
|
||||||
.form-error { color: #991b1b; }
|
color:var(--ink-2); text-decoration:none;
|
||||||
|
font-size:.87rem; font-weight:500;
|
||||||
#zone-canvas {
|
padding:.45rem .75rem; border-radius:8px;
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: #111827 center / contain no-repeat;
|
|
||||||
cursor: crosshair;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
.app-nav a:hover, .app-nav a:focus-visible { background:#f1f2f5; color:var(--ink); text-decoration:none; }
|
||||||
button {
|
.app-nav a.is-active { color:var(--accent); }
|
||||||
cursor: pointer;
|
.app-nav a.is-active::after {
|
||||||
padding: 0.4rem 0.8rem;
|
content:""; position:absolute; left:.75rem; right:.75rem; bottom:-.86rem; height:2px;
|
||||||
border: 1px solid var(--border);
|
background:var(--accent); border-radius:2px;
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
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 {
|
.event-row {
|
||||||
display: flex;
|
display:flex; align-items:center; gap:1rem;
|
||||||
align-items: center;
|
background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||||
gap: 0.75rem;
|
padding:.95rem 1.15rem; margin-bottom:.6rem; box-shadow:var(--shadow);
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
.event-row.is-unacknowledged { border-left: 4px solid #4b5563; }
|
/* An unacknowledged critical is the only row that takes color; the tint + red badge +
|
||||||
.event-meta { flex: 1; }
|
"확인 필요" chip make it unmissable without shouting. */
|
||||||
.event-meta .event-type { font-weight: 600; }
|
.event-row.is-unacknowledged.sev-row-critical { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||||
.event-meta .event-time { color: #4b5563; font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* Severity badges (NF9): color-blind-safe — icon + text label + color.
|
/* Evidence thumbnail (newest snapshot) — a small camera still on the left of the row. */
|
||||||
Never rely on color alone. Pastel background + dark text pairs are
|
.event-thumb { width:64px; height:44px; object-fit:cover; border-radius:8px; border:1px solid var(--line); background:#0f1216; flex:none; }
|
||||||
chosen for WCAG-AA contrast (>= 4.5:1 for normal-size text). */
|
|
||||||
.severity-badge {
|
.severity-badge { display:inline-flex; align-items:center; gap:.45rem; flex:none; min-width:66px; font-size:.84rem; font-weight:600; white-space:nowrap; }
|
||||||
display: inline-flex;
|
/* app.js/detail.js build the badge as <span.icon>(emoji)</span><span.label>text</span>.
|
||||||
align-items: center;
|
In this theme the icon becomes a colored dot (emoji hidden) and the label carries identity. */
|
||||||
gap: 0.35rem;
|
.severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; }
|
||||||
padding: 0.15rem 0.5rem;
|
.sev-critical { color:var(--crit); }
|
||||||
border-radius: 999px;
|
.sev-high { color:var(--high); }
|
||||||
font-weight: 700;
|
.sev-warning { color:var(--warn); }
|
||||||
font-size: 0.85rem;
|
.sev-info { color:var(--info); }
|
||||||
white-space: nowrap;
|
|
||||||
|
.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; }
|
/* ---- Zone editor ------------------------------------------------- */
|
||||||
.sev-high { background: #ffedd5; color: #9a3412; border: 1px solid #9a3412; }
|
.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; }
|
||||||
.sev-warning { background: #fef9c3; color: #854d0e; border: 1px solid #854d0e; }
|
.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; }
|
||||||
.sev-info { background: #e5e7eb; color: #374151; border: 1px solid #374151; }
|
#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; }
|
/* ---- Incident detail -------------------------------------------- */
|
||||||
.ack-btn:disabled { opacity: 0.6; cursor: default; }
|
.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). */
|
/* ---- Responsive -------------------------------------------------- */
|
||||||
.event-detail-link { color: inherit; text-decoration: none; }
|
@media (max-width:720px) {
|
||||||
.event-detail-link:hover,
|
.status-strip { grid-template-columns:repeat(2,1fr); }
|
||||||
.event-detail-link:focus-visible { text-decoration: underline; }
|
.app-nav { margin-left:0; }
|
||||||
|
.app-main { padding:1.5rem 1.1rem 3rem; }
|
||||||
/* Incident detail view (event_detail.php + detail.js). */
|
}
|
||||||
.detail-row { margin: 0.25rem 0; }
|
@media (max-width:420px) {
|
||||||
.detail-row .detail-label { color: #4b5563; font-weight: 600; }
|
.status-strip { grid-template-columns:1fr 1fr; }
|
||||||
#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; }
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@
|
||||||
var root = document.getElementById('events-root');
|
var root = document.getElementById('events-root');
|
||||||
if (!root) return;
|
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;
|
var csrfToken = null;
|
||||||
function loadCsrfToken() {
|
function loadCsrfToken() {
|
||||||
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
||||||
|
|
@ -112,8 +121,26 @@
|
||||||
function buildRow(ev) {
|
function buildRow(ev) {
|
||||||
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
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');
|
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 <img> 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 <img>
|
||||||
|
// 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));
|
row.appendChild(buildBadge(ev.severity));
|
||||||
|
|
||||||
|
|
@ -127,6 +154,14 @@
|
||||||
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
|
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
|
||||||
typeLink.textContent = String(ev.event_type || '');
|
typeLink.textContent = String(ev.event_type || '');
|
||||||
type.appendChild(typeLink);
|
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);
|
meta.appendChild(type);
|
||||||
|
|
||||||
var sub = document.createElement('div');
|
var sub = document.createElement('div');
|
||||||
|
|
@ -161,6 +196,11 @@
|
||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) throw new Error('ack_failed:' + res.status);
|
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();
|
return loadEvents();
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
|
|
@ -168,59 +208,350 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(events) {
|
function patchOlderAck(eventId) {
|
||||||
var sorted = events.slice().sort(function (a, b) {
|
for (var i = 0; i < olderEvents.length; i++) {
|
||||||
var aAcked = a.acknowledged_by !== null && a.acknowledged_by !== undefined && a.acknowledged_by !== '';
|
var ev = olderEvents[i];
|
||||||
var bAcked = b.acknowledged_by !== null && b.acknowledged_by !== undefined && b.acknowledged_by !== '';
|
if (ev && ev.event_id === eventId && !isAcked(ev)) {
|
||||||
if (aAcked === bAcked) return 0;
|
// New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
|
||||||
return aAcked ? 1 : -1; // unacknowledged first
|
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.textContent = '';
|
||||||
root.removeAttribute('aria-busy');
|
root.removeAttribute('aria-busy');
|
||||||
if (sorted.length === 0) {
|
if (shown.length === 0) {
|
||||||
root.textContent = '표시할 이벤트가 없습니다.';
|
var anyFilter = filters.severity || filters.camera || filters.unackedOnly;
|
||||||
return;
|
root.textContent = anyFilter ? '조건에 맞는 이벤트가 없습니다.' : '표시할 이벤트가 없습니다.';
|
||||||
}
|
} else {
|
||||||
var frag = document.createDocumentFragment();
|
var frag = document.createDocumentFragment();
|
||||||
sorted.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
|
shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
|
||||||
root.appendChild(frag);
|
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() {
|
function loadEvents() {
|
||||||
// Request the full server page cap (MAX_LIMIT=200), not the default 50: the audible alarm
|
// Newest window (server MAX_LIMIT=200). The audible alarm and the "unacknowledged first"
|
||||||
// and the "unacknowledged first" pinning only consider the rows fetched here. During an event
|
// pinning only consider this set, so it stays the full cap — during an event burst a smaller
|
||||||
// burst, a default page of 50 could push an unacknowledged critical off the fetched set, and
|
// page could push an unacknowledged critical off it and updateAlarm() would clear the alarm
|
||||||
// updateAlarm() would then clear the alarm while a critical fall is still unacknowledged.
|
// while a fall is still unacknowledged.
|
||||||
return fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
|
return fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
window.location.href = B + '/login';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error('list_failed:' + res.status);
|
if (!res.ok) throw new Error('list_failed:' + res.status);
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.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 () {
|
.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');
|
function loadMore() {
|
||||||
if (logoutBtn) {
|
if (moreCursor === null) return;
|
||||||
logoutBtn.addEventListener('click', function () {
|
if (moreBtn) { moreBtn.disabled = true; moreBtn.textContent = '불러오는 중...'; }
|
||||||
fetch(B + '/logout', { method: 'POST' }).then(function () { window.location.href = B + '/login'; });
|
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);
|
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();
|
loadCsrfToken();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
setInterval(loadEvents, POLL_INTERVAL_MS);
|
loadDevices();
|
||||||
|
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterR
|
||||||
use App\Ingest\IngestController;
|
use App\Ingest\IngestController;
|
||||||
use App\Api\{ConfigController, HeartbeatController};
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
use App\Auth\{AuthController, CsrfController};
|
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
|
// 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
|
// 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);
|
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
|
||||||
$auth = new AuthController($users, $audit, $rate);
|
$auth = new AuthController($users, $audit, $rate);
|
||||||
$csrfCtl = new CsrfController();
|
$csrfCtl = new CsrfController();
|
||||||
$list = new EventListController($events, $sess);
|
$list = new EventListController($events, $snaps, $sess);
|
||||||
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
||||||
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||||
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
||||||
|
$deviceStatus = new DeviceStatusController($devices, $sess);
|
||||||
|
|
||||||
$render = function (string $view): Response {
|
$render = function (string $view): Response {
|
||||||
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
|
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', '/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('POST', '/logout', fn(Request $q) => $auth->logout($sess));
|
||||||
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
|
$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('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('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));
|
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,15 @@ final class HeartbeatController
|
||||||
}
|
}
|
||||||
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
|
$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, [
|
$this->devices->touchHeartbeat($deviceId, [
|
||||||
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
|
'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,
|
'at' => $now,
|
||||||
]);
|
]);
|
||||||
return Response::json(['ok' => true], 200);
|
return Response::json(['ok' => true], 200);
|
||||||
|
|
|
||||||
59
server/src/Dashboard/DeviceStatusController.php
Normal file
59
server/src/Dashboard/DeviceStatusController.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Http\{Request, Response};
|
||||||
|
use App\Auth\SessionStore;
|
||||||
|
use App\Repository\DeviceRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
|
||||||
|
* reports fps/temp/throttle/config_version via POST /api/heartbeat (touchHeartbeat); this only
|
||||||
|
* surfaces what is already stored, so it adds no new data pipeline. Session-guarded and
|
||||||
|
* site-scoped exactly like the event list: a scoped operator sees only their site's devices.
|
||||||
|
*
|
||||||
|
* The one derived field is liveness: a device is "online" if it sent a heartbeat within
|
||||||
|
* OFFLINE_AFTER_S. That is computed server-side against the request clock so a client with a
|
||||||
|
* wrong local time cannot mislabel a live camera as down (or a dead one as up) — a dead camera
|
||||||
|
* is a safety blind spot, so this verdict has to be trustworthy.
|
||||||
|
*/
|
||||||
|
final class DeviceStatusController
|
||||||
|
{
|
||||||
|
// ~2 minutes without a heartbeat = offline. The edge beats on a fixed 30s WALL-CLOCK cadence
|
||||||
|
// (daemon.run_loop heartbeat_interval_s), not an iteration count, so this is a 4x margin: three
|
||||||
|
// beats can be dropped (server hiccup, a failed POST) before a healthy device flaps offline,
|
||||||
|
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
|
||||||
|
private const OFFLINE_AFTER_S = 120;
|
||||||
|
|
||||||
|
public function __construct(private DeviceRepository $devices, private SessionStore $sess) {}
|
||||||
|
|
||||||
|
public function json(Request $req, int $now): Response
|
||||||
|
{
|
||||||
|
if ($this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,14 +4,14 @@ namespace App\Dashboard;
|
||||||
|
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\SessionStore;
|
use App\Auth\SessionStore;
|
||||||
use App\Repository\EventRepository;
|
use App\Repository\{EventRepository, SnapshotRepository};
|
||||||
|
|
||||||
final class EventListController
|
final class EventListController
|
||||||
{
|
{
|
||||||
private const DEFAULT_LIMIT = 50;
|
private const DEFAULT_LIMIT = 50;
|
||||||
private const MAX_LIMIT = 200;
|
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
|
public function json(Request $req): Response
|
||||||
{
|
{
|
||||||
|
|
@ -28,6 +28,16 @@ final class EventListController
|
||||||
$site = is_string($scope) ? $scope : null;
|
$site = is_string($scope) ? $scope : null;
|
||||||
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site);
|
$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.
|
// Advertise a next cursor only on a full page — a short page means we hit the end.
|
||||||
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
|
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
|
||||||
$next = null;
|
$next = null;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ final class SnapshotProxy
|
||||||
return Response::json(['error' => 'not_found'], 404);
|
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 <img src=/snap/{uuid}> 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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,15 @@ final class ZoneConfigController
|
||||||
return Response::json(['error' => 'invalid_zone'], 400);
|
return Response::json(['error' => 'invalid_zone'], 400);
|
||||||
}
|
}
|
||||||
$deviceId = (string)($b['device_id'] ?? '');
|
$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
|
// 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
|
// 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.
|
// caller cannot re-home a cross-site zone under one of their own device ids.
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ final class Response
|
||||||
public static function text(string $s, int $status = 200): self {
|
public static function text(string $s, int $status = 200): self {
|
||||||
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
|
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
|
||||||
}
|
}
|
||||||
public static function bytes(string $b, string $contentType, int $status = 200): self {
|
public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self {
|
||||||
return new self($status, ['Content-Type' => $contentType], $b);
|
// 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 {
|
public static function redirect(string $to): self {
|
||||||
return new self(302, ['Location' => $to], '');
|
return new self(302, ['Location' => $to], '');
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,19 @@ namespace App\Repository;
|
||||||
interface DeviceRepository
|
interface DeviceRepository
|
||||||
{
|
{
|
||||||
public function secretFor(string $deviceId): ?string;
|
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. */
|
/** The device's access-control site (devices.site), or null if unassigned/unknown. */
|
||||||
public function siteFor(string $deviceId): ?string;
|
public function siteFor(string $deviceId): ?string;
|
||||||
public function touchHeartbeat(string $deviceId, array $meta): void;
|
public function touchHeartbeat(string $deviceId, array $meta): void;
|
||||||
public function configVersion(string $deviceId): int;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ final class PdoDeviceRepository implements DeviceRepository
|
||||||
return $secret === false ? null : (string)$secret;
|
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
|
public function siteFor(string $deviceId): ?string
|
||||||
{
|
{
|
||||||
$st = $this->pdo->prepare('SELECT site FROM move_devices WHERE device_id = ? LIMIT 1');
|
$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 = ?
|
reported_config_version = ?, last_heartbeat_at = ?
|
||||||
WHERE device_id = ?'
|
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([
|
$st->execute([
|
||||||
(float)($meta['fps'] ?? 0.0),
|
(float)($meta['fps'] ?? 0.0),
|
||||||
(float)($meta['temp'] ?? 0.0),
|
(float)($meta['temp'] ?? 0.0),
|
||||||
!empty($meta['throttle']) ? 1 : 0,
|
!empty($meta['throttle']) ? 1 : 0,
|
||||||
(int)($meta['config_version'] ?? 0),
|
$reportedVersion !== null ? (int)$reportedVersion : null,
|
||||||
(int)($meta['at'] ?? time()),
|
(int)($meta['at'] ?? time()),
|
||||||
$deviceId,
|
$deviceId,
|
||||||
]);
|
]);
|
||||||
|
|
@ -56,4 +66,31 @@ final class PdoDeviceRepository implements DeviceRepository
|
||||||
$version = $st->fetchColumn();
|
$version = $st->fetchColumn();
|
||||||
return $version === false ? 1 : (int)$version;
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,31 @@ final class PdoSnapshotRepository implements SnapshotRepository
|
||||||
return $st->fetchAll();
|
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
|
public function latestForCamera(string $cameraId): ?array
|
||||||
{
|
{
|
||||||
// Ordered by snapshot_id (insert order), NOT events.occurred_at: occurred_at is the
|
// Ordered by snapshot_id (insert order), NOT events.occurred_at: occurred_at is the
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ interface SnapshotRepository
|
||||||
* snapshot's site (via the owning event) for access control. */
|
* snapshot's site (via the owning event) for access control. */
|
||||||
public function eventIdByUuid(string $uuid): ?int;
|
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.
|
/** 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
|
* 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.
|
* over what the camera actually sees instead of guessing coordinates on a blank canvas.
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,30 @@
|
||||||
require __DIR__ . '/layout.php';
|
require __DIR__ . '/layout.php';
|
||||||
render_header('이벤트');
|
render_header('이벤트');
|
||||||
?>
|
?>
|
||||||
<h1>이벤트</h1>
|
<div class="page-head">
|
||||||
|
<h1>이벤트</h1>
|
||||||
|
<span class="watch" id="watch-status" aria-live="polite">실시간 감시 중</span>
|
||||||
|
</div>
|
||||||
|
<p class="page-sub">감지 현황 · 5초마다 갱신</p>
|
||||||
|
<div id="events-status" class="status-strip" aria-label="미확인 이벤트 현황"></div>
|
||||||
|
<p class="section-label">장치 상태</p>
|
||||||
|
<div id="devices-root" class="device-strip" aria-live="polite" aria-label="장치·카메라 상태"></div>
|
||||||
|
<div class="list-head">
|
||||||
|
<p class="section-label">최근 이벤트</p>
|
||||||
|
<div class="list-filters">
|
||||||
|
<select id="severity-filter" aria-label="심각도 필터">
|
||||||
|
<option value="">전체 심각도</option>
|
||||||
|
<option value="critical">위급</option>
|
||||||
|
<option value="high">높음</option>
|
||||||
|
<option value="warning">경고</option>
|
||||||
|
<option value="info">정보</option>
|
||||||
|
</select>
|
||||||
|
<select id="camera-filter" aria-label="카메라 필터"><option value="">전체 카메라</option></select>
|
||||||
|
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
<div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
||||||
|
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
|
||||||
<script src="<?= view_url('/assets/app.js') ?>"></script>
|
<script src="<?= view_url('/assets/app.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ if (!function_exists('view_url')) {
|
||||||
if (!function_exists('render_header')) {
|
if (!function_exists('render_header')) {
|
||||||
function render_header(string $title): void {
|
function render_header(string $title): void {
|
||||||
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
|
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
|
||||||
|
// Which primary nav tab is active is derived from the page title so no view
|
||||||
|
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
|
||||||
|
$navEvents = ($title === '이벤트') ? ' is-active' : '';
|
||||||
|
$navZones = ($title === '구역 설정') ? ' is-active' : '';
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
|
@ -34,17 +38,24 @@ if (!function_exists('render_header')) {
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= view_e($title) ?> · 이동 감지 대시보드</title>
|
<title><?= view_e($title) ?> · 이동 감지 대시보드</title>
|
||||||
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
|
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
|
||||||
<script>window.APP_BASE = <?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;</script>
|
<script>
|
||||||
|
window.APP_BASE = <?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||||
|
window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
|
<div class="app-brand">
|
||||||
|
<span class="live-dot" aria-hidden="true"></span>
|
||||||
|
<span class="brand-name">이동 감지 관제</span>
|
||||||
|
</div>
|
||||||
<nav class="app-nav">
|
<nav class="app-nav">
|
||||||
<a href="<?= view_url('/') ?>">이벤트</a>
|
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a>
|
||||||
<a href="<?= view_url('/zones') ?>">구역 설정</a>
|
<a href="<?= view_url('/zones') ?>" class="app-nav-link<?= $navZones ?>">구역 설정</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="app-session">
|
<div class="app-session">
|
||||||
<?php if ($username !== null): ?>
|
<?php if ($username !== null): ?>
|
||||||
<span><?= view_e($username) ?></span>
|
<span class="who"><?= view_e($username) ?></span>
|
||||||
<button type="button" id="logout-btn">로그아웃</button>
|
<button type="button" id="logout-btn">로그아웃</button>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<a href="<?= view_url('/login') ?>">로그인</a>
|
<a href="<?= view_url('/login') ?>">로그인</a>
|
||||||
|
|
@ -60,6 +71,19 @@ if (!function_exists('render_footer')) {
|
||||||
function render_footer(): void {
|
function render_footer(): void {
|
||||||
?>
|
?>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
/* Logout is wired here in the shared layout, not in a page script: app.js loads only on the
|
||||||
|
* events page and bails early on any page without #events-root, so previously the 로그아웃 button
|
||||||
|
* was inert on /zones and event-detail pages (a stale open session on a shared control-room PC). */
|
||||||
|
(function () {
|
||||||
|
var b = window.APP_BASE || '';
|
||||||
|
var btn = document.getElementById('logout-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
fetch(b + '/logout', { method: 'POST' }).then(function () { window.location.href = b + '/login'; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<?php
|
<?php
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,19 @@ final class ApiSyncTest extends TestCase
|
||||||
$res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000);
|
$res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000);
|
||||||
$this->assertSame(200, $res->status());
|
$this->assertSame(200, $res->status());
|
||||||
$this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']);
|
$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 {
|
public function testHeartbeatRejectsReplayedNonce(): void {
|
||||||
$cfg = Config::fromArray([]);
|
$cfg = Config::fromArray([]);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ declare(strict_types=1);
|
||||||
namespace App\Tests;
|
namespace App\Tests;
|
||||||
use App\Http\Request;
|
use App\Http\Request;
|
||||||
use App\Auth\ArraySessionStore;
|
use App\Auth\ArraySessionStore;
|
||||||
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy};
|
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, DeviceStatusController};
|
||||||
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository};
|
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository,
|
||||||
|
InMemoryDeviceRepository};
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
final class DashboardTest extends TestCase
|
final class DashboardTest extends TestCase
|
||||||
|
|
@ -14,13 +15,13 @@ final class DashboardTest extends TestCase
|
||||||
}
|
}
|
||||||
public function testListRequiresAuth(): void {
|
public function testListRequiresAuth(): void {
|
||||||
$repo = new InMemoryEventRepository();
|
$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());
|
$this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status());
|
||||||
}
|
}
|
||||||
public function testListReturnsRecent(): void {
|
public function testListReturnsRecent(): void {
|
||||||
$repo = new InMemoryEventRepository();
|
$repo = new InMemoryEventRepository();
|
||||||
$repo->insert(['severity' => 'high', 'event_type' => 'fall']);
|
$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', [], '', [], []));
|
$res = $c->json(new Request('GET', '/api/events', [], '', [], []));
|
||||||
$this->assertSame(200, $res->status());
|
$this->assertSame(200, $res->status());
|
||||||
$this->assertCount(1, json_decode($res->body(), true)['events']);
|
$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' => 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' => 200.0]); // id 2
|
||||||
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3
|
$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.
|
// 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);
|
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true);
|
||||||
$this->assertCount(2, $p1['events']);
|
$this->assertCount(2, $p1['events']);
|
||||||
|
|
@ -49,7 +50,7 @@ final class DashboardTest extends TestCase
|
||||||
$repo = new InMemoryEventRepository();
|
$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.123456]); // id 1
|
||||||
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2
|
$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);
|
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true);
|
||||||
$this->assertSame(2, $p1['events'][0]['event_id']); // newest first
|
$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);
|
$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' => 1.0, 'site' => 'factory-a']);
|
||||||
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']);
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']);
|
||||||
// A factory-a operator sees only the factory-a event.
|
// 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'];
|
$rows = json_decode($scoped->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
|
||||||
$this->assertCount(1, $rows);
|
$this->assertCount(1, $rows);
|
||||||
$this->assertSame('factory-a', $rows[0]['site']);
|
$this->assertSame('factory-a', $rows[0]['site']);
|
||||||
// A global operator (no site_scope) sees both.
|
// 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'];
|
$all = json_decode($global->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
|
||||||
$this->assertCount(2, $all);
|
$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 __construct(private string $file, private int $eid, private string $uuid) {}
|
||||||
public function add(int $e, string $u, string $h): void {}
|
public function add(int $e, string $u, string $h): void {}
|
||||||
public function forEvent(int $e): array { return []; }
|
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 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 eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; }
|
||||||
public function latestForCamera(string $cameraId): ?array { return 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'));
|
$cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a'));
|
||||||
$this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status());
|
$this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status());
|
||||||
$same = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-b'));
|
$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);
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ final class IngestControllerTest extends TestCase
|
||||||
$throwingSnaps = new class implements \App\Repository\SnapshotRepository {
|
$throwingSnaps = new class implements \App\Repository\SnapshotRepository {
|
||||||
public function add(int $e, string $u, string $h): void { throw new \RuntimeException('db down'); }
|
public function add(int $e, string $u, string $h): void { throw new \RuntimeException('db down'); }
|
||||||
public function forEvent(int $e): array { return []; }
|
public function forEvent(int $e): array { return []; }
|
||||||
|
public function thumbUuidsFor(array $ids): array { return []; }
|
||||||
public function pathByUuid(string $u): ?string { return null; }
|
public function pathByUuid(string $u): ?string { return null; }
|
||||||
public function eventIdByUuid(string $u): ?int { return null; }
|
public function eventIdByUuid(string $u): ?int { return null; }
|
||||||
public function latestForCamera(string $cameraId): ?array { return null; }
|
public function latestForCamera(string $cameraId): ?array { return null; }
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,23 @@ use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
|
||||||
|
|
||||||
final class InMemoryDeviceRepository implements DeviceRepository {
|
final class InMemoryDeviceRepository implements DeviceRepository {
|
||||||
public array $heartbeats = [];
|
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 = [],
|
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 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 siteFor(string $d): ?string { return $this->sites[$d] ?? null; }
|
||||||
public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
|
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 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 {
|
final class InMemoryNonceRepository implements NonceRepository {
|
||||||
public array $store = [];
|
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();
|
$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 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 pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
|
||||||
public function eventIdByUuid(string $u): ?int {
|
public function eventIdByUuid(string $u): ?int {
|
||||||
foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e;
|
foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e;
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,12 @@ final class ZoneConfigTest extends TestCase
|
||||||
if ($site !== null) $s->set('site_scope', $site);
|
if ($site !== null) $s->set('site_scope', $site);
|
||||||
return $s;
|
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 {
|
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 {
|
public function testPolygonValidation(): void {
|
||||||
$this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
|
$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',
|
$c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1',
|
||||||
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
'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 {
|
public function testSaveRejectsBadLevelOrPolygon(): void {
|
||||||
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
|
$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());
|
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue