Only SameSite=Lax protected the acknowledge and zone save/delete endpoints, even though a Csrf helper and mintable token already existed. Add GET /api/csrf (CsrfController::token) to mint/return a per-session token, check it (auth -> rbac -> csrf -> mutate) in EventDetailController::acknowledge and ZoneConfigController::save/delete, and wire app.js/zone-draw.js to fetch the token on load and send it as X-Csrf-Token on every POST.
160 lines
5.2 KiB
JavaScript
160 lines
5.2 KiB
JavaScript
/**
|
||
* 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;
|
||
|
||
var csrfToken = null;
|
||
function loadCsrfToken() {
|
||
return fetch('/api/csrf', { headers: { Accept: 'application/json' } })
|
||
.then(function (res) { return res.ok ? res.json() : null; })
|
||
.then(function (data) { if (data && data.token) csrfToken = data.token; })
|
||
.catch(function () { /* ack will fail with 403 and re-enable the button */ });
|
||
}
|
||
|
||
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',
|
||
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||
})
|
||
.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'; });
|
||
});
|
||
}
|
||
|
||
loadCsrfToken();
|
||
loadEvents();
|
||
setInterval(loadEvents, POLL_INTERVAL_MS);
|
||
})();
|