diff --git a/cloud/public/.htaccess b/cloud/public/.htaccess new file mode 100644 index 0000000..66ef8f6 --- /dev/null +++ b/cloud/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/cloud/public/assets/app.css b/cloud/public/assets/app.css new file mode 100644 index 0000000..733ec6d --- /dev/null +++ b/cloud/public/assets/app.css @@ -0,0 +1,86 @@ +/* Base layout ------------------------------------------------------- */ +:root { + color-scheme: light; + --border: #d1d5db; + --text: #1f2937; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--text); + background: #f9fafb; +} +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: #111827; + color: #fff; +} +.app-nav a { color: #fff; margin-right: 1rem; text-decoration: none; } +.app-nav a:hover, .app-nav a:focus { text-decoration: underline; } +.app-session { display: flex; align-items: center; gap: 0.5rem; } +.app-session a { color: #fff; } +.app-main { padding: 1rem; max-width: 960px; margin: 0 auto; } + +.login-form, .zone-controls { display: flex; flex-direction: column; gap: 0.5rem; max-width: 320px; } +.zone-controls { flex-direction: row; align-items: end; flex-wrap: wrap; max-width: none; } +.zone-controls label { display: flex; flex-direction: column; font-size: 0.85rem; } +.form-error { color: #991b1b; } + +#zone-canvas { + border: 1px solid var(--border); + background: #111827 center / contain no-repeat; + cursor: crosshair; + max-width: 100%; +} + +button { + cursor: pointer; + padding: 0.4rem 0.8rem; + border: 1px solid var(--border); + border-radius: 4px; + background: #fff; +} +button:hover { background: #f3f4f6; } + +/* Event list ---------------------------------------------------------- */ +.event-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 0.5rem; + background: #fff; +} +.event-row.is-unacknowledged { border-left: 4px solid #4b5563; } +.event-meta { flex: 1; } +.event-meta .event-type { font-weight: 600; } +.event-meta .event-time { color: #4b5563; font-size: 0.85rem; } + +/* Severity badges (NF9): color-blind-safe — icon + text label + color. + Never rely on color alone. Pastel background + dark text pairs are + chosen for WCAG-AA contrast (>= 4.5:1 for normal-size text). */ +.severity-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-weight: 700; + font-size: 0.85rem; + white-space: nowrap; +} +.severity-badge .icon { font-size: 1rem; line-height: 1; } + +.sev-critical { background: #fee2e2; color: #991b1b; border: 1px solid #991b1b; } +.sev-high { background: #ffedd5; color: #9a3412; border: 1px solid #9a3412; } +.sev-warning { background: #fef9c3; color: #854d0e; border: 1px solid #854d0e; } +.sev-info { background: #e5e7eb; color: #374151; border: 1px solid #374151; } + +.ack-btn { margin-left: auto; } +.ack-btn:disabled { opacity: 0.6; cursor: default; } diff --git a/cloud/public/assets/app.js b/cloud/public/assets/app.js new file mode 100644 index 0000000..10e55cc --- /dev/null +++ b/cloud/public/assets/app.js @@ -0,0 +1,148 @@ +/** + * Events dashboard: polls GET /api/events every 5s, renders the list with + * severity text+icon+color (never color alone, per NF9), pins unacknowledged + * events to the top, and wires the acknowledge button to POST /events/{id}/ack. + * + * XSS-safe: all dynamic values are set via textContent, never innerHTML. + */ +(function () { + 'use strict'; + + var POLL_INTERVAL_MS = 5000; + + var SEVERITY_LABELS = { + critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔ + high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠ + warning: { icon: '△', text: '경고', cls: 'sev-warning' }, // △ + info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } // ℹ + }; + + var root = document.getElementById('events-root'); + if (!root) return; + + function severityInfo(sev) { + return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' }; + } + + 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'); + } + + function buildBadge(sev) { + var info = severityInfo(sev); + var badge = document.createElement('span'); + badge.className = 'severity-badge ' + info.cls; + var icon = document.createElement('span'); + icon.className = 'icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = info.icon; + var label = document.createElement('span'); + label.className = 'label'; + label.textContent = info.text; + badge.appendChild(icon); + badge.appendChild(label); + return badge; + } + + function buildRow(ev) { + var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; + + var row = document.createElement('div'); + row.className = 'event-row' + (isAcked ? '' : ' is-unacknowledged'); + + row.appendChild(buildBadge(ev.severity)); + + var meta = document.createElement('div'); + meta.className = 'event-meta'; + + var type = document.createElement('div'); + type.className = 'event-type'; + type.textContent = String(ev.event_type || ''); + meta.appendChild(type); + + var sub = document.createElement('div'); + sub.className = 'event-time'; + var cam = ev.camera_id ? String(ev.camera_id) : ''; + var time = formatTime(ev.occurred_at); + sub.textContent = [cam, time].filter(Boolean).join(' · '); + meta.appendChild(sub); + + row.appendChild(meta); + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ack-btn'; + if (isAcked) { + btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')'; + btn.disabled = true; + } else { + btn.textContent = '확인'; + btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); }); + } + row.appendChild(btn); + + return row; + } + + function acknowledge(eventId, btn) { + btn.disabled = true; + fetch('/events/' + encodeURIComponent(eventId) + '/ack', { method: 'POST' }) + .then(function (res) { + if (!res.ok) throw new Error('ack_failed:' + res.status); + return loadEvents(); + }) + .catch(function () { + btn.disabled = false; + }); + } + + function render(events) { + var sorted = events.slice().sort(function (a, b) { + var aAcked = a.acknowledged_by !== null && a.acknowledged_by !== undefined && a.acknowledged_by !== ''; + var bAcked = b.acknowledged_by !== null && b.acknowledged_by !== undefined && b.acknowledged_by !== ''; + if (aAcked === bAcked) return 0; + return aAcked ? 1 : -1; // unacknowledged first + }); + + root.textContent = ''; + root.removeAttribute('aria-busy'); + if (sorted.length === 0) { + root.textContent = '표시할 이벤트가 없습니다.'; + return; + } + var frag = document.createDocumentFragment(); + sorted.forEach(function (ev) { frag.appendChild(buildRow(ev)); }); + root.appendChild(frag); + } + + function loadEvents() { + return fetch('/api/events', { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { + window.location.href = '/login'; + return null; + } + if (!res.ok) throw new Error('list_failed:' + res.status); + return res.json(); + }) + .then(function (data) { + if (data && Array.isArray(data.events)) render(data.events); + }) + .catch(function () { + // Leave the last rendered state on transient poll failure. + }); + } + + var logoutBtn = document.getElementById('logout-btn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', function () { + fetch('/logout', { method: 'POST' }).then(function () { window.location.href = '/login'; }); + }); + } + + loadEvents(); + setInterval(loadEvents, POLL_INTERVAL_MS); +})(); diff --git a/cloud/public/assets/zone-draw.js b/cloud/public/assets/zone-draw.js new file mode 100644 index 0000000..ecd3e27 --- /dev/null +++ b/cloud/public/assets/zone-draw.js @@ -0,0 +1,197 @@ +/** + * Zone editor: canvas-based polygon drawing for camera zones. + * - Click the canvas to add points to the polygon currently being drawn. + * - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones. + * - "Load" fetches existing zones for the camera via GET /api/zones/{camera} + * and redraws them; each has a Delete button -> POST /api/zones/{id}/delete. + * - If a `?snapshot={uuid}` query param is present, that image (served via + * the authenticated /snap/{uuid} proxy) is drawn as the canvas background; + * otherwise the canvas keeps its plain dark backdrop. + * + * XSS-safe: all dynamic text is set via textContent, never innerHTML. + */ +(function () { + 'use strict'; + + var canvas = document.getElementById('zone-canvas'); + if (!canvas) return; + var ctx = canvas.getContext('2d'); + + var deviceInput = document.getElementById('device-id'); + var cameraInput = document.getElementById('camera-id'); + var levelSelect = document.getElementById('zone-level'); + var loadBtn = document.getElementById('zone-load-btn'); + var clearBtn = document.getElementById('zone-clear-btn'); + var saveBtn = document.getElementById('zone-save-btn'); + var listEl = document.getElementById('zone-list'); + var statusEl = document.getElementById('zone-status'); + + var LEVEL_STYLE = { + warning: { stroke: '#eab308', label: '경고' }, + restricted: { stroke: '#dc2626', label: '제한' }, + pinch: { stroke: '#a855f7', label: '협착' } + }; + + var backgroundImage = null; + var existingZones = []; // [{zone_id, level, points: [[x,y],...]}] + var currentPoints = []; // in-progress polygon being drawn + + function setStatus(msg) { + statusEl.textContent = msg; + } + + function parsePolygon(zone) { + try { + var pts = JSON.parse(zone.polygon_json || '[]'); + return Array.isArray(pts) ? pts : []; + } catch (e) { + return []; + } + } + + function drawPolygon(points, strokeStyle, label, closed) { + if (points.length === 0) return; + ctx.strokeStyle = strokeStyle; + ctx.fillStyle = strokeStyle; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]); + if (closed && points.length > 2) ctx.closePath(); + ctx.stroke(); + points.forEach(function (p) { + ctx.beginPath(); + ctx.arc(p[0], p[1], 3, 0, Math.PI * 2); + ctx.fill(); + }); + if (label) { + ctx.font = '14px sans-serif'; + ctx.fillText(label, points[0][0] + 6, points[0][1] - 6); + } + } + + function redraw() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (backgroundImage) { + ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height); + } + existingZones.forEach(function (z) { + var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level }; + drawPolygon(z.points, style.stroke, style.label, true); + }); + var current = LEVEL_STYLE[levelSelect.value] || { stroke: '#38bdf8', label: '' }; + drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', false); + } + + function renderZoneList() { + listEl.textContent = ''; + existingZones.forEach(function (z) { + var li = document.createElement('li'); + var style = LEVEL_STYLE[z.level] || { label: z.level }; + + var label = document.createElement('span'); + label.textContent = style.label + ' (' + z.points.length + '점)'; + li.appendChild(label); + + var del = document.createElement('button'); + del.type = 'button'; + del.textContent = '삭제'; + del.addEventListener('click', function () { deleteZone(z.zone_id); }); + li.appendChild(del); + + listEl.appendChild(li); + }); + } + + function loadBackground(uuid) { + if (!uuid) { backgroundImage = null; redraw(); return; } + var img = new Image(); + img.onload = function () { backgroundImage = img; redraw(); }; + img.onerror = function () { backgroundImage = null; redraw(); }; + img.src = '/snap/' + encodeURIComponent(uuid); + } + + function loadZones() { + var camera = cameraInput.value.trim(); + if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + setStatus('불러오는 중...'); + fetch('/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { window.location.href = '/login'; return null; } + if (!res.ok) throw new Error('load_failed:' + res.status); + return res.json(); + }) + .then(function (data) { + if (!data) return; + existingZones = (data.zones || []).map(function (z) { + return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z) }; + }); + currentPoints = []; + renderZoneList(); + redraw(); + setStatus('구역 ' + existingZones.length + '개를 불러왔습니다.'); + }) + .catch(function () { setStatus('불러오기에 실패했습니다.'); }); + } + + function saveZone() { + var camera = cameraInput.value.trim(); + var device = deviceInput.value.trim(); + if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; } + setStatus('저장 중...'); + fetch('/api/zones', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_id: device, + camera_id: camera, + level: levelSelect.value, + polygon: currentPoints + }) + }) + .then(function (res) { + if (res.status === 401) { window.location.href = '/login'; return null; } + if (!res.ok) throw new Error('save_failed:' + res.status); + return res.json(); + }) + .then(function (data) { + if (!data) return; + currentPoints = []; + setStatus('저장되었습니다.'); + loadZones(); + }) + .catch(function () { setStatus('저장에 실패했습니다.'); }); + } + + function deleteZone(zoneId) { + fetch('/api/zones/' + encodeURIComponent(zoneId) + '/delete', { method: 'POST' }) + .then(function (res) { + if (res.status === 401) { window.location.href = '/login'; return; } + if (!res.ok) throw new Error('delete_failed:' + res.status); + loadZones(); + }) + .catch(function () { setStatus('삭제에 실패했습니다.'); }); + } + + canvas.addEventListener('click', function (evt) { + var rect = canvas.getBoundingClientRect(); + var scaleX = canvas.width / rect.width; + var scaleY = canvas.height / rect.height; + var x = Math.round((evt.clientX - rect.left) * scaleX); + var y = Math.round((evt.clientY - rect.top) * scaleY); + currentPoints.push([x, y]); + redraw(); + }); + + levelSelect.addEventListener('change', redraw); + loadBtn.addEventListener('click', loadZones); + clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); }); + saveBtn.addEventListener('click', saveZone); + + var params = new URLSearchParams(window.location.search); + if (params.get('camera')) cameraInput.value = params.get('camera'); + if (params.get('device')) deviceInput.value = params.get('device'); + loadBackground(params.get('snapshot')); + if (cameraInput.value) loadZones(); +})(); diff --git a/cloud/public/index.php b/cloud/public/index.php new file mode 100644 index 0000000..d3eb839 --- /dev/null +++ b/cloud/public/index.php @@ -0,0 +1,63 @@ +snapshotDir()); +$users = new PdoUserRepository($pdo); +$zones = new PdoZoneRepository($pdo); +$thr = new PdoThresholdRepository($pdo); +$audit = new PdoAuditRepository($pdo); + +$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); +$config = new ConfigController($devices, $zones, $thr); +$heartbeat = new HeartbeatController($cfg, $devices); +$auth = new AuthController($users, $audit); +$list = new EventListController($events, $sess); +$detail = new EventDetailController($events, $snaps, $audit, $sess); +$proxy = new SnapshotProxy($snaps, $sess); +$zoneCtl = new ZoneConfigController($zones, $audit, $sess); + +$render = function (string $view): Response { + ob_start(); require __DIR__ . '/../src/View/' . $view . '.php'; + return Response::text((string)ob_get_clean()); +}; + +$r = new Router(); +$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now)); +$r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now)); +$r->add('GET', '/api/config', fn(Request $q) => $config->get($q)); +$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now)); +$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); +$r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); +$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); +$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); +$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); +$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); +$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p)); +$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p)); +$r->add('GET', '/', fn() => $render('events')); +$r->add('GET', '/login', fn() => $render('login')); +$r->add('GET', '/zones', fn() => $render('zones')); + +$r->dispatch(Request::fromGlobals())->send(); diff --git a/cloud/src/View/event_detail.php b/cloud/src/View/event_detail.php new file mode 100644 index 0000000..952d4aa --- /dev/null +++ b/cloud/src/View/event_detail.php @@ -0,0 +1,8 @@ + +