move_detect/server/public/assets/compliance.js
유창욱 43a3d069a3 fix(audit): 1.1.0 3차 재감사 — 확정 결함 14건 전량 해소
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증된 4건은 감사 문서에
REFUTED 로 남겼다(히트맵 왜곡·에스컬레이션 전파·스트림 필터 고정·설정 미배선).

CRITICAL 2건 — 둘 다 이번 기능이 만든 회귀다:

R3-1 dispatch_alerts.php 의 장치 건전성 단계가 try 밖에 있었다. 이 단계만
move_devices·move_device_metrics 를 읽는데 통보 경로는 그 두 표를 건드리지
않는다 — 즉 마이그레이션 미적용·커넥션 한도·lock timeout 이 원래대로면 정상
발송됐을 실행을 통째로 죽인다. 원인이 지속형이라 매분 반복되고 콘솔은 멀쩡해
보인다. 1·2단계를 각각 격리했고, 3단계의 exit(1) 도 없앴다 — 거기 도달하는
예외는 저장소 장애이고 4단계 재시도가 바로 그것에서 회복하려고 존재한다.

R3-2 간헐 고장에 감지기가 양방향으로 무너졌다. edge/daemon.py:105 는 성공
프레임 하나에 streak 를 지워 cameras_erroring 를 0 으로 되돌린다. 정상 표본
하나로 저하 구간을 끊으니 (a) 두 시간 고장에도 sustain 을 못 넘어 아무것도
못 올리고 (b) 끊긴 구간마다 새 앵커가 생겨 6시간에 54건을 쏟아냈다. 엣지
낙상 감지기의 recover_grace_s 와 같은 규율로 짧은 정상을 흡수하고, uuid 에
재고지 버킷을 더해 주기당 1건으로 묶었다(같은 입력 ≤7건). 기존 16개 테스트가
전부 단조 시퀀스라 이 결함이 CI를 통과했다 — 그래서 플래핑·폭주·회복·미래시각
테스트를 추가했다.

HIGH 5건: 센티넬이 메일·웹훅으로 회사 밖에 나갔고(라벨을 app.js 에만 넣은 것이
원인), 사건 상세 링크로 존재하지 않는 카메라에 구역을 저장할 수 있었고, 인제스트가
센티넬을 예약값으로 안 막아 엣지가 그 camera_id 로 올린 진짜 낙상이 화면에서
더 안 보이게 됐고, BUILD-INFO 가 릴리스 이전 커밋을 가리켰고, 실제 자격증명
파일이 납품 폴더 안에 있었다.

값이 흩어진 것이 센티넬 결함의 근본 원인이라 단일 출처를 만들었다:
App\DeviceScope(서버) + assets/device-scope.js(프런트) + PdoEventRepository 의
NOT_DEVICE SQL 조각. 셋이 어긋나면 DeviceScopeTest 가 잡는다.

MEDIUM 7건: 카메라별 집계 3곳 제외, 설정 점검 오판정, 개선조치 대상 거부,
라벨 CSV 제외, 낡은 지표로 판정 금지, 미래 시각 표본 제거, 죽은 노드 재고지.

검증: phpunit 250 passed (신규 10) · pytest 는 dist 재생성 후 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 00:06:35 +09:00

420 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 안전 관리: 교대 인수인계 · 정비 억제 · 위험성평가 · 설비 성능 추이.
*
* XSS-safe: 모든 동적 값은 textContent. 그래프는 인라인 SVG 를 DOM API 로 조립한다
* (문자열 innerHTML 금지 — 프로젝트 규약이자, 카메라 이름 같은 값이 마크업에 섞이는 것을 막는다).
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var root = document.querySelector('.compliance-page');
if (!root) return;
var csrf = null;
var TYPE_LABELS = {
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
};
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 pad2(n) { return n < 10 ? '0' + n : String(n); }
function fmtDate(ts) {
var d = new Date(Number(ts) * 1000);
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function fmtStamp(ts) {
var d = new Date(Number(ts) * 1000);
return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
function fmtLeft(seconds) {
if (seconds <= 0) return '만료됨';
if (seconds < 3600) return Math.ceil(seconds / 60) + '분 남음';
return Math.floor(seconds / 3600) + '시간 ' + Math.round((seconds % 3600) / 60) + '분 남음';
}
function loadCsrf() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.token) csrf = d.token; })
.catch(function () {});
}
function post(path, body) {
return fetch(B + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' },
body: JSON.stringify(body || {})
}).then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
return r.json().then(function (d) { return { ok: r.ok, data: d }; });
});
}
function getJson(path) {
return fetch(B + path, { headers: { Accept: 'application/json' } })
.then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
return r.ok ? r.json() : null;
});
}
var ERRORS = {
reason_required: '사유를 입력하세요 — 사유 없는 억제는 나중에 아무도 해석할 수 없습니다.',
invalid_window: '기간은 1분 이상 8시간(480분) 이하여야 합니다.',
invalid_scope_id: '대상을 선택하세요.',
unknown_scope_id: '등록되지 않은 대상입니다.',
invalid_rating: '강도·빈도는 1~5 사이여야 합니다.',
invalid_hazard: '위험요인을 입력하세요.',
invalid_control: '현재 대책을 입력하세요.',
forbidden: '권한이 없습니다.',
csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.'
};
function errText(c) { return ERRORS[c] || ('처리에 실패했습니다 (' + String(c) + ')'); }
// ============================================================ 교대 인수인계
var pendingBox = document.getElementById('handover-pending');
var handoverList = document.getElementById('handover-list');
function renderHandovers(d) {
var p = d.pending || {};
pendingBox.textContent = '';
var head = el('div', 'handover-head');
head.appendChild(el('strong', null, '인수 대상 구간'));
head.appendChild(el('span', 'handover-range',
fmtStamp(p.period_start) + ' ~ ' + fmtStamp(p.period_end)));
pendingBox.appendChild(head);
var stats = el('div', 'handover-stats');
var a = el('div', 'handover-stat' + (p.unacked_count > 0 ? ' is-open' : ''));
a.appendChild(el('strong', null, String(p.unacked_count || 0)));
a.appendChild(el('span', null, '미확인'));
stats.appendChild(a);
var b = el('div', 'handover-stat' + (p.severe_count > 0 ? ' is-severe' : ''));
b.appendChild(el('strong', null, String(p.severe_count || 0)));
b.appendChild(el('span', null, '이 중 중대'));
stats.appendChild(b);
pendingBox.appendChild(stats);
if (p.from_user) {
pendingBox.appendChild(el('p', 'handover-from', '인계자: ' + p.from_user));
}
handoverList.textContent = '';
var list = d.handovers || [];
if (!list.length) {
handoverList.appendChild(el('p', 'admin-empty', '기록된 인수인계가 없습니다.'));
return;
}
list.forEach(function (h) {
var row = el('div', 'handover-row');
row.appendChild(el('span', 'handover-when', fmtStamp(h.created_at)));
row.appendChild(el('span', 'handover-who',
(h.from_user ? h.from_user + ' → ' : '') + h.to_user));
row.appendChild(el('span', 'handover-count',
'미확인 ' + h.unacked_count + ' (중대 ' + h.severe_count + ')'));
if (h.note) row.appendChild(el('span', 'handover-note', h.note));
handoverList.appendChild(row);
});
}
function loadHandovers() {
getJson('/api/handovers').then(function (d) { if (d) renderHandovers(d); })
.catch(function () { pendingBox.textContent = '인수인계 정보를 불러오지 못했습니다.'; });
}
var handoverForm = document.getElementById('handover-form');
if (handoverForm) {
handoverForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var note = document.getElementById('handover-note');
post('/api/handovers', { note: note.value.trim() }).then(function (r) {
if (!r) return;
if (r.ok) { note.value = ''; loadHandovers(); }
});
});
}
// ============================================================ 억제
var supList = document.getElementById('suppress-list');
var supStatus = document.getElementById('sup-status');
var supType = document.getElementById('sup-scope-type');
var supId = document.getElementById('sup-scope-id');
var deviceRows = [];
var cameraRows = [];
function fillScopeOptions() {
var rows = supType.value === 'device' ? deviceRows : cameraRows;
supId.textContent = '';
if (!rows.length) {
supId.appendChild(new Option(supType.value === 'device' ? '등록된 장치가 없습니다' : '관측된 카메라가 없습니다', ''));
return;
}
supId.appendChild(new Option('선택하세요', ''));
rows.forEach(function (r) { supId.appendChild(new Option(r.label, r.id)); });
}
function renderSuppressions(d) {
var now = d.now || Math.floor(Date.now() / 1000);
supList.textContent = '';
var list = d.suppressions || [];
if (!list.length) {
supList.appendChild(el('p', 'admin-empty', '현재 억제 중인 대상이 없습니다.'));
return;
}
list.forEach(function (s) {
var row = el('div', 'suppress-row');
var main = el('div', 'suppress-main');
main.appendChild(el('span', 'suppress-scope',
(s.scope_type === 'device' ? '장치' : s.scope_type === 'camera' ? '카메라' : '구역') + ' ' + s.scope_id));
main.appendChild(el('span', 'suppress-reason', s.reason));
main.appendChild(el('span', 'suppress-meta',
s.created_by + ' · ' + fmtStamp(s.starts_at) + ' 시작 · ' + fmtLeft(s.ends_at - now)));
row.appendChild(main);
var stop = el('button', null, '해제');
stop.type = 'button';
stop.addEventListener('click', function () {
post('/api/suppressions/' + s.suppression_id + '/revoke').then(function (r) {
if (r && r.ok) loadSuppressions();
});
});
row.appendChild(stop);
supList.appendChild(row);
});
}
function loadSuppressions() {
getJson('/api/suppressions').then(function (d) { if (d) renderSuppressions(d); })
.catch(function () { supList.textContent = '억제 목록을 불러오지 못했습니다.'; });
}
var supForm = document.getElementById('suppress-form');
if (supForm) {
supType.addEventListener('change', fillScopeOptions);
supForm.addEventListener('submit', function (ev) {
ev.preventDefault();
supStatus.textContent = '';
post('/api/suppressions', {
scope_type: supType.value,
scope_id: supId.value,
minutes: Number(document.getElementById('sup-minutes').value) || 0,
reason: document.getElementById('sup-reason').value.trim()
}).then(function (r) {
if (!r) return;
if (!r.ok) { supStatus.textContent = errText(r.data && r.data.error); return; }
supStatus.textContent = '억제를 시작했습니다. ' + fmtStamp(r.data.ends_at) + ' 에 자동 해제됩니다.';
document.getElementById('sup-reason').value = '';
loadSuppressions();
});
});
}
// ============================================================ 위험성평가
var riskList = document.getElementById('risk-list');
var riskForm = document.getElementById('risk-form');
var riskStatus = document.getElementById('risk-status');
function riskBand(score) {
if (score >= 15) return { cls: 'is-high', text: '높음' };
if (score >= 8) return { cls: 'is-mid', text: '보통' };
return { cls: 'is-low', text: '낮음' };
}
function renderRisks(list) {
riskList.textContent = '';
if (!list.length) {
riskList.appendChild(el('p', 'admin-empty',
'등록된 위험요인이 없습니다. 감지 유형별로 무엇을 통제하고 있는지 기록해 두면 사고조사 패키지에 함께 실립니다.'));
return;
}
list.forEach(function (r) {
var band = riskBand(r.risk_score);
var row = el('div', 'risk-row');
var score = el('div', 'risk-score ' + band.cls);
score.appendChild(el('strong', null, String(r.risk_score)));
score.appendChild(el('span', null, band.text));
row.appendChild(score);
var body = el('div', 'risk-body');
body.appendChild(el('span', 'risk-type', TYPE_LABELS[r.event_type] || r.event_type));
body.appendChild(el('span', 'risk-hazard', r.hazard));
body.appendChild(el('span', 'risk-control', '대책: ' + r.control));
var meta = '강도 ' + r.severity_rating + ' × 빈도 ' + r.likelihood_rating;
if (r.reviewed_by) meta += ' · ' + r.reviewed_by + ' ' + fmtDate(r.reviewed_at);
body.appendChild(el('span', 'risk-meta', meta));
row.appendChild(body);
var edit = el('button', null, '수정');
edit.type = 'button';
edit.addEventListener('click', function () { openRisk(r); });
row.appendChild(edit);
riskList.appendChild(row);
});
}
function openRisk(r) {
riskForm.hidden = false;
riskStatus.textContent = '';
var sel = document.getElementById('risk-type');
if (!sel.options.length) {
Object.keys(TYPE_LABELS).forEach(function (k) { sel.appendChild(new Option(TYPE_LABELS[k], k)); });
}
r = r || {};
sel.value = r.event_type || 'fall';
document.getElementById('risk-hazard').value = r.hazard || '';
document.getElementById('risk-control').value = r.control || '';
document.getElementById('risk-severity').value = r.severity_rating || 3;
document.getElementById('risk-likelihood').value = r.likelihood_rating || 3;
document.getElementById('risk-hazard').focus();
}
if (riskForm) {
document.getElementById('risk-new').addEventListener('click', function () { openRisk(null); });
document.getElementById('risk-cancel').addEventListener('click', function () { riskForm.hidden = true; });
riskForm.addEventListener('submit', function (ev) {
ev.preventDefault();
post('/api/risks', {
event_type: document.getElementById('risk-type').value,
hazard: document.getElementById('risk-hazard').value.trim(),
control: document.getElementById('risk-control').value.trim(),
severity_rating: Number(document.getElementById('risk-severity').value),
likelihood_rating: Number(document.getElementById('risk-likelihood').value)
}).then(function (r) {
if (!r) return;
if (!r.ok) { riskStatus.textContent = errText(r.data && r.data.error); return; }
riskStatus.textContent = '저장되었습니다.';
riskForm.hidden = true;
loadRisks();
});
});
}
function loadRisks() {
getJson('/api/risks').then(function (d) { if (d) renderRisks(d.controls || []); })
.catch(function () { riskList.textContent = '위험성평가를 불러오지 못했습니다.'; });
}
// ============================================================ 성능 추이
var metricDevice = document.getElementById('metric-device');
var metricChart = document.getElementById('metric-chart');
var SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs) {
var n = document.createElementNS(SVG_NS, tag);
Object.keys(attrs || {}).forEach(function (k) { n.setAttribute(k, String(attrs[k])); });
return n;
}
/** 값이 없는 구간은 선을 잇지 않는다 — 없는 데이터를 직선으로 메우면 그래프가 거짓말을 한다. */
function polylines(points, key, x, y) {
var segs = [], cur = [];
points.forEach(function (p, i) {
var v = p[key];
if (v === null || v === undefined) { if (cur.length > 1) segs.push(cur); cur = []; return; }
cur.push(x(i) + ',' + y(v));
});
if (cur.length > 1) segs.push(cur);
return segs;
}
function renderMetrics(d) {
metricChart.textContent = '';
var pts = d.points || [];
if (pts.length < 2) {
metricChart.appendChild(el('p', 'admin-empty',
'이 장치의 성능 기록이 아직 충분하지 않습니다. 하트비트가 쌓이면 추이가 그려집니다.'));
return;
}
var W = 720, H = 160, PAD = 28;
var fpsVals = pts.map(function (p) { return p.fps; }).filter(function (v) { return v !== null; });
var maxFps = Math.max(30, Math.ceil(Math.max.apply(null, fpsVals.length ? fpsVals : [30])));
var x = function (i) { return PAD + (i / (pts.length - 1)) * (W - PAD * 2); };
var yFps = function (v) { return H - PAD - (v / maxFps) * (H - PAD * 2); };
var chart = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, class: 'metric-svg',
role: 'img', 'aria-label': '프레임률 추이' });
chart.appendChild(svg('line', { x1: PAD, y1: H - PAD, x2: W - PAD, y2: H - PAD, class: 'axis' }));
chart.appendChild(svg('line', { x1: PAD, y1: PAD, x2: PAD, y2: H - PAD, class: 'axis' }));
polylines(pts, 'fps', x, yFps).forEach(function (seg) {
chart.appendChild(svg('polyline', { points: seg.join(' '), class: 'series-fps' }));
});
// 스로틀 구간은 배경 띠로 — 프레임률 하락의 원인을 같은 그림에서 읽을 수 있어야 한다.
pts.forEach(function (p, i) {
if (!p.throttle) return;
chart.appendChild(svg('rect', { x: x(i) - 1, y: PAD, width: 3, height: H - PAD * 2,
class: 'series-throttle' }));
});
metricChart.appendChild(chart);
var legend = el('div', 'metric-legend');
legend.appendChild(el('span', 'legend-fps', '프레임률 (최대 ' + maxFps + ')'));
legend.appendChild(el('span', 'legend-throttle', '과열 스로틀 구간'));
legend.appendChild(el('span', 'legend-range',
fmtStamp(pts[0].ts) + ' ~ ' + fmtStamp(pts[pts.length - 1].ts) + ' · ' + pts.length + '점'));
metricChart.appendChild(legend);
var last = pts[pts.length - 1];
var summary = el('p', 'metric-summary');
var bits = [];
if (last.fps !== null) bits.push('현재 ' + last.fps.toFixed(1) + ' fps');
if (last.temp !== null) bits.push(last.temp.toFixed(1) + '°C');
if (last.queue_pending !== null) bits.push('전송 대기 ' + last.queue_pending + '건');
if (last.cameras_erroring) bits.push('오류 카메라 ' + last.cameras_erroring + '대');
summary.textContent = bits.join(' · ');
metricChart.appendChild(summary);
}
function loadMetrics() {
var dev = metricDevice.value;
if (!dev) { metricChart.textContent = '장치를 선택하세요.'; return; }
metricChart.textContent = '불러오는 중…';
getJson('/api/devices/' + encodeURIComponent(dev) + '/metrics?hours=24')
.then(function (d) { if (d) renderMetrics(d); })
.catch(function () { metricChart.textContent = '성능 기록을 불러오지 못했습니다.'; });
}
if (metricDevice) metricDevice.addEventListener('change', loadMetrics);
// ---------------------------------------------------------------- 공통 목록
function loadTargets() {
return Promise.all([getJson('/api/devices'), getJson('/api/events?limit=200')])
.then(function (res) {
var devs = (res[0] && res[0].devices) || [];
deviceRows = devs.map(function (d) {
return { id: d.device_id, label: (d.name || d.device_id) + ' · ' + d.device_id };
});
var seen = {};
cameraRows = [];
((res[1] && res[1].events) || []).forEach(function (e) {
if (!e.camera_id || DeviceScope.is(e.camera_id) || seen[e.camera_id]) return;
seen[e.camera_id] = true;
cameraRows.push({ id: e.camera_id, label: e.camera_id });
});
cameraRows.sort(function (a, b) { return a.id < b.id ? -1 : 1; });
fillScopeOptions();
metricDevice.textContent = '';
metricDevice.appendChild(new Option(devs.length ? '장치를 선택하세요' : '등록된 장치가 없습니다', ''));
deviceRows.forEach(function (d) { metricDevice.appendChild(new Option(d.label, d.id)); });
})
.catch(function () {});
}
loadCsrf().then(function () {
loadTargets();
loadHandovers();
loadSuppressions();
loadRisks();
});
})();