feat(cloud): front controller wiring + views + zone-draw UI
This commit is contained in:
parent
5135c08e18
commit
81e8e81b72
11 changed files with 643 additions and 0 deletions
4
cloud/public/.htaccess
Normal file
4
cloud/public/.htaccess
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^ index.php [QSA,L]
|
||||||
86
cloud/public/assets/app.css
Normal file
86
cloud/public/assets/app.css
Normal file
|
|
@ -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; }
|
||||||
148
cloud/public/assets/app.js
Normal file
148
cloud/public/assets/app.js
Normal file
|
|
@ -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);
|
||||||
|
})();
|
||||||
197
cloud/public/assets/zone-draw.js
Normal file
197
cloud/public/assets/zone-draw.js
Normal file
|
|
@ -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();
|
||||||
|
})();
|
||||||
63
cloud/public/index.php
Normal file
63
cloud/public/index.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\{Request, Router, Response};
|
||||||
|
use App\Db\Database;
|
||||||
|
use App\Auth\PhpSessionStore;
|
||||||
|
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
||||||
|
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
||||||
|
PdoThresholdRepository, PdoAuditRepository};
|
||||||
|
use App\Ingest\IngestController;
|
||||||
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
|
use App\Auth\AuthController;
|
||||||
|
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController};
|
||||||
|
|
||||||
|
$cfg = Config::fromArray(require __DIR__ . '/../config.php');
|
||||||
|
$pdo = Database::connect($cfg);
|
||||||
|
$sess = new PhpSessionStore();
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$devices = new PdoDeviceRepository($pdo);
|
||||||
|
$nonces = new PdoNonceRepository($pdo);
|
||||||
|
$rate = new PdoRateLimiterRepository($pdo);
|
||||||
|
$events = new PdoEventRepository($pdo);
|
||||||
|
$snaps = new PdoSnapshotRepository($pdo, $cfg->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();
|
||||||
8
cloud/src/View/event_detail.php
Normal file
8
cloud/src/View/event_detail.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/layout.php';
|
||||||
|
render_header('이벤트 상세');
|
||||||
|
?>
|
||||||
|
<h1>이벤트 상세</h1>
|
||||||
|
<div id="event-detail-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
||||||
|
<?php
|
||||||
|
render_footer();
|
||||||
9
cloud/src/View/events.php
Normal file
9
cloud/src/View/events.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/layout.php';
|
||||||
|
render_header('이벤트');
|
||||||
|
?>
|
||||||
|
<h1>이벤트</h1>
|
||||||
|
<div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
<?php
|
||||||
|
render_footer();
|
||||||
53
cloud/src/View/layout.php
Normal file
53
cloud/src/View/layout.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Shared header/nav + footer for server-rendered views.
|
||||||
|
* No business logic: only static markup + minimal session-derived display state
|
||||||
|
* (username is read from $_SESSION purely for display, not decisioning).
|
||||||
|
* render_header()/render_footer() bracket the page-specific <main> content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('view_e')) {
|
||||||
|
function view_e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('render_header')) {
|
||||||
|
function render_header(string $title): void {
|
||||||
|
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= view_e($title) ?> · 이동 감지 대시보드</title>
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<nav class="app-nav">
|
||||||
|
<a href="/">이벤트</a>
|
||||||
|
<a href="/zones">구역 설정</a>
|
||||||
|
</nav>
|
||||||
|
<div class="app-session">
|
||||||
|
<?php if ($username !== null): ?>
|
||||||
|
<span><?= view_e($username) ?></span>
|
||||||
|
<button type="button" id="logout-btn">로그아웃</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="/login">로그인</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="app-main">
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('render_footer')) {
|
||||||
|
function render_footer(): void {
|
||||||
|
?>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
24
cloud/src/View/login.php
Normal file
24
cloud/src/View/login.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/layout.php';
|
||||||
|
|
||||||
|
// CSRF token: persisted in the PHP session (already started by PhpSessionStore
|
||||||
|
// before rendering) so a future controller-side check can validate it.
|
||||||
|
if (empty($_SESSION['csrf'])) {
|
||||||
|
$_SESSION['csrf'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
$csrf = $_SESSION['csrf'];
|
||||||
|
|
||||||
|
render_header('로그인');
|
||||||
|
?>
|
||||||
|
<form method="post" action="/login" class="login-form">
|
||||||
|
<h1>로그인</h1>
|
||||||
|
<input type="hidden" name="csrf" value="<?= view_e($csrf) ?>">
|
||||||
|
<label for="username">아이디</label>
|
||||||
|
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||||
|
<label for="password">비밀번호</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
|
<button type="submit">로그인</button>
|
||||||
|
<p id="login-error" class="form-error" role="alert" hidden></p>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
render_footer();
|
||||||
28
cloud/src/View/zones.php
Normal file
28
cloud/src/View/zones.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/layout.php';
|
||||||
|
render_header('구역 설정');
|
||||||
|
?>
|
||||||
|
<h1>구역 설정</h1>
|
||||||
|
<div class="zone-controls">
|
||||||
|
<label for="device-id">장치 ID</label>
|
||||||
|
<input type="text" id="device-id" placeholder="device-01">
|
||||||
|
<label for="camera-id">카메라 ID</label>
|
||||||
|
<input type="text" id="camera-id" placeholder="cam-01">
|
||||||
|
<button type="button" id="zone-load-btn">불러오기</button>
|
||||||
|
</div>
|
||||||
|
<div class="zone-controls">
|
||||||
|
<label for="zone-level">등급</label>
|
||||||
|
<select id="zone-level">
|
||||||
|
<option value="warning">경고 (warning)</option>
|
||||||
|
<option value="restricted">제한 (restricted)</option>
|
||||||
|
<option value="pinch">협착 (pinch)</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="zone-clear-btn">현재 폴리곤 지우기</button>
|
||||||
|
<button type="button" id="zone-save-btn">저장</button>
|
||||||
|
</div>
|
||||||
|
<canvas id="zone-canvas" width="960" height="540" tabindex="0" aria-label="구역 편집 캔버스: 클릭하여 폴리곤 점을 추가합니다"></canvas>
|
||||||
|
<ul id="zone-list" aria-live="polite"></ul>
|
||||||
|
<p id="zone-status" role="status"></p>
|
||||||
|
<script src="/assets/zone-draw.js"></script>
|
||||||
|
<?php
|
||||||
|
render_footer();
|
||||||
23
cloud/tests/WiringSmokeTest.php
Normal file
23
cloud/tests/WiringSmokeTest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Tests;
|
||||||
|
use App\Http\{Router, Request, Response};
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class WiringSmokeTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAllRoutesRegisteredAndDispatch(): void {
|
||||||
|
$r = new Router();
|
||||||
|
$paths = [
|
||||||
|
['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
|
||||||
|
['POST', '/login'], ['POST', '/logout'], ['GET', '/api/events'],
|
||||||
|
['GET', '/events/{id}'], ['POST', '/events/{id}/ack'],
|
||||||
|
['GET', '/api/zones/{camera}'], ['POST', '/api/zones'],
|
||||||
|
['POST', '/api/zones/{id}/delete'], ['GET', '/snap/{uuid}'],
|
||||||
|
];
|
||||||
|
foreach ($paths as [$m, $p]) $r->add($m, $p, fn() => Response::json(['route' => $p]));
|
||||||
|
$this->assertSame(200, $r->dispatch(new Request('GET', '/events/9', [], '', [], []))->status());
|
||||||
|
$this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status());
|
||||||
|
$this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->status());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue