move_detect/server/public/assets/compliance.js
유창욱 4bfb7e0274
Some checks are pending
verify / edge (push) Waiting to run
verify / server (push) Waiting to run
feat: 추락 · 동시 다발 쓰러짐 · 구역 프리셋 (1.2.0)
AI CCTV 목적을 원론부터 재점검한 결과. 셋 다 새 추론 모델 없이 지금 있는
자세추정·구역·트랙 이력만으로 구현했다.

■ 추락 (fall_from_height)

잡고 있던 것은 같은 높이의 '넘어짐'이었고, 사망 1위 유형인 '떨어짐'은 못
잡았다. GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로
(_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라
아무것도 안 걸렸다.

판정의 핵심은 bbox 중심이 아니라 하단(y2)이다. 쭈그려 앉으면 중심은
내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려간다 — 중심으로
재면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도
내려가므로 높이 증가 가드로 따로 막는다. Track.frames 가 90프레임을 들고
있어 별도 버퍼가 없다.

발보는 두 갈래로 나뉘되 한 사고는 한 건이다: 프레임 안에서 넘어짐이
확정되면 유형을 승격하고, 화각 밖으로 사라지면 ghost 무장을 넓혀 그쪽으로
낸다. 심각도 CRITICAL — 척추·두부 손상 주의가 통보에 실리려면 넘어짐(HIGH)과
등급이 갈라져야 한다. 고소 구역은 rules_json 의 elevated 키로 붙어 스키마
변경이 없고, 구역이 없어도 하강 신호만으로 동작한다.

■ 동시 다발 쓰러짐 (multi_down)

같은 구역에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식·
가스·폭발이다. 이 기능의 본체는 감지가 아니라 통보 문구다 — 밀폐공간
사망자의 상당수가 구조하러 들어간 사람이므로, 링크를 열기 전에 메일 본문에서
"공기호흡기 없이 진입하지 마십시오"가 읽혀야 한다. Channels::guidance 로
유형별 안내를 붙였고 문구를 테스트로 고정했다(문구가 사라지면 기능이 사라진
것과 같다).

서버에 둔 이유: 가스는 카메라 화각을 존중하지 않아 노드를 넘는 판단이
필요하고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보한다. 오탐 방어의
핵심은 근접성 — 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로
일어나므로 같은 구역이거나 좌표가 가까워야 묶는다. 한 사람이 두 번 넘어진
것은 한 명으로 센다.

■ 구역 프리셋 6종 + max_workers:0 수정

밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한)은 이미 전부 있었는데
화면이 "최소 인원을 넣으세요"라고만 물어 아무도 그렇게 쓰지 못했다.
안전관리자는 min_workers 가 아니라 밀폐공간을 생각한다.

그 과정에서 잠복 결함 하나: max_workers:0 이 falsy 로 걸러져 "아무도 있으면
안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 통째로
꺼져 있었다. 운영자가 저장해 놓고도 아무 일이 안 일어났다. 감지기·입력란
(min=1)·저장 직렬화(>0)·목록 표시 네 곳에서 막혀 있었다.

■ 검증

pytest 322 passed / phpunit 263 passed. 신규 테스트 30건.
이벤트 유형 2종 추가는 test_event_type_parity 가 강제하는 5곳(엣지 enum ·
EventValidator · schema CHECK · 프런트 라벨 5파일 · 필터 옵션) 전부 동기화했고
기존 설치용 마이그레이션을 추가했다.

■ Pi 번들

1.1.0 과 달리 이번에는 edge/ 가 바뀌었으므로 Pi 번들 재빌드가 필수다.
낡은 1.0.0 번들은 dist/ 에서 제거했다 — 두면 추락 감지가 빠진 노드가 그대로
납품된다. arm64 + 네트워크 빌드 머신에서 재빌드해야 Pi 납품이 가능하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 12:21:50 +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: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
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();
});
})();