feat(dashboard): device -> camera tree rail with pair-scoped camera filter
- Rail rework: the flat device strip + camera <select> becomes one device->camera
tree (device-tree.js owns the DOM). A device row carries the heartbeat verdict
(online/offline, fps, temp, throttle, config drift); the cameras seen in the
event stream nest under it and double as the stream's camera filter.
- The camera filter is a (device, camera) PAIR: camera ids are free-form edge
strings, so two devices may both run a "cam-01" - one device's events no longer
leak into another's filtered view. Clicking the selected camera (or 전체 보기)
clears the filter.
- A camera whose device is missing from /api/devices renders as its own
unknown-device group ("장치 목록에 없음 - 등록 확인") instead of being dropped.
- Offline rows show liveness only - fps/temp are the LAST values a dead device
reported and would read as current telemetry.
- Poll-failure staleness, collapse state, and focus survive the 5s re-render;
an unchanged poll never rebuilds the DOM (render signature).
- Stream rows: device NAME + mono camera id + compact 24h stamp (year only when
not current); thumb slot links to the incident and is honest about 무영상 for
screen readers; KPI strip captioned "미확인" once.
Verified: PHPUnit 119 pass (4 skipped, PDO gate) + real-render harness in Chrome
(tree states, pair filter, collapse, reset, no console errors).
This commit is contained in:
parent
9d25e0a2d1
commit
eaff1765a4
4 changed files with 627 additions and 231 deletions
|
|
@ -1,44 +1,51 @@
|
|||
/* ==================================================================
|
||||
AI SAFETY CCTV — operations console.
|
||||
Light, dense, professional. A safety board is calm most of the time and
|
||||
urgent rarely: near-monochrome chrome, indigo reserved for interaction,
|
||||
colour spent only on the unacknowledged-critical alarm. Density carries the
|
||||
"professional product" weight — a two-column console (device/camera rail +
|
||||
detection stream) with a compact KPI strip and snapshot-forward event rows.
|
||||
urgent rarely: near-monochrome chrome, one interaction colour, and colour
|
||||
spent only on the unacknowledged-critical alarm. Density carries the
|
||||
"professional product" weight — a two-column console (device→camera tree +
|
||||
detection stream) with a compact KPI strip and evidence-forward event rows.
|
||||
Accessibility (NF9): state is never colour alone — a dot/led always pairs
|
||||
with a text label. Air-gapped (폐쇄망): system fonts, zero external assets.
|
||||
with a text label, and every ink is contrast-checked on its own surface.
|
||||
Air-gapped (폐쇄망): system fonts, zero external assets.
|
||||
================================================================== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Surfaces — content is light; chrome (top bar + rail) is a second neutral
|
||||
layer, a hair cooler and elevated with hairline borders. */
|
||||
--bg: #eef1f5; /* app canvas (cool light) */
|
||||
/* Surfaces — content is white; chrome (top bar + rail) is a second neutral
|
||||
layer, and the canvas sits a step deeper so white cards read as figure. */
|
||||
--bg: #e9edf1; /* app canvas */
|
||||
--panel: #ffffff; /* top bar + rail surface */
|
||||
--panel-2: #f7f9fc; /* rail inset / hover fill */
|
||||
--panel-2: #f5f8fa; /* rail inset / hover fill */
|
||||
--card: #ffffff; /* content rows/cards */
|
||||
--line: #e3e7ee; /* hairline border */
|
||||
--line-2: #edf0f5; /* faint divider */
|
||||
--line: #dee4ea; /* hairline border */
|
||||
--line-2: #eaeff4; /* faint divider */
|
||||
|
||||
/* Ink ramp — contrast-checked on white. ink-2 ≈ 8:1, ink-3 ≈ 4.6:1. */
|
||||
/* Ink ramp — contrast-checked on white AND on --panel-2 (the rail inset),
|
||||
where the rail's small mono meta lives: ink-3 ≈ 5.0:1 there. */
|
||||
--ink: #131824;
|
||||
--ink-2: #48505e;
|
||||
--ink-3: #6b7382;
|
||||
--ink-2: #464e5c;
|
||||
--ink-3: #626a79;
|
||||
|
||||
/* Brand + interaction (indigo — only where you act: nav, focus, selection, primary). */
|
||||
--accent: #4f46e5;
|
||||
/* Interaction — deep petrol. Placed deliberately far from the severity ramp
|
||||
(red/orange/amber) on the wheel: "where you act" can never be misread as
|
||||
"what is wrong". Used for nav, focus, selection and primary only. */
|
||||
--accent: #0b6b7a;
|
||||
--accent-deep: #095a67;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-wash: #eceafe;
|
||||
--accent-line: #d6d2fb;
|
||||
--accent-wash: #e2f0f3;
|
||||
--accent-line: #b9d9df;
|
||||
|
||||
/* Live monitoring (calm green, never an alarm). */
|
||||
--ok: #15a34a;
|
||||
--ok: #15803d;
|
||||
--ok-wash: #e7f6ec;
|
||||
|
||||
/* Severity — reserved, semantic; identity always carries a text label too. */
|
||||
/* Severity — reserved, semantic; identity always carries a text label too.
|
||||
The pure hue drives graphics (dots, leds, tints); the *-ink variant drives
|
||||
TEXT, because the pure hue alone misses 4.5:1 at label sizes. */
|
||||
--crit:#dc2626; --crit-ink:#a51d1d; --crit-bg:#fdf2f2; --crit-line:#f2cccc;
|
||||
--high:#e2560c; --high-bg:#fdf4ee; --high-line:#f5d9c4;
|
||||
--warn:#b7820a; --warn-bg:#fbf6e6;
|
||||
--high:#e2560c; --high-ink:#9a3a06; --high-bg:#fdf4ee; --high-line:#f5d9c4;
|
||||
--warn:#b7820a; --warn-ink:#7a5606; --warn-bg:#fbf6e6; --warn-line:#efe0b4;
|
||||
--info:#5b6473;
|
||||
|
||||
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
|
||||
|
|
@ -46,7 +53,7 @@
|
|||
|
||||
--shadow-sm:0 1px 2px rgba(19,24,36,.05);
|
||||
--shadow: 0 1px 2px rgba(19,24,36,.04), 0 10px 26px -18px rgba(19,24,36,.42);
|
||||
--radius:12px;
|
||||
--radius:10px;
|
||||
|
||||
/* z-scale (semantic, not arbitrary) */
|
||||
--z-sticky:100; --z-dropdown:200; --z-toast:400;
|
||||
|
|
@ -68,38 +75,38 @@ body {
|
|||
.app-header {
|
||||
position:sticky; top:0; z-index:var(--z-sticky);
|
||||
display:flex; align-items:center; gap:1.1rem;
|
||||
padding:.6rem 1.25rem;
|
||||
padding:.55rem 1.15rem;
|
||||
background:var(--panel);
|
||||
border-bottom:1px solid var(--line);
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.app-brand { display:flex; align-items:center; gap:.6rem; text-decoration:none; color:inherit; }
|
||||
/* The one dark object in a light console: an equipment-plate mark. It is the page's
|
||||
focal anchor and reads as hardware, not as a generic app logo. */
|
||||
.brand-mark {
|
||||
display:grid; place-items:center; width:34px; height:34px; flex:none;
|
||||
border-radius:9px; color:var(--accent);
|
||||
background:linear-gradient(180deg,#f3f2ff,#e9e7fe);
|
||||
border:1px solid var(--accent-line);
|
||||
display:grid; place-items:center; width:32px; height:32px; flex:none;
|
||||
border-radius:8px; color:#7fd0dd; background:#0f2b31;
|
||||
}
|
||||
.brand-mark svg { width:20px; height:20px; display:block; }
|
||||
.brand-mark svg { width:19px; height:19px; display:block; }
|
||||
.brand-text { display:flex; flex-direction:column; line-height:1.12; }
|
||||
.brand-name { font-weight:750; font-size:.92rem; letter-spacing:.02em; color:var(--ink); }
|
||||
.brand-tag { font-size:.68rem; color:var(--ink-3); letter-spacing:.01em; }
|
||||
.brand-name { font-weight:750; font-size:.9rem; letter-spacing:.02em; color:var(--ink); }
|
||||
.brand-tag { font-size:.67rem; color:var(--ink-3); letter-spacing:.01em; }
|
||||
/* legacy hook from the old header — kept harmless */
|
||||
.live-dot { display:none; }
|
||||
|
||||
.app-nav { display:flex; gap:.15rem; margin-left:.6rem; margin-right:auto; }
|
||||
.app-nav a {
|
||||
position:relative; color:var(--ink-2); text-decoration:none;
|
||||
font-size:.86rem; font-weight:550; padding:.42rem .7rem; border-radius:8px;
|
||||
font-size:.85rem; font-weight:550; padding:.4rem .68rem; border-radius:7px;
|
||||
transition:background .15s, color .15s;
|
||||
}
|
||||
.app-nav a:hover, .app-nav a:focus-visible { background:var(--panel-2); color:var(--ink); }
|
||||
.app-nav a.is-active { color:var(--accent); background:var(--accent-wash); }
|
||||
|
||||
.app-session { display:flex; align-items:center; gap:.7rem; font-size:.84rem; color:var(--ink-2); }
|
||||
.app-session { display:flex; align-items:center; gap:.7rem; font-size:.83rem; color:var(--ink-2); }
|
||||
.app-session .who { color:var(--ink); font-weight:550; display:inline-flex; align-items:center; gap:.4rem; }
|
||||
.app-session .who::before {
|
||||
content:""; width:22px; height:22px; border-radius:50%; flex:none;
|
||||
content:""; width:21px; height:21px; border-radius:50%; flex:none;
|
||||
background:radial-gradient(circle at 50% 38%, #fff 0 34%, transparent 35%), var(--accent-wash);
|
||||
border:1px solid var(--accent-line);
|
||||
}
|
||||
|
|
@ -109,19 +116,19 @@ body {
|
|||
/* ---- Controls: buttons, fields ----------------------------------- */
|
||||
button, .btn {
|
||||
font:inherit; font-size:.82rem; font-weight:550; cursor:pointer;
|
||||
padding:.4rem .78rem; color:var(--ink); background:var(--card);
|
||||
border:1px solid var(--line); border-radius:9px;
|
||||
padding:.38rem .74rem; color:var(--ink); background:var(--card);
|
||||
border:1px solid var(--line); border-radius:8px;
|
||||
transition:transform .1s, background .15s, border-color .15s, box-shadow .15s;
|
||||
}
|
||||
button:hover, .btn:hover { background:var(--panel-2); border-color:#d3d9e2; }
|
||||
button:hover, .btn:hover { background:var(--panel-2); border-color:#ccd4dd; }
|
||||
button:active, .btn:active { transform:translateY(.5px) scale(.99); }
|
||||
button:disabled { cursor:default; }
|
||||
:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
|
||||
|
||||
input, select {
|
||||
font:inherit; font-size:.86rem; color:var(--ink);
|
||||
padding:.44rem .6rem; background:var(--card);
|
||||
border:1px solid var(--line); border-radius:9px;
|
||||
font:inherit; font-size:.85rem; color:var(--ink);
|
||||
padding:.42rem .58rem; background:var(--card);
|
||||
border:1px solid var(--line); border-radius:8px;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input::placeholder { color:var(--ink-3); }
|
||||
|
|
@ -132,96 +139,162 @@ input:focus, select:focus {
|
|||
label { color:var(--ink-2); font-size:.84rem; }
|
||||
|
||||
/* ---- Page shell -------------------------------------------------- */
|
||||
.app-main { max-width:1380px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
|
||||
/* Measure, not screen width: past ~1240px the eye has to travel from the severity badge to the
|
||||
확인 button across pure air, and the row stops reading as one unit. */
|
||||
.app-main { max-width:1240px; margin:0 auto; padding:1.15rem 1.15rem 3rem; }
|
||||
|
||||
/* ---- Console: device/camera rail + detection stream -------------- */
|
||||
.console { display:grid; grid-template-columns:264px minmax(0,1fr); gap:1.25rem; align-items:start; }
|
||||
.console { display:grid; grid-template-columns:272px minmax(0,1fr); gap:1rem; align-items:start; }
|
||||
|
||||
.rail {
|
||||
position:sticky; top:calc(46px + 1.5rem);
|
||||
display:flex; flex-direction:column; gap:1.1rem;
|
||||
position:sticky; top:calc(45px + 1.15rem);
|
||||
display:flex; flex-direction:column; gap:.85rem;
|
||||
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
|
||||
padding:1rem .9rem; box-shadow:var(--shadow-sm);
|
||||
padding:.8rem .75rem; box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.rail-block { display:flex; flex-direction:column; gap:.6rem; }
|
||||
.rail-block + .rail-block { padding-top:1rem; border-top:1px solid var(--line-2); }
|
||||
.rail-block { display:flex; flex-direction:column; gap:.45rem; }
|
||||
.rail-block + .rail-block { padding-top:.8rem; border-top:1px solid var(--line-2); }
|
||||
.rail-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem; min-height:1.15rem; }
|
||||
.rail-title {
|
||||
margin:0; font-size:.7rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
|
||||
margin:0; font-size:.69rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
|
||||
color:var(--ink-3);
|
||||
}
|
||||
.rail-filters { display:flex; flex-direction:column; gap:.6rem; }
|
||||
.rail-filters .field { display:flex; flex-direction:column; gap:.3rem; font-size:.74rem; font-weight:600; color:var(--ink-3); letter-spacing:.02em; text-transform:uppercase; }
|
||||
.rail-filters select { text-transform:none; font-size:.85rem; font-weight:400; letter-spacing:0; color:var(--ink); }
|
||||
.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.85rem; color:var(--ink-2); cursor:pointer; user-select:none; }
|
||||
.rail-reset {
|
||||
font-size:.68rem; font-weight:600; padding:.1rem .42rem; border-radius:6px;
|
||||
color:var(--accent); background:var(--accent-wash); border-color:var(--accent-line);
|
||||
}
|
||||
.rail-reset:hover { background:#d5e9ed; border-color:#a5ccd4; }
|
||||
.rail-reset[hidden] { display:none; }
|
||||
/* Rail's live region: one short summary line, so a screen reader hears "카메라 1대 오프라인"
|
||||
instead of the whole tree re-read on every 5s poll. */
|
||||
.rail-sum { margin:0; font-family:var(--mono); font-size:.7rem; color:var(--ink-3); letter-spacing:-.01em; }
|
||||
.rail-sum .is-down { color:var(--crit-ink); font-weight:600; }
|
||||
|
||||
.rail-filters { display:flex; flex-direction:column; gap:.55rem; }
|
||||
.rail-filters .field { display:flex; flex-direction:column; gap:.28rem; font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
|
||||
.rail-filters select { text-transform:none; font-size:.84rem; font-weight:400; letter-spacing:0; color:var(--ink); }
|
||||
.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; }
|
||||
.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
|
||||
|
||||
/* ---- Device → camera tree (rail) --------------------------------- */
|
||||
/* Devices carry the heartbeat telemetry (online/offline, fps, temp, throttle, drift); the
|
||||
cameras that have actually reported events nest underneath on hairline connectors, so the
|
||||
rail answers "what is watching, and is it alive?" in one glance. A camera node is also the
|
||||
stream filter. An offline device takes the alarm tint, with "오프라인" text beside the dot. */
|
||||
.device-tree { display:flex; flex-direction:column; gap:.28rem; }
|
||||
.device-tree.is-empty { color:var(--ink-3); font-size:.8rem; padding:.2rem 0; }
|
||||
.device-tree.is-stale { opacity:.62; }
|
||||
.device-stale-note { font-size:.69rem; color:var(--crit-ink); padding-bottom:.15rem; }
|
||||
|
||||
.device-node { border:1px solid var(--line); border-radius:9px; background:var(--panel-2); }
|
||||
.device-head {
|
||||
display:block; width:100%; text-align:left; font:inherit;
|
||||
padding:.4rem .48rem; background:none; border:0; border-radius:9px;
|
||||
}
|
||||
.device-head:hover { background:#fff; }
|
||||
.device-line { display:flex; align-items:center; gap:.4rem; }
|
||||
.device-line .twisty {
|
||||
width:5px; height:5px; flex:none; margin:-2px .05rem 0 .1rem;
|
||||
border-right:1.5px solid var(--ink-3); border-bottom:1.5px solid var(--ink-3);
|
||||
transform:rotate(45deg); transition:transform .15s;
|
||||
}
|
||||
.device-node.is-collapsed .twisty { transform:rotate(-45deg); }
|
||||
.device-line .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; box-shadow:0 0 0 3px var(--ok-wash); }
|
||||
.device-name { font-weight:600; font-size:.83rem; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.device-tally { margin-left:auto; font-family:var(--mono); font-size:.68rem; color:var(--ink-3); flex:none; }
|
||||
.device-meta { display:block; margin-top:.2rem; padding-left:1.1rem; font-family:var(--mono); font-size:.69rem; color:var(--ink-3); }
|
||||
.device-warn { display:block; margin-top:.18rem; padding-left:1.1rem; font-size:.69rem; font-weight:650; color:var(--warn-ink); }
|
||||
.device-node.is-offline { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.device-node.is-offline .device-head:hover { background:#fef7f7; }
|
||||
.device-node.is-offline .device-name { color:var(--crit-ink); }
|
||||
.device-node.is-offline .device-meta { color:var(--crit-ink); }
|
||||
.device-node.is-offline .device-line .dot { background:var(--crit); box-shadow:0 0 0 3px #f7d9d9; }
|
||||
/* A camera reporting under a device_id that /api/devices does not know: no heartbeat verdict
|
||||
exists, so the dot must not claim one. Neutral dot + "장치 목록에 없음" text. */
|
||||
.device-node.is-unknown .device-line .dot { background:var(--ink-3); box-shadow:0 0 0 3px #e2e6ec; }
|
||||
|
||||
/* Cameras: hairline tree connectors (structural, 1px, neutral) — an equipment list, not
|
||||
an indent. The last child's vertical segment stops at its own elbow. */
|
||||
.cam-list { list-style:none; margin:0; padding:.05rem .48rem .4rem 1.5rem; }
|
||||
.cam-item { position:relative; }
|
||||
.cam-item + .cam-item { margin-top:.1rem; }
|
||||
.cam-item::before { content:""; position:absolute; left:-.5rem; top:0; bottom:0; width:1px; background:#c8d1db; }
|
||||
.cam-item:last-child::before { bottom:auto; height:50%; }
|
||||
.cam-item::after { content:""; position:absolute; left:-.5rem; top:50%; width:.42rem; height:1px; background:#c8d1db; }
|
||||
.cam-item:has(.cam-node[aria-pressed="true"])::after { background:var(--accent); }
|
||||
.device-node.is-offline .cam-item::before, .device-node.is-offline .cam-item::after { background:var(--crit-line); }
|
||||
|
||||
.cam-node {
|
||||
display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:.4rem;
|
||||
width:100%; text-align:left; font:inherit;
|
||||
padding:.2rem .35rem; border:1px solid transparent; border-radius:6px;
|
||||
background:none; color:var(--ink-2);
|
||||
}
|
||||
.cam-node:hover { background:#fff; border-color:var(--line); }
|
||||
.cam-node[aria-pressed="true"] { background:var(--accent-wash); border-color:var(--accent-line); color:var(--accent); }
|
||||
.cam-id { font-family:var(--mono); font-size:.74rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.cam-when { font-family:var(--mono); font-size:.66rem; color:var(--ink-3); }
|
||||
.cam-node[aria-pressed="true"] .cam-when { color:var(--accent); }
|
||||
.cam-count {
|
||||
min-width:1.1rem; text-align:center; padding:.02rem .26rem; border-radius:999px;
|
||||
font-size:.65rem; font-weight:700; line-height:1.5;
|
||||
color:var(--ink-2); background:#e6eaef;
|
||||
}
|
||||
.cam-count.lv-critical { color:var(--crit-ink); background:var(--crit-bg); box-shadow:inset 0 0 0 1px var(--crit-line); }
|
||||
.cam-count.lv-high { color:var(--high-ink); background:var(--high-bg); box-shadow:inset 0 0 0 1px var(--high-line); }
|
||||
.cam-count.lv-warning { color:var(--warn-ink); background:var(--warn-bg); box-shadow:inset 0 0 0 1px var(--warn-line); }
|
||||
/* Sits on the camera column, not the card edge — an empty tree branch, not a stray note. */
|
||||
.cam-empty { padding:.05rem .48rem .42rem 1.85rem; font-size:.69rem; color:var(--ink-3); }
|
||||
|
||||
/* ---- Stream (main) ----------------------------------------------- */
|
||||
.stream { display:flex; flex-direction:column; min-width:0; }
|
||||
.stream-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.9rem; }
|
||||
.stream-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.75rem; }
|
||||
.stream-title { display:flex; align-items:baseline; gap:.7rem; }
|
||||
h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; }
|
||||
h1 { font-size:1.3rem; font-weight:760; letter-spacing:-.02em; margin:0; }
|
||||
.page-head { display:flex; align-items:baseline; gap:.8rem; margin:0 0 .25rem; flex-wrap:wrap; }
|
||||
.page-sub { color:var(--ink-3); font-size:.86rem; margin:.15rem 0 1.4rem; }
|
||||
.section-label { font-size:.72rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; margin:0 0 .7rem; }
|
||||
|
||||
/* Live watch pill — green when polling, muted when stale; dot breathes while live. */
|
||||
.watch { font-size:.8rem; font-weight:650; color:var(--ok); display:inline-flex; align-items:center; gap:.4rem; }
|
||||
.watch { font-size:.79rem; font-weight:650; color:var(--ok); display:inline-flex; align-items:center; gap:.4rem; }
|
||||
.watch::before { content:""; width:7px; height:7px; border-radius:50%; background:currentColor; flex:none; box-shadow:0 0 0 0 currentColor; animation:watch-pulse 2.4s ease-out infinite; }
|
||||
.watch.is-stale { color:var(--ink-3); }
|
||||
.watch.is-stale::before { animation:none; }
|
||||
@keyframes watch-pulse { 0%{ box-shadow:0 0 0 0 rgba(21,163,74,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,163,74,0);} }
|
||||
@keyframes watch-pulse { 0%{ box-shadow:0 0 0 0 rgba(21,128,61,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,128,61,0);} }
|
||||
|
||||
/* ---- KPI strip: compact unacknowledged counts -------------------- */
|
||||
/* Computed client-side from the /api/events window (no extra endpoint). A tight
|
||||
inline strip of pills, not four hero cards — dense, glanceable. Zero dims, never hides. */
|
||||
#events-status.kpi-strip { display:flex; gap:.5rem; flex-wrap:wrap; }
|
||||
.kpi-wrap { display:flex; align-items:center; gap:.55rem; flex-wrap:wrap; }
|
||||
.kpi-cap { font-size:.68rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--ink-3); }
|
||||
#events-status.kpi-strip { display:flex; gap:.4rem; flex-wrap:wrap; }
|
||||
.stat-tile {
|
||||
display:inline-flex; align-items:center; gap:.5rem;
|
||||
padding:.4rem .7rem; background:var(--card);
|
||||
display:inline-flex; align-items:center; gap:.45rem;
|
||||
padding:.32rem .62rem; background:var(--card);
|
||||
border:1px solid var(--line); border-radius:999px; box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.stat-tile .stat-label { display:inline-flex; align-items:center; gap:.4rem; font-size:.76rem; font-weight:600; color:var(--ink-2); }
|
||||
.stat-tile .stat-label { display:inline-flex; align-items:center; gap:.38rem; font-size:.75rem; font-weight:600; 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; }
|
||||
.stat-tile .stat-count { font-size:1rem; font-weight:750; letter-spacing:-.02em; line-height:1; color:var(--ink); }
|
||||
.stat-tile .stat-count { font-size:.98rem; font-weight:750; letter-spacing:-.02em; line-height:1; color:var(--ink); }
|
||||
.stat-tile.is-zero { opacity:.55; }
|
||||
.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);}
|
||||
.tile-critical:not(.is-zero) { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.tile-critical:not(.is-zero) .stat-count, .tile-critical:not(.is-zero) .stat-label { color:var(--crit-ink); }
|
||||
|
||||
/* ---- Device / camera tree (rail) --------------------------------- */
|
||||
/* Heartbeat telemetry the edge reports (online/offline, fps, temp, throttle, drift).
|
||||
A dead camera is a safety blind spot → an offline row takes the alarm red, with the
|
||||
"오프라인/응답 없음" text carrying state alongside the dot (NF9). */
|
||||
.device-strip { display:flex; flex-direction:column; gap:.4rem; }
|
||||
.device-strip.is-empty { color:var(--ink-3); font-size:.82rem; padding:.3rem 0; }
|
||||
.device-card {
|
||||
background:var(--panel-2); border:1px solid var(--line); border-radius:10px;
|
||||
padding:.55rem .65rem; transition:border-color .15s, background .15s;
|
||||
}
|
||||
.device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.85rem; color:var(--ink); }
|
||||
.device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; box-shadow:0 0 0 3px var(--ok-wash); }
|
||||
.device-card .device-meta { margin-top:.28rem; padding-left:.02rem; font-family:var(--mono); font-size:.72rem; color:var(--ink-3); }
|
||||
.device-card .device-warn { margin-top:.32rem; font-size:.7rem; font-weight:650; color:var(--warn); }
|
||||
.device-card.is-offline { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.device-card.is-offline .device-name { color:var(--crit-ink); }
|
||||
.device-card.is-offline .device-name .dot { background:var(--crit); box-shadow:0 0 0 3px var(--crit-bg); }
|
||||
.device-strip.is-stale { opacity:.6; }
|
||||
.device-stale-note { font-size:.7rem; color:var(--ink-3); padding-bottom:.2rem; }
|
||||
|
||||
/* ---- Detection stream: event rows -------------------------------- */
|
||||
.event-list { display:flex; flex-direction:column; gap:.5rem; }
|
||||
.event-list { display:flex; flex-direction:column; gap:.4rem; }
|
||||
.event-list[aria-busy="true"] .stream-empty { color:var(--ink-3); }
|
||||
.stream-empty { padding:2.5rem 1rem; text-align:center; color:var(--ink-3); font-size:.9rem; }
|
||||
|
||||
.event-row {
|
||||
display:flex; align-items:center; gap:.9rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:11px;
|
||||
padding:.6rem .8rem; box-shadow:var(--shadow-sm);
|
||||
transition:border-color .15s, box-shadow .15s, transform .1s;
|
||||
display:flex; align-items:center; gap:.8rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:10px;
|
||||
padding:.45rem .7rem .45rem .45rem; box-shadow:var(--shadow-sm);
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.event-row:hover { border-color:#d3d9e2; box-shadow:var(--shadow); }
|
||||
.event-row:hover { border-color:#ccd4dd; box-shadow:var(--shadow); }
|
||||
/* The one row that takes colour: an unacknowledged critical — full tint + border + a soft
|
||||
attention glow (state, not decoration). Never a bare colour: badge + "확인 필요" chip too. */
|
||||
.event-row.is-unacknowledged.sev-row-critical {
|
||||
|
|
@ -231,34 +304,46 @@ h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; }
|
|||
.event-row.is-unacknowledged.sev-row-high { background:var(--high-bg); border-color:var(--high-line); }
|
||||
@keyframes crit-attention { 0%{ box-shadow:0 0 0 0 rgba(220,38,38,.18);} 70%,100%{ box-shadow:0 0 0 5px rgba(220,38,38,0);} }
|
||||
|
||||
/* Evidence slot — a camera still on the left of every row (aligned, CCTV-grid feel). */
|
||||
.event-thumb-slot { position:relative; width:74px; height:52px; flex:none; border-radius:8px; border:1px solid var(--line); background:#0e1116; overflow:hidden; }
|
||||
/* Evidence first — a 16:9 still large enough to judge the scene without opening the
|
||||
incident, and itself a link into it. Rows stay aligned, so the stream reads as a
|
||||
contact sheet of the last N detections. */
|
||||
.event-thumb-slot {
|
||||
position:relative; display:block; width:132px; height:76px; flex:none;
|
||||
border-radius:7px; border:1px solid var(--line); background:#0e1116; overflow:hidden;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-wash); }
|
||||
.event-thumb { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
/* No snapshot (none captured, or purged by PIPA retention): an empty frame, not a black feed. */
|
||||
.event-thumb-slot.is-empty { border-style:dashed; border-color:#cdd3dc; background:var(--panel-2); }
|
||||
.event-thumb-slot.is-empty::after { content:"무영상"; position:absolute; inset:0; display:grid; place-items:center; font-size:.62rem; color:var(--ink-3); }
|
||||
.event-thumb-slot.is-empty { border-style:dashed; border-color:#c9d1da; background:var(--panel-2); }
|
||||
.event-thumb-slot.is-empty::after { content:"무영상"; position:absolute; inset:0; display:grid; place-items:center; font-size:.68rem; color:var(--ink-3); }
|
||||
|
||||
.severity-badge { display:inline-flex; align-items:center; gap:.42rem; flex:none; min-width:58px; font-size:.8rem; font-weight:650; white-space:nowrap; }
|
||||
.severity-badge { display:inline-flex; align-items:center; gap:.4rem; flex:none; min-width:56px; font-size:.79rem; font-weight:650; white-space:nowrap; }
|
||||
.severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; flex:none; }
|
||||
.sev-critical{ color:var(--crit);} .sev-high{ color:var(--high);} .sev-warning{ color:var(--warn);} .sev-info{ color:var(--info);}
|
||||
.sev-critical{ color:var(--crit-ink);} .sev-high{ color:var(--high-ink);} .sev-warning{ color:var(--warn-ink);} .sev-info{ color:var(--info);}
|
||||
|
||||
.event-meta { flex:1; min-width:0; }
|
||||
.event-type { font-weight:650; font-size:.95rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; }
|
||||
.event-time { margin-top:.15rem; font-family:var(--mono); font-size:.75rem; color:var(--ink-3); }
|
||||
/* Provenance: device (prose) → camera (mono) → stamp (mono). Typeface does the separating,
|
||||
so no "·" chain fights the "·" inside device names. */
|
||||
.event-time { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.7rem; margin-top:.22rem; font-size:.74rem; }
|
||||
.event-time .ev-src { font-weight:550; color:var(--ink-2); }
|
||||
.event-time .ev-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); }
|
||||
.event-time .ev-at { font-family:var(--mono); 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 { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
|
||||
.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
|
||||
|
||||
.ack-btn { margin-left:auto; flex:none; }
|
||||
.ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.76rem; }
|
||||
.ack-btn { margin-left:auto; flex:none; min-width:4.5rem; text-align:center; }
|
||||
.ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.75rem; }
|
||||
|
||||
/* "new events arrived while paging" reload affordance — honest about a possible gap. */
|
||||
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .7rem; padding:.5rem .75rem;
|
||||
font-size:.8rem; color:var(--ink-2); background:var(--accent-wash); border:1px solid var(--accent-line); border-radius:9px; }
|
||||
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem;
|
||||
font-size:.79rem; color:var(--ink-2); background:var(--accent-wash); border:1px solid var(--accent-line); border-radius:8px; }
|
||||
.page-gap-hint[hidden] { display:none; }
|
||||
.page-gap-hint button { margin-left:auto; }
|
||||
|
||||
.list-more { display:flex; justify-content:center; margin-top:.8rem; }
|
||||
.list-more { display:flex; justify-content:center; margin-top:.7rem; }
|
||||
.list-more button[hidden] { display:none; }
|
||||
|
||||
/* legacy list toolbar hooks (filters moved into the rail) */
|
||||
|
|
@ -268,20 +353,20 @@ h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; }
|
|||
/* ---- Login ------------------------------------------------------- */
|
||||
.login-form {
|
||||
display:flex; flex-direction:column; gap:.7rem; max-width:360px; margin:4rem auto 0;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:16px; padding:2rem 1.9rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:14px; padding:2rem 1.9rem;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.login-form h1 { margin:.1rem 0 .5rem; font-size:1.3rem; }
|
||||
.login-form label { display:flex; flex-direction:column; gap:.35rem; font-size:.8rem; font-weight:600; color:var(--ink-2); }
|
||||
.login-form button { margin-top:.4rem; padding:.6rem; font-size:.9rem; background:var(--accent); color:var(--accent-ink); border-color:var(--accent); }
|
||||
.login-form button:hover { background:#4239d6; border-color:#4239d6; }
|
||||
.form-error { color:var(--crit); font-size:.85rem; }
|
||||
.login-form button:hover { background:var(--accent-deep); border-color:var(--accent-deep); }
|
||||
.form-error { color:var(--crit-ink); font-size:.85rem; }
|
||||
|
||||
/* ---- Zone editor ------------------------------------------------- */
|
||||
.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; }
|
||||
.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; }
|
||||
#zone-canvas {
|
||||
border:1px solid var(--line); border-radius:12px;
|
||||
border:1px solid var(--line); border-radius:10px;
|
||||
background:#0e1116 center / contain no-repeat; cursor:crosshair; max-width:100%;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
|
|
@ -289,7 +374,7 @@ h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; }
|
|||
#zone-list li {
|
||||
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
||||
max-width:380px; padding:.5rem .75rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:10px;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:9px;
|
||||
font-size:.85rem; color:var(--ink-2);
|
||||
}
|
||||
#zone-status { font-family:var(--mono); font-size:.82rem; color:var(--ink-3); }
|
||||
|
|
@ -298,21 +383,32 @@ h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; }
|
|||
.detail-row { margin:.35rem 0; }
|
||||
.detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; 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); }
|
||||
.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:10px; background:#fff; padding:4px; box-shadow:var(--shadow); }
|
||||
|
||||
/* ---- Responsive: rail collapses above the stream ----------------- */
|
||||
@media (max-width:860px) {
|
||||
.console { grid-template-columns:1fr; }
|
||||
.rail { position:static; }
|
||||
.rail-block { flex-direction:column; }
|
||||
.rail-filters { flex-direction:row; flex-wrap:wrap; align-items:end; }
|
||||
.device-strip { flex-direction:row; flex-wrap:wrap; }
|
||||
.device-card { flex:1 1 12rem; }
|
||||
/* The tree keeps its nesting but flows into columns instead of one tall list. */
|
||||
.device-tree { display:grid; grid-template-columns:repeat(auto-fill,minmax(15rem,1fr)); gap:.35rem; align-items:start; }
|
||||
.device-stale-note { grid-column:1/-1; }
|
||||
}
|
||||
@media (max-width:560px) {
|
||||
.app-main { padding:1rem .9rem 2.5rem; }
|
||||
.brand-tag { display:none; }
|
||||
.event-thumb { width:60px; height:42px; }
|
||||
/* Below this width the four header groups cannot share one line without every label
|
||||
wrapping mid-word. Give the nav its own row instead. */
|
||||
.app-header { flex-wrap:wrap; gap:.5rem .7rem; padding:.5rem .9rem; }
|
||||
.brand-name, .app-session .who, .app-session button, .app-session a { white-space:nowrap; }
|
||||
.app-nav { order:3; width:100%; margin:0; }
|
||||
.app-nav a { flex:1; text-align:center; }
|
||||
.app-session { margin-left:auto; }
|
||||
.event-row { gap:.6rem; }
|
||||
.event-thumb-slot { width:104px; height:60px; }
|
||||
/* The badge shrink-wraps, but its text label always stays: severity must never
|
||||
ride on the dot's colour alone (NF9), least of all on a phone. */
|
||||
.severity-badge { min-width:0; }
|
||||
}
|
||||
|
||||
/* ---- Reduced motion ---------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -46,11 +46,19 @@
|
|||
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
||||
}
|
||||
|
||||
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
||||
|
||||
// Compact 24h stamp in the row's mono column. toLocaleString('ko-KR') renders
|
||||
// "2026. 7. 26. 오후 2:22:31": long, 12-hour, and non-tabular, so a column of them cannot be
|
||||
// scanned. The year is shown only when the event is not from the current year, so a deep
|
||||
// paged-in row is never mistaken for a recent one.
|
||||
function formatTime(occurredAt) {
|
||||
if (!occurredAt && occurredAt !== 0) return '';
|
||||
var d = new Date(Number(occurredAt) * 1000);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('ko-KR');
|
||||
var year = d.getFullYear() !== new Date().getFullYear() ? d.getFullYear() + '-' : '';
|
||||
return year + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' '
|
||||
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
||||
}
|
||||
|
||||
function buildBadge(sev) {
|
||||
|
|
@ -118,6 +126,32 @@
|
|||
}
|
||||
}
|
||||
|
||||
// No snapshot, or the file is gone (PIPA retention purge / a broken store). Both are the same
|
||||
// fact for the operator — the row has no evidence — and the label has to say so, because the
|
||||
// "무영상" the slot draws is CSS content and never reaches a screen reader.
|
||||
function markEmptySlot(slot) {
|
||||
slot.classList.add('is-empty');
|
||||
slot.setAttribute('aria-label', '무영상 · 상세 보기');
|
||||
}
|
||||
|
||||
function spanWith(cls, text) {
|
||||
var s = document.createElement('span');
|
||||
s.className = cls;
|
||||
s.textContent = text;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Human name for the event's device, from the last /api/devices payload. Falls back to the
|
||||
// raw device_id: a row must still say where it came from when the device is unregistered.
|
||||
function deviceLabel(deviceId) {
|
||||
var id = String(deviceId || '');
|
||||
if (!id) return '';
|
||||
for (var i = 0; i < deviceList.length; i++) {
|
||||
if (String(deviceList[i].device_id) === id) return deviceList[i].name || id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function buildRow(ev) {
|
||||
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||||
|
||||
|
|
@ -129,21 +163,28 @@
|
|||
row.className = 'event-row sev-row-' + sevRow + (isAcked ? '' : ' is-unacknowledged');
|
||||
|
||||
// Evidence slot — EVERY row is a camera slot so rows stay aligned and the stream reads as a
|
||||
// CCTV grid. When a snapshot exists it fills the slot; otherwise (or after a PIPA-retention
|
||||
// contact sheet. When a snapshot exists it fills the slot; otherwise (or after a PIPA-retention
|
||||
// purge) the slot shows an empty "무영상" frame instead of collapsing the row. The <img> goes
|
||||
// through /snap/{uuid}, which re-checks session + site scope, so the uuid grants nothing.
|
||||
var slot = document.createElement('div');
|
||||
// The slot opens the incident — the same destination as the title link below, so it is
|
||||
// skipped by Tab (a mouse shortcut, not a second stop) but kept in the accessibility tree:
|
||||
// whether an event has evidence at all is part of reading the row. The link carries the
|
||||
// label, so the image itself is decorative (alt="").
|
||||
var slot = document.createElement('a');
|
||||
slot.className = 'event-thumb-slot';
|
||||
slot.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
|
||||
slot.tabIndex = -1;
|
||||
if (ev.thumb_uuid) {
|
||||
var thumb = document.createElement('img');
|
||||
thumb.className = 'event-thumb';
|
||||
thumb.src = B + '/snap/' + encodeURIComponent(ev.thumb_uuid);
|
||||
thumb.alt = '스냅샷';
|
||||
thumb.alt = '';
|
||||
thumb.loading = 'lazy';
|
||||
thumb.onerror = function () { slot.classList.add('is-empty'); if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
|
||||
thumb.onerror = function () { markEmptySlot(slot); if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
|
||||
slot.appendChild(thumb);
|
||||
slot.setAttribute('aria-label', '스냅샷 · 상세 보기');
|
||||
} else {
|
||||
slot.classList.add('is-empty');
|
||||
markEmptySlot(slot);
|
||||
}
|
||||
row.appendChild(slot);
|
||||
|
||||
|
|
@ -169,11 +210,16 @@
|
|||
}
|
||||
meta.appendChild(type);
|
||||
|
||||
// Provenance line: WHERE (device, then camera) and WHEN. The device name is prose and the
|
||||
// ids/stamp are mono, so the three read apart on typeface alone — a "·" chain would blur
|
||||
// into the device names, which contain "·" themselves ("1공장 · 프레스 라인").
|
||||
var sub = document.createElement('div');
|
||||
sub.className = 'event-time';
|
||||
var cam = ev.camera_id ? String(ev.camera_id) : '';
|
||||
var src = deviceLabel(ev.device_id);
|
||||
if (src) sub.appendChild(spanWith('ev-src', src));
|
||||
if (ev.camera_id) sub.appendChild(spanWith('ev-cam', String(ev.camera_id)));
|
||||
var time = formatTime(ev.occurred_at);
|
||||
sub.textContent = [cam, time].filter(Boolean).join(' · ');
|
||||
if (time) sub.appendChild(spanWith('ev-at', time));
|
||||
meta.appendChild(sub);
|
||||
|
||||
row.appendChild(meta);
|
||||
|
|
@ -265,11 +311,14 @@
|
|||
icon.className = 'icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = info.icon;
|
||||
// The strip is captioned "미확인" once (see the view), so each tile carries only its tier —
|
||||
// four repetitions of the same word is noise in a strip meant to be read at a glance.
|
||||
var text = document.createElement('span');
|
||||
text.textContent = '미확인 ' + info.text;
|
||||
text.textContent = info.text;
|
||||
label.appendChild(led);
|
||||
label.appendChild(icon);
|
||||
label.appendChild(text);
|
||||
tile.setAttribute('aria-label', '미확인 ' + info.text + ' ' + n + '건');
|
||||
|
||||
var count = document.createElement('span');
|
||||
count.className = 'stat-count';
|
||||
|
|
@ -293,13 +342,16 @@
|
|||
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');
|
||||
// The camera filter is a (device, camera) PAIR, not a bare camera_id: camera ids are free-form
|
||||
// strings from the edge, so two devices may both run a "cam-01". Selecting the pair is what the
|
||||
// rail tree hands us, and it keeps one device's events out of another's filtered view.
|
||||
var filters = { severity: '', device: '', camera: '', unackedOnly: false };
|
||||
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.device && String(ev.device_id || '') !== filters.device) return false;
|
||||
if (filters.unackedOnly && isAcked(ev)) return false;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -314,44 +366,12 @@
|
|||
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
|
||||
renderTree(); // rail: camera stats + the current selection
|
||||
|
||||
var all = combinedEvents();
|
||||
refreshCameraOptions(all);
|
||||
|
||||
var shown = all.filter(matchesFilters).sort(function (a, b) {
|
||||
var aA = isAcked(a), bA = isAcked(b);
|
||||
|
|
@ -365,7 +385,12 @@
|
|||
root.removeAttribute('aria-busy');
|
||||
if (shown.length === 0) {
|
||||
var anyFilter = filters.severity || filters.camera || filters.unackedOnly;
|
||||
root.textContent = anyFilter ? '조건에 맞는 이벤트가 없습니다.' : '표시할 이벤트가 없습니다.';
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'stream-empty';
|
||||
empty.textContent = anyFilter
|
||||
? '조건에 맞는 이벤트가 없습니다. 왼쪽에서 필터를 해제하면 전체 스트림으로 돌아갑니다.'
|
||||
: '표시할 이벤트가 없습니다.';
|
||||
root.appendChild(empty);
|
||||
} else {
|
||||
var frag = document.createDocumentFragment();
|
||||
shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
|
||||
|
|
@ -456,82 +481,84 @@
|
|||
// 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 -------------------------------------------------
|
||||
// --- rail: device → camera tree ---------------------------------------------------
|
||||
// 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".
|
||||
// fps, temp, throttle, config drift); the cameras seen in the event stream nest under their
|
||||
// device. 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". DeviceTree (device-tree.js) owns the DOM.
|
||||
var devicesRoot = document.getElementById('devices-root');
|
||||
var railSummary = document.getElementById('rail-summary');
|
||||
var cameraReset = document.getElementById('camera-reset');
|
||||
var deviceList = []; // last successful /api/devices payload
|
||||
var devicesLoaded = false; // false until the first success, so "empty" ≠ "not known yet"
|
||||
var devicesStale = false; // last poll failed: show the old state, but say that it is old
|
||||
var deviceLabelSig = ''; // device_id=name pairs last painted into the stream rows
|
||||
|
||||
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) + '시간 전';
|
||||
var SEV_RANK = { info: 0, warning: 1, high: 2, critical: 3 };
|
||||
var CAM_SEP = '\u001f'; // (device, camera) key separator — a unit separator, never in an id
|
||||
|
||||
function cameraKey(deviceId, cameraId) {
|
||||
return String(deviceId || '') + CAM_SEP + String(cameraId || '');
|
||||
}
|
||||
|
||||
function renderDevices(devices) {
|
||||
if (!devicesRoot) return;
|
||||
devicesRoot.textContent = '';
|
||||
if (!devices || devices.length === 0) {
|
||||
devicesRoot.className = 'device-strip is-empty';
|
||||
devicesRoot.textContent = '등록된 장치가 없습니다.';
|
||||
return;
|
||||
// Per-camera rail stats. MEMBERSHIP and "last seen" come from everything this session has seen
|
||||
// (live window + any paged tail), so a camera keeps its place in the tree during a quiet spell
|
||||
// instead of blinking out between polls. The UNACKNOWLEDGED counts come from the live window
|
||||
// only — the same set the KPI strip and the alarm use, so the rail can never show a backlog
|
||||
// that the strip contradicts.
|
||||
function cameraStats() {
|
||||
var byKey = {}, out = [];
|
||||
combinedEvents().forEach(function (ev) {
|
||||
if (!ev.camera_id) return;
|
||||
var key = cameraKey(ev.device_id, ev.camera_id);
|
||||
var cam = byKey[key];
|
||||
if (!cam) {
|
||||
cam = byKey[key] = { key: key, device_id: String(ev.device_id || ''), camera_id: String(ev.camera_id),
|
||||
last_at: 0, unacked: 0, worst: null };
|
||||
out.push(cam);
|
||||
}
|
||||
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);
|
||||
var at = Number(ev.occurred_at) || 0;
|
||||
if (at > cam.last_at) cam.last_at = at;
|
||||
});
|
||||
devicesRoot.appendChild(frag);
|
||||
liveEvents.forEach(function (ev) {
|
||||
if (!ev.camera_id || isAcked(ev)) return;
|
||||
var cam = byKey[cameraKey(ev.device_id, ev.camera_id)];
|
||||
if (!cam) return;
|
||||
cam.unacked += 1;
|
||||
var sev = SEV_RANK.hasOwnProperty(ev.severity) ? ev.severity : 'info';
|
||||
if (cam.worst === null || SEV_RANK[sev] > SEV_RANK[cam.worst]) cam.worst = sev;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
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 renderTree() {
|
||||
if (cameraReset) cameraReset.hidden = !filters.camera;
|
||||
if (!devicesRoot || !window.DeviceTree) return;
|
||||
window.DeviceTree.render(devicesRoot, railSummary, {
|
||||
devices: deviceList,
|
||||
cameras: cameraStats(),
|
||||
now: Date.now() / 1000,
|
||||
loaded: devicesLoaded,
|
||||
stale: devicesStale,
|
||||
selectedKey: filters.camera ? cameraKey(filters.device, filters.camera) : '',
|
||||
onSelect: selectCamera
|
||||
});
|
||||
}
|
||||
|
||||
// Clicking the selected camera again clears the filter — the same affordance as the
|
||||
// "전체 보기" button, so the operator never gets stuck inside one camera.
|
||||
function selectCamera(key) {
|
||||
var pair = key.split(CAM_SEP);
|
||||
var isSame = filters.device === pair[0] && filters.camera === pair[1];
|
||||
filters.device = isSame ? '' : pair[0];
|
||||
filters.camera = isSame ? '' : pair[1];
|
||||
renderList();
|
||||
}
|
||||
|
||||
function clearCamera() {
|
||||
filters.device = '';
|
||||
filters.camera = '';
|
||||
renderList();
|
||||
}
|
||||
|
||||
function loadDevices() {
|
||||
|
|
@ -541,8 +568,26 @@
|
|||
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
|
||||
.then(function (data) {
|
||||
if (data && Array.isArray(data.devices)) {
|
||||
deviceList = data.devices;
|
||||
devicesLoaded = true;
|
||||
devicesStale = false;
|
||||
// The stream rows print the device's NAME, not its id, so a changed name set — the
|
||||
// first load above all, which lands after the first event render — has to repaint the
|
||||
// list too. Unchanged names (the steady state) only repaint the rail.
|
||||
var sig = deviceList.map(function (d) { return d.device_id + '=' + (d.name || ''); }).join('|');
|
||||
if (sig !== deviceLabelSig) { deviceLabelSig = sig; renderList(); }
|
||||
else renderTree();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
// A silently frozen panel (poll 500 / WAF-blocked while events still flow) is worse than
|
||||
// an empty one: a camera that dies afterwards would keep showing "온라인". Keep the last
|
||||
// state, but flag it. Cleared automatically by the next successful poll.
|
||||
devicesStale = true;
|
||||
renderTree();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', unlockAudio);
|
||||
|
|
@ -550,7 +595,7 @@
|
|||
// 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(); });
|
||||
if (cameraReset) cameraReset.addEventListener('click', clearCamera);
|
||||
var unackedChk = document.getElementById('unacked-only');
|
||||
if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); });
|
||||
if (moreBtn) moreBtn.addEventListener('click', loadMore);
|
||||
|
|
|
|||
248
server/public/assets/device-tree.js
Normal file
248
server/public/assets/device-tree.js
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Rail: the device → camera tree of the events console.
|
||||
*
|
||||
* A device row carries the heartbeat verdict (online/offline, fps, temp, throttle, config
|
||||
* drift); the cameras that have reported events nest under it on hairline connectors, and a
|
||||
* camera node doubles as the stream's camera filter. app.js owns the polling, the filter state
|
||||
* and the per-camera aggregation — this file owns only the rail's DOM.
|
||||
*
|
||||
* XSS-safe: every dynamic value goes through textContent / setAttribute, never innerHTML.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var LEVELS = { critical: 'lv-critical', high: 'lv-high', warning: 'lv-warning', info: 'lv-info' };
|
||||
var LEVEL_TEXT = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
|
||||
|
||||
var collapsed = {}; // device_id -> true; survives the 5s re-render
|
||||
var lastSig = null; // rendered signature; an unchanged poll must not rebuild the DOM
|
||||
var last = null; // {root, summaryEl, model} for the collapse toggle's own re-render
|
||||
|
||||
function ago(seconds) {
|
||||
if (seconds === null || seconds === undefined) return '응답 없음';
|
||||
if (seconds < 60) return Math.max(0, Math.round(seconds)) + '초 전';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + '분 전';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + '시간 전';
|
||||
return Math.floor(seconds / 86400) + '일 전';
|
||||
}
|
||||
|
||||
function deviceMeta(d) {
|
||||
// Liveness first, spelled out in words — the status dot is colour, and colour is never
|
||||
// allowed to carry state on its own (NF9). The verdict itself is the server's (computed
|
||||
// against the request clock), never re-derived here from a local clock.
|
||||
if (d.seconds_since === null || d.seconds_since === undefined) return '응답 없음';
|
||||
// fps/temp are the LAST values a dead device reported, so on an offline row they would read
|
||||
// as current telemetry ("0 fps" for a device that simply stopped talking). Liveness only.
|
||||
if (!d.online) return '오프라인 · ' + ago(d.seconds_since);
|
||||
var parts = ['온라인 · ' + ago(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) + '°');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function deviceWarn(d) {
|
||||
var w = [];
|
||||
if (d.throttle) w.push('과열 스로틀');
|
||||
if (d.config_drift) w.push('설정 미적용');
|
||||
return w.join(' · ');
|
||||
}
|
||||
|
||||
/** Devices from the heartbeat API, each with its observed cameras nested underneath. */
|
||||
function groupsOf(model) {
|
||||
var groups = [], byId = {};
|
||||
(model.devices || []).forEach(function (d) {
|
||||
var id = String(d.device_id);
|
||||
var g = { id: id, label: d.name || id, state: d.online ? 'online' : 'offline',
|
||||
meta: deviceMeta(d), warn: deviceWarn(d), cams: [] };
|
||||
byId[id] = g;
|
||||
groups.push(g);
|
||||
});
|
||||
(model.cameras || []).forEach(function (c) {
|
||||
var g = byId[c.device_id];
|
||||
if (!g) {
|
||||
// A camera whose device is missing from /api/devices — it was deleted, or it is sending
|
||||
// events under an id nobody provisioned. Show it as its own unknown-device group: an
|
||||
// unattributed camera is exactly the thing an operator must be told about, never dropped.
|
||||
g = byId[c.device_id] = { id: c.device_id, label: c.device_id || '(장치 ID 없음)',
|
||||
state: 'unknown', meta: '', warn: '장치 목록에 없음 — 등록 확인', cams: [] };
|
||||
groups.push(g);
|
||||
}
|
||||
g.cams.push({
|
||||
key: c.key,
|
||||
id: c.camera_id,
|
||||
when: c.last_at ? ago(model.now - c.last_at) : '감지 없음',
|
||||
count: c.unacked,
|
||||
level: c.worst
|
||||
});
|
||||
});
|
||||
groups.forEach(function (g) {
|
||||
// Stable order: the rail is a target the operator clicks, so a camera must not jump
|
||||
// position because its event count changed.
|
||||
g.cams.sort(function (a, b) { return a.id < b.id ? -1 : (a.id > b.id ? 1 : 0); });
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Collapsed, unless this branch holds the camera the stream is currently filtered by —
|
||||
* hiding that node would leave the operator filtered with no visible sign of by what. */
|
||||
function isCollapsed(g, selectedKey) {
|
||||
if (!collapsed[g.id]) return false;
|
||||
for (var i = 0; i < g.cams.length; i++) if (g.cams[i].key === selectedKey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function signature(groups, model) {
|
||||
var parts = [model.stale ? 'S' : '-', model.loaded ? 'L' : '-', model.selectedKey || ''];
|
||||
groups.forEach(function (g) {
|
||||
parts.push(g.id + '|' + g.label + '|' + g.state + '|' + g.meta + '|' + g.warn
|
||||
+ '|' + (isCollapsed(g, model.selectedKey) ? 'c' : 'o'));
|
||||
g.cams.forEach(function (c) { parts.push(' ' + c.id + '|' + c.when + '|' + c.count + '|' + (c.level || '')); });
|
||||
});
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text !== undefined && text !== null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
|
||||
function buildDeviceHead(g, shut) {
|
||||
var head = el('button', 'device-head');
|
||||
head.type = 'button';
|
||||
head.setAttribute('data-node', 'd' + g.id);
|
||||
head.setAttribute('aria-expanded', shut ? 'false' : 'true');
|
||||
|
||||
var line = el('span', 'device-line');
|
||||
line.appendChild(el('span', 'twisty')).setAttribute('aria-hidden', 'true');
|
||||
line.appendChild(el('span', 'dot')).setAttribute('aria-hidden', 'true');
|
||||
line.appendChild(el('span', 'device-name', g.label));
|
||||
line.appendChild(el('span', 'device-tally', g.cams.length + '대'));
|
||||
head.appendChild(line);
|
||||
|
||||
if (g.meta) head.appendChild(el('span', 'device-meta', g.meta));
|
||||
if (g.warn) head.appendChild(el('span', 'device-warn', g.warn));
|
||||
|
||||
// Spoken name: everything the row shows, in reading order and without the visual
|
||||
// separators. It must stay a superset of the visible text — a shorter label would hide
|
||||
// exactly the fields (오프라인, 과열 스로틀) that this rail exists to surface.
|
||||
var spoken = [g.label];
|
||||
if (g.meta) spoken.push(g.meta.split(' · ').join(', '));
|
||||
spoken.push('카메라 ' + g.cams.length + '대');
|
||||
if (g.warn) spoken.push(g.warn);
|
||||
head.setAttribute('aria-label', spoken.join(', '));
|
||||
return head;
|
||||
}
|
||||
|
||||
function buildCamNode(c, onSelect, selectedKey) {
|
||||
var node = el('button', 'cam-node');
|
||||
node.type = 'button';
|
||||
node.setAttribute('data-node', 'c' + c.key);
|
||||
node.setAttribute('aria-pressed', c.key === selectedKey ? 'true' : 'false');
|
||||
node.appendChild(el('span', 'cam-id', c.id));
|
||||
node.appendChild(el('span', 'cam-when', c.when));
|
||||
if (c.count > 0) {
|
||||
node.appendChild(el('span', 'cam-count ' + (LEVELS[c.level] || 'lv-info'), String(c.count)));
|
||||
}
|
||||
node.setAttribute('aria-label',
|
||||
c.id + ' 카메라, 마지막 감지 ' + c.when
|
||||
+ (c.count > 0 ? ', 미확인 ' + (LEVEL_TEXT[c.level] || '') + ' 포함 ' + c.count + '건' : ', 미확인 없음')
|
||||
+ ' — 이 카메라만 보기');
|
||||
node.addEventListener('click', function () { onSelect(c.key); });
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderSummary(summaryEl, groups, model) {
|
||||
if (!summaryEl) return;
|
||||
summaryEl.textContent = '';
|
||||
if (!model.loaded) { summaryEl.textContent = '장치 상태 확인 중…'; return; }
|
||||
// "장치" counts REGISTERED devices only — an unknown-device group is not a device the
|
||||
// operator has, it is a discrepancy, and it gets its own count rather than padding this one.
|
||||
var cams = 0, known = 0, down = 0, unknown = 0;
|
||||
groups.forEach(function (g) {
|
||||
cams += g.cams.length;
|
||||
if (g.state === 'unknown') { unknown += 1; return; }
|
||||
known += 1;
|
||||
if (g.state === 'offline') down += 1;
|
||||
});
|
||||
summaryEl.appendChild(document.createTextNode('장치 ' + known + ' · 카메라 ' + cams));
|
||||
if (down > 0) summaryEl.appendChild(el('span', 'is-down', ' · 오프라인 ' + down));
|
||||
if (unknown > 0) summaryEl.appendChild(el('span', 'is-down', ' · 미등록 ' + unknown));
|
||||
}
|
||||
|
||||
function render(root, summaryEl, model) {
|
||||
if (!root) return;
|
||||
last = { root: root, summaryEl: summaryEl, model: model };
|
||||
|
||||
var groups = groupsOf(model);
|
||||
var sig = signature(groups, model);
|
||||
if (sig === lastSig) return; // nothing visible changed: keep the DOM, and the focus in it
|
||||
lastSig = sig;
|
||||
|
||||
// The tree is rebuilt, not patched, so a node the operator is tabbing through would lose
|
||||
// focus on any change. Remember which node held it and hand focus back to its replacement.
|
||||
var active = document.activeElement;
|
||||
var refocus = (active && root.contains(active)) ? active.getAttribute('data-node') : null;
|
||||
|
||||
renderSummary(summaryEl, groups, model);
|
||||
|
||||
root.textContent = '';
|
||||
root.className = 'device-tree' + (model.stale ? ' is-stale' : '') + (groups.length === 0 ? ' is-empty' : '');
|
||||
if (model.stale) {
|
||||
// A frozen panel is worse than an empty one: a camera that dies while the poll is down
|
||||
// would keep showing "온라인". Say the state is old instead of quietly implying it is live.
|
||||
root.appendChild(el('div', 'device-stale-note', '상태 갱신 지연 — 마지막으로 확인된 상태입니다'));
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
root.appendChild(document.createTextNode(
|
||||
model.loaded ? '등록된 장치가 없습니다. 관리자가 장치를 등록하면 여기에 표시됩니다.' : '불러오는 중…'));
|
||||
return;
|
||||
}
|
||||
|
||||
var frag = document.createDocumentFragment();
|
||||
groups.forEach(function (g) {
|
||||
var shut = isCollapsed(g, model.selectedKey);
|
||||
var node = el('div', 'device-node'
|
||||
+ (g.state === 'offline' ? ' is-offline' : '')
|
||||
+ (g.state === 'unknown' ? ' is-unknown' : '')
|
||||
+ (shut ? ' is-collapsed' : ''));
|
||||
var head = buildDeviceHead(g, shut);
|
||||
head.addEventListener('click', function () { toggle(g.id); });
|
||||
node.appendChild(head);
|
||||
|
||||
if (!shut) {
|
||||
if (g.cams.length === 0) {
|
||||
node.appendChild(el('div', 'cam-empty', '수신된 감지 이벤트 없음'));
|
||||
} else {
|
||||
var list = el('ul', 'cam-list');
|
||||
g.cams.forEach(function (c) {
|
||||
var item = el('li', 'cam-item');
|
||||
item.appendChild(buildCamNode(c, model.onSelect, model.selectedKey));
|
||||
list.appendChild(item);
|
||||
});
|
||||
node.appendChild(list);
|
||||
}
|
||||
}
|
||||
frag.appendChild(node);
|
||||
});
|
||||
root.appendChild(frag);
|
||||
|
||||
if (refocus) {
|
||||
var nodes = root.querySelectorAll('[data-node]');
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].getAttribute('data-node') === refocus) { nodes[i].focus(); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(deviceId) {
|
||||
if (collapsed[deviceId]) delete collapsed[deviceId];
|
||||
else collapsed[deviceId] = true;
|
||||
if (!last) return;
|
||||
lastSig = null; // collapse state is part of the signature; force the rebuild
|
||||
render(last.root, last.summaryEl, last.model);
|
||||
}
|
||||
|
||||
window.DeviceTree = { render: render };
|
||||
})();
|
||||
|
|
@ -5,8 +5,14 @@ render_header('이벤트');
|
|||
<div class="console">
|
||||
<aside class="rail">
|
||||
<section class="rail-block">
|
||||
<div class="rail-head">
|
||||
<h2 class="rail-title">장치 · 카메라</h2>
|
||||
<div id="devices-root" class="device-strip" aria-live="polite" aria-label="장치·카메라 상태">불러오는 중…</div>
|
||||
<button type="button" id="camera-reset" class="rail-reset" hidden>전체 보기</button>
|
||||
</div>
|
||||
<!-- The tree itself is NOT a live region: it is rebuilt on a 5s poll and would be re-read
|
||||
in full every time. This one-line summary carries the announcement instead. -->
|
||||
<p id="rail-summary" class="rail-sum" aria-live="polite">장치 상태 확인 중…</p>
|
||||
<div id="devices-root" class="device-tree" aria-label="장치·카메라 트리">불러오는 중…</div>
|
||||
</section>
|
||||
<section class="rail-block">
|
||||
<h2 class="rail-title">필터</h2>
|
||||
|
|
@ -20,9 +26,6 @@ render_header('이벤트');
|
|||
<option value="info">정보</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">카메라
|
||||
<select id="camera-filter" aria-label="카메라 필터"><option value="">전체 카메라</option></select>
|
||||
</label>
|
||||
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만 보기</label>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -33,7 +36,10 @@ render_header('이벤트');
|
|||
<h1>감지 스트림</h1>
|
||||
<span class="watch" id="watch-status" aria-live="polite">실시간 감시 중</span>
|
||||
</div>
|
||||
<div class="kpi-wrap">
|
||||
<span class="kpi-cap">미확인</span>
|
||||
<div id="events-status" class="kpi-strip" aria-label="미확인 이벤트 현황"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="events-root" class="event-list" aria-live="polite" aria-busy="true">
|
||||
<div class="stream-empty">감지 이벤트를 불러오는 중…</div>
|
||||
|
|
@ -41,6 +47,7 @@ render_header('이벤트');
|
|||
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="<?= view_url('/assets/device-tree.js') ?>"></script>
|
||||
<script src="<?= view_url('/assets/app.js') ?>"></script>
|
||||
<?php
|
||||
render_footer();
|
||||
|
|
|
|||
Loading…
Reference in a new issue