move_detect/server/public/assets/compliance.js
유창욱 e00ad69aa3 feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
  뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
  밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
  '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
  (전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
  무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
  플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
  통째로 꺼지는 경로다.

전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
  KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
  (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
  시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
  넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.

시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
  납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
  캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.

━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━

통보 — 이 제품의 정의에 해당하는 구멍
  지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
  넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
  어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
  없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
  아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
  '아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
  인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.

조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
  전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
  초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.

끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
  /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
  장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
  없었다. 지표는 있는데 그것을 켜는 사건이 없었다.

관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
  엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
  목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
  통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
  의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).

증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
  없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
  exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
  매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
  조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).

운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
  모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
  백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.

미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
  감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
  (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
  명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
2026-08-18 07:51:23 +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 || 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();
});
})();