feat(cloud): front controller wiring + views + zone-draw UI

This commit is contained in:
유창욱 2026-07-12 01:24:42 +09:00
parent 5135c08e18
commit 81e8e81b72
11 changed files with 643 additions and 0 deletions

4
cloud/public/.htaccess Normal file
View file

@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

View 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
View 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);
})();

View 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
View 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();

View 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();

View 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
View 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
View 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
View 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();

View 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());
}
}