move_detect/server/public/assets/report.js
유창욱 7daee0b372 fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.

CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
  edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
  끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
  검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
  허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
  (tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
  프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
  지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
  한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
  식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
  (/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
  전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
  경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.

HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
  그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
  공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
  04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
  Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
  DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
  이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.

MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
  크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
  claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
  정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
  발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
  공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.

LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
  읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
  PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.

수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.

부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.

검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
2026-08-18 10:33:02 +09:00

410 lines
20 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.

/**
* Safety operations report: fetches GET /api/report?days=N and renders the management
* indicators a 안전관리자 takes into the weekly safety meeting. Read-only; the 인쇄 button
* prints the page (print CSS strips the app chrome).
*
* XSS-safe: every dynamic value goes through textContent; the only inline style is a
* numeric bar width.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
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: '화재·연기'
};
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
var PATTERN_MIN_PEAK = 4; // 요일×시간 격자에서 최다 칸이 이보다 적으면 무늬는 우연
var ACTION_MIN_BEFORE = 5; // 개선조치 전후 비교의 최소 '전' 표본
var root = document.getElementById('report-kpis');
if (!root) return;
var daysSel = document.getElementById('report-days');
var printBtn = document.getElementById('report-print');
var rangeEl = document.getElementById('report-range');
var typesEl = document.getElementById('report-types');
var camerasEl = document.getElementById('report-cameras');
var ergoEl = document.getElementById('report-ergo');
var rulesEl = document.getElementById('report-rules');
var footEl = document.getElementById('report-foot');
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 fmtDuration(s) {
if (s === null || s === undefined) return '—';
if (s < 60) return Math.round(s) + '초';
if (s < 3600) return Math.floor(s / 60) + '분 ' + Math.round(s % 60) + '초';
return Math.floor(s / 3600) + '시간 ' + Math.floor((s % 3600) / 60) + '분';
}
function fmtHours(s) {
if (!s) return '0분';
if (s < 3600) return Math.round(s / 60) + '분';
return (s / 3600).toFixed(1) + '시간';
}
// One stamp format across the product (app.js / detail.js use the same shape). This page
// printed toLocaleDateString('ko-KR') — "2026. 8. 2." — while the stream printed "08-02",
// so the same day looked like two different notations depending on the screen.
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function fmtDate(ts) {
var d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '-';
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function fmtStamp(ts) {
var d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '-';
return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
// KPI tile: the headline number plus the one-line reading of what it means.
function kpi(label, value, note, cls) {
var t = el('div', 'kpi' + (cls ? ' ' + cls : ''));
t.appendChild(el('span', 'kpi-label', label));
t.appendChild(el('strong', 'kpi-value', value));
if (note) t.appendChild(el('span', 'kpi-note', note));
return t;
}
// 전기간 대비 추이 한 줄. neutral: 니어미스처럼 증감의 좋고 나쁨을 단정할 수 없는 지표
// (감지가 늘어난 것일 수도, 위험이 늘어난 것일 수도) — 방향만 보여주고 색은 칠하지 않는다.
function trendLine(tile, prevVal, curVal, fmt, neutral) {
if (prevVal === null || prevVal === undefined) return;
var line = el('span', 'kpi-trend');
if (prevVal === curVal) {
line.textContent = '이전 기간과 동일';
} else {
var up = curVal > prevVal;
line.textContent = (up ? '▲ ' : '▼ ') + '이전 기간 ' + fmt(prevVal) + ' → ' + fmt(curVal);
if (!neutral) line.className = 'kpi-trend ' + (up ? 'is-worse' : 'is-better');
}
tile.appendChild(line);
}
function barRow(label, value, max, text, hot) {
var row = el('div', 'bar-row');
var head = el('div', 'bar-head');
head.appendChild(el('span', 'bar-label', label));
head.appendChild(el('span', 'bar-value' + (hot ? ' is-hot' : ''), text));
row.appendChild(head);
var track = el('div', 'bar-track');
var fill = el('div', 'bar-fill' + (hot ? ' is-hot' : ''));
fill.style.width = (max > 0 ? Math.max(2, Math.round(value / max * 100)) : 0) + '%';
track.appendChild(fill);
row.appendChild(track);
return row;
}
function render(r) {
var t = r.totals;
var severe = t.critical + t.high;
rangeEl.textContent = '최근 ' + r.days + '일 · '
+ fmtDate(r.generated_at - r.days * 86400) + ' ~ ' + fmtDate(r.generated_at);
// 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다.
var alertEl = document.getElementById('report-alert');
if (alertEl) {
var sp = r.precursor_alert || {};
alertEl.hidden = !sp.alert;
alertEl.textContent = '';
if (sp.alert) {
var lead = el('strong', null, '⚠ 전조 급증');
alertEl.appendChild(lead);
alertEl.appendChild(document.createTextNode(
' — 최근 7일 니어미스·구역 신호 ' + sp.this_week + '건, 이전 4주 주평균 '
+ sp.baseline_weekly_avg + '건' + (sp.ratio ? ' (' + sp.ratio + '배)' : '')
+ '. 배치·동선·휴식 등 사전 개입을 검토하세요.'));
}
}
root.textContent = '';
var prev = r.previous || {};
var kSevere = kpi('중대 이벤트', String(severe),
'위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : '');
trendLine(kSevere, (prev.critical || 0) + (prev.high || 0), severe, String);
root.appendChild(kSevere);
var kTta = kpi('평균 대응 시간', fmtDuration(r.tta.avg_s),
r.tta.acked > 0 ? '중대 ' + r.tta.acked + '건 확인 · 최장 ' + fmtDuration(r.tta.max_s) : '확인된 중대 이벤트 없음');
if (r.tta.avg_s !== null) trendLine(kTta, prev.tta_avg_s, r.tta.avg_s, fmtDuration);
root.appendChild(kTta);
// 하인리히 지표: 니어미스가 사고보다 얼마나 앞서 잡히고 있나 — 예방이 작동한다는 증거.
var nm = r.near_miss;
var kNm = kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents,
nm.near_fall > 0 ? '사고 전 신호가 먼저 잡히고 있습니다' : '기록된 니어미스 없음');
trendLine(kNm, prev.near_fall, nm.near_fall, String, true);
root.appendChild(kNm);
var fpPct = r.fp.total > 0 ? Math.round(r.fp.tagged / r.fp.total * 100) : 0;
var prevFpPct = prev.fp && prev.fp.total > 0 ? Math.round(prev.fp.tagged / prev.fp.total * 100) : null;
var kFp = kpi('오탐율', fpPct + '%',
'전체 ' + r.fp.total + '건 중 오탐 태그 ' + r.fp.tagged + '건', fpPct >= 30 ? 'is-warn' : '');
trendLine(kFp, prevFpPct, fpPct, function (v) { return v + '%'; });
root.appendChild(kFp);
// 현장별 롤업 (본사 관점). 스코프 운영자에게는 서버가 null 을 주고 카드는 숨은 채로 남는다.
var sitesCard = document.getElementById('report-sites-card');
var sitesEl = document.getElementById('report-sites');
if (sitesCard && sitesEl) {
var sites = r.by_site;
// 현장이 하나뿐인 설치에서 한 줄짜리 표는 정보가 아니라 잡음이다.
var show = Array.isArray(sites) && sites.length > 1;
sitesCard.hidden = !show;
if (show) {
sitesEl.textContent = '';
var table = el('div', 'site-rows');
var maxSevere = sites.reduce(function (m, s) { return Math.max(m, s.critical_high); }, 0);
sites.forEach(function (s) {
var row = el('div', 'site-row' + (s.critical_high > 0 && s.critical_high === maxSevere ? ' is-worst' : ''));
row.appendChild(el('span', 'site-name', s.site || '현장 미지정'));
var nums = el('span', 'site-nums');
nums.appendChild(el('strong', 'site-severe' + (s.critical_high > 0 ? ' is-hot' : ''), String(s.critical_high)));
nums.appendChild(el('span', null, '중대'));
nums.appendChild(el('strong', null, String(s.count)));
nums.appendChild(el('span', null, '전체'));
nums.appendChild(el('strong', 'site-unacked' + (s.unacked > 0 ? ' is-open' : ''), String(s.unacked)));
nums.appendChild(el('span', null, '미확인'));
nums.appendChild(el('span', 'site-cams', '카메라 ' + s.cameras));
row.appendChild(nums);
table.appendChild(row);
});
sitesEl.appendChild(table);
sitesEl.appendChild(el('p', 'report-empty',
'현장별 수치는 접근 권한이 전역인 계정에만 표시됩니다. 중대(위급+높음)가 가장 많은 현장을 강조했습니다.'));
}
}
typesEl.textContent = '';
var typeKeys = Object.keys(r.by_type);
if (typeKeys.length === 0) typesEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
var maxType = typeKeys.reduce(function (m, k) { return Math.max(m, r.by_type[k]); }, 0);
typeKeys.forEach(function (k) {
typesEl.appendChild(barRow(TYPE_LABELS[k] || k, r.by_type[k], maxType, r.by_type[k] + '건'));
});
camerasEl.textContent = '';
if (r.by_camera.length === 0) camerasEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
var maxCam = r.by_camera.reduce(function (m, c) { return Math.max(m, c.count); }, 0);
// "위험 순위"인데 중대가 1건이라도 있으면 전부 빨강이었다 — 실제 현장에서는 거의 모든
// 카메라가 그 조건을 만족하므로 순위가 순위 노릇을 못 했다. 최상위 카메라와, 그에 준하는
// (중대 밀도가 최상위의 60% 이상인) 카메라만 강조해 '먼저 볼 곳'을 실제로 좁힌다.
var maxSevere = r.by_camera.reduce(function (m, c) { return Math.max(m, c.critical_high); }, 0);
var hotFloor = Math.max(1, Math.ceil(maxSevere * 0.6));
r.by_camera.forEach(function (c) {
camerasEl.appendChild(barRow(c.camera_id, c.count, maxCam,
c.count + '건 · 중대 ' + c.critical_high, maxSevere > 0 && c.critical_high >= hotFloor));
});
if (maxSevere > 0) {
camerasEl.appendChild(el('p', 'report-empty',
'중대(위급+높음)가 ' + hotFloor + '건 이상인 카메라를 강조했습니다.'));
}
// 인체공학: 관측 시간 대비 각 밴드 노출 — 절대시간과 비율을 함께.
ergoEl.textContent = '';
var eg = r.ergo;
if (!eg.observed_s) {
ergoEl.appendChild(el('p', 'report-empty', '기간 내 자세 관측 데이터가 없습니다.'));
} else {
[['trunk_high_s', '허리 고위험 (60°+)', true],
['trunk_mod_s', '허리 중위험 (20~60°)', false],
['arm_high_s', '팔 거상 (90°+)', false],
['squat_s', '깊은 웅크림', false]].forEach(function (row) {
var v = eg[row[0]] || 0;
var pct = Math.round(v / eg.observed_s * 100);
ergoEl.appendChild(barRow(row[1], v, eg.observed_s,
fmtHours(v) + ' (' + pct + '%)', row[2] && pct >= 20));
});
ergoEl.appendChild(el('p', 'report-empty', '총 관측 ' + fmtHours(eg.observed_s)
+ ' — 비율은 관측 시간 대비입니다.'));
}
rulesEl.textContent = '';
var oc = r.occupancy;
var rules = el('div', 'rule-stats');
// '미확인 잔여'는 존 규칙 위반이 아니다. 같은 2×2 격자에 같은 무게로 놓여 있어서
// 네 번째 규칙처럼 읽혔다 — 처리 현황은 격자 밖 한 줄로 분리한다.
[['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay]]
.forEach(function (pair) {
var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : ''));
box.appendChild(el('strong', null, String(pair[1])));
box.appendChild(el('span', null, pair[0]));
rules.appendChild(box);
});
rulesEl.appendChild(rules);
rulesEl.appendChild(el('p', 'report-empty',
'처리 현황 — 이 기간 감지 ' + t.events + '건 중 미확인 ' + t.unacked + '건.'));
// 시간 패턴: 요일×시간 발생 밀도 — "언제가 위험한가"를 한 장에.
var patternEl = document.getElementById('report-pattern');
if (patternEl) {
patternEl.textContent = '';
var pt = r.pattern || { grid: [], max: 0 };
if (!pt.max) {
patternEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
} else if (pt.max < PATTERN_MIN_PEAK) {
// 168칸에 최대 2~3건이면 무늬는 우연이다. 7일 창에서는 거의 항상 이 상태이므로,
// 전폭 카드가 '패턴을 찾았다'고 말하지 않도록 게이트를 둔다.
patternEl.appendChild(el('p', 'report-empty',
'아직 시간대 패턴을 말할 만큼 감지가 쌓이지 않았습니다 (가장 많은 칸이 ' + pt.max
+ '건). 기간을 30일 이상으로 넓히면 분포가 드러납니다.'));
} else {
var DAYS = ['월', '화', '수', '목', '금', '토', '일'];
var table = el('div', 'pattern');
var head = el('div', 'pattern-row pattern-head');
head.appendChild(el('span', 'pattern-day', ''));
for (var h = 0; h < 24; h++) head.appendChild(el('span', 'pattern-hour', h % 6 === 0 ? String(h) : ''));
table.appendChild(head);
pt.grid.forEach(function (rowArr, d) {
var row = el('div', 'pattern-row');
row.appendChild(el('span', 'pattern-day', DAYS[d]));
rowArr.forEach(function (n, h) {
var cell = el('span', 'pattern-cell');
if (n > 0) {
cell.style.opacity = String(0.25 + 0.75 * (n / pt.max));
cell.classList.add('is-filled');
}
cell.title = DAYS[d] + ' ' + h + '시 · ' + n + '건';
cell.setAttribute('aria-label', cell.title);
row.appendChild(cell);
});
table.appendChild(row);
});
patternEl.appendChild(table);
patternEl.appendChild(el('p', 'report-empty', '진할수록 감지가 몰린 시간대입니다 (최대 ' + pt.max + '건).'));
}
}
footEl.textContent = '생성 ' + fmtStamp(r.generated_at)
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
}
// --- 개선 조치 효과 --------------------------------------------------------------
var actionForm = document.getElementById('action-form');
var actionCamera = document.getElementById('action-camera');
var actionNote = document.getElementById('action-note');
var actionsRoot = document.getElementById('report-actions');
var csrfToken = null;
function loadCsrf() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) { if (d && d.token) csrfToken = d.token; })
.catch(function () { /* 기록 시 403으로 드러난다 */ });
}
// 이 줄은 안전회의에 그대로 올라간다. 표본 6건에서 나온 "▲33%"를 확정 색으로 칠하면
// 회의에서 바로 반박당하고, 그 다음부터는 이 카드 전체가 신뢰를 잃는다. 표본이 임계 미만이면
// 원시 수치만 중립 톤으로 적고 판정을 미룬다. 창 길이도 함께 적어 서로 다른 창의 두 행이
// 같은 잣대로 읽히지 않게 한다.
function actionEffect(a) {
if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)');
var e = a.effect;
var base = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)';
if (e.before < ACTION_MIN_BEFORE) {
var thin = el('span', 'action-effect is-thin', base + ' · 표본 부족, 판정 보류');
thin.title = '조치 전 감지가 ' + ACTION_MIN_BEFORE + '건 이상일 때만 증감률을 표시합니다.';
return thin;
}
// 카운트 데이터의 자연 변동폭(√before, 푸아송 표준편차)을 넘지 못하는 차이는 조치의 효과가
// 아니라 잡음이다. 6건→8건을 "▲33% 악화"로 칠하면 그 카드는 회의에서 한 번 반박당하고
// 끝난다. 노이즈 범위 안이면 방향도 색도 주장하지 않는다.
var diff = e.after - e.before;
if (Math.abs(diff) <= Math.sqrt(e.before)) {
var flat = el('span', 'action-effect is-thin', base + ' · 유의미한 변화 아님 (통계적 잡음 범위)');
flat.title = '카운트 데이터의 자연 변동폭(±' + (Math.round(Math.sqrt(e.before) * 10) / 10)
+ '건) 안이라 조치 효과로 볼 수 없습니다.';
return flat;
}
var pct = Math.round(diff / e.before * 100);
var txt = base + ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%';
return el('span', 'action-effect' + (diff < 0 ? ' is-better' : ' is-worse'), txt);
}
function renderActions(list) {
if (!actionsRoot) return;
actionsRoot.textContent = '';
if (!list || list.length === 0) {
actionsRoot.appendChild(el('p', 'report-empty',
'기록된 조치가 없습니다. 조치를 기록하면 전후 감지 밀도를 자동 비교합니다.'));
return;
}
list.forEach(function (a) {
var row = el('div', 'action-row');
var what = el('div', 'action-what');
what.appendChild(el('span', 'action-date', fmtDate(a.taken_at)));
what.appendChild(el('span', 'action-cam', a.camera_id));
what.appendChild(el('span', 'action-note', a.note));
row.appendChild(what);
row.appendChild(actionEffect(a));
actionsRoot.appendChild(row);
});
}
function loadActions() {
if (!actionsRoot) return;
fetch(B + '/api/actions', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) { if (d) renderActions(d.actions); })
.catch(function () { /* 카드 유지 */ });
}
if (actionForm) {
actionForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var camera = (actionCamera.value || '').trim();
var note = (actionNote.value || '').trim();
if (!camera || !note) return;
fetch(B + '/api/actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
body: JSON.stringify({ camera_id: camera, note: note })
})
.then(function (res) {
if (!res.ok) throw new Error('action_failed:' + res.status);
actionNote.value = '';
loadActions();
})
.catch(function () { /* 권한/CSRF 실패: 입력 유지 */ });
});
}
var exportLink = document.getElementById('export-labels');
if (exportLink) exportLink.href = B + '/api/export/labels?days=90';
function load() {
var days = daysSel ? daysSel.value : '7';
fetch(B + '/api/report?days=' + encodeURIComponent(days), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('report_failed:' + res.status);
return res.json();
})
.then(function (data) { if (data) render(data); })
.catch(function () {
rangeEl.textContent = '리포트를 불러오지 못했습니다. 새로고침해 주세요.';
});
}
if (daysSel) daysSel.addEventListener('change', load);
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
load();
loadActions();
loadCsrf();
})();