앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.
읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.
크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.
증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.
증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.
마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).
검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
466 lines
20 KiB
JavaScript
466 lines
20 KiB
JavaScript
/**
|
||
* Incident detail view: parses the event id from /events/{id}/view, fetches
|
||
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
|
||
* plus each snapshot as an <img src="/snap/{file_uuid}">.
|
||
*
|
||
* This is where an incident is JUDGED, so it is also where it is RESOLVED: 확인/오탐 post to
|
||
* the same routes the stream uses. Previously the operator had to read the evidence here,
|
||
* navigate back, and find the row again — the deepest screen had no way to act on what it
|
||
* showed, which is exactly backwards.
|
||
*
|
||
* XSS-safe: every dynamic value is written via textContent or a validated URL
|
||
* attribute, never innerHTML. Severity is shown as icon + text (never color
|
||
* alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
|
||
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
|
||
var B = window.APP_BASE || '';
|
||
|
||
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' }
|
||
};
|
||
|
||
// Same wire-code -> Korean map as app.js (the two pages share no module system);
|
||
// an unknown code falls through as the raw code — never blank.
|
||
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: '화재·연기'
|
||
};
|
||
|
||
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo
|
||
// report's whole value IS its metrics). Unknown keys fall through as the raw key — which is
|
||
// why `confidence` had been rendering as a bare uppercase "CONFIDENCE" in an otherwise
|
||
// Korean column. Anything the edge can attach belongs in this table.
|
||
var METRIC_LABELS = {
|
||
confidence: '검출 신뢰도', score: '검출 점수',
|
||
observed_s: '관측 시간', trunk_high_s: '허리 고위험(60°+) 노출',
|
||
trunk_mod_s: '허리 중위험(20~60°) 노출', arm_high_s: '팔 거상(90°+) 노출',
|
||
squat_s: '웅크림 노출', prone_s: '엎드림 지속', immobility_s: '부동 지속',
|
||
waited_s: '재출현 대기', dwell_s: '체류 시간', limit_s: '체류 상한',
|
||
alone_s: '단독 작업 지속', workers: '감지 인원', required: '최소 인원', limit: '정원',
|
||
torso_angle: '몸통 각도(°)', aspect_ratio: '박스 종횡비', height_remaining: '잔여 높이 비율',
|
||
last_state: '소실 시 상태'
|
||
};
|
||
|
||
var STATE_LABELS = { FALLING: '넘어지는 중', FALLEN: '넘어짐', NO_MOTION: '미동 없음', UPRIGHT: '기립' };
|
||
|
||
function metricValue(key, v) {
|
||
if (/_s$/.test(key) && typeof v === 'number') {
|
||
if (v >= 90) return Math.round(v / 60) + '분 ' + Math.round(v % 60) + '초';
|
||
return v + '초';
|
||
}
|
||
// A 0..1 confidence reads as a percentage everywhere else a human meets one.
|
||
if ((key === 'confidence' || key === 'score') && typeof v === 'number' && v <= 1) {
|
||
return Math.round(v * 100) + '%';
|
||
}
|
||
if (key === 'last_state') return STATE_LABELS[v] || String(v);
|
||
return String(v);
|
||
}
|
||
|
||
var meta = document.getElementById('event-detail-meta');
|
||
var snapBox = document.getElementById('event-detail-snapshots');
|
||
var actionsBox = document.getElementById('detail-actions');
|
||
var titleEl = document.getElementById('detail-title');
|
||
var whenEl = document.getElementById('detail-when');
|
||
if (!meta) return;
|
||
|
||
var m = location.pathname.match(/\/events\/(\d+)\/view/);
|
||
if (!m) {
|
||
meta.textContent = '잘못된 주소입니다.';
|
||
meta.removeAttribute('aria-busy');
|
||
return;
|
||
}
|
||
var id = m[1];
|
||
var currentEvent = null;
|
||
var csrfToken = null;
|
||
var deviceNames = {};
|
||
|
||
function severityInfo(sev) {
|
||
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
||
}
|
||
|
||
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
||
|
||
// One stamp format across the product. This page used to print toLocaleString('ko-KR')
|
||
// ("2026. 8. 8. 오후 4:01:50") while the stream printed "08-08 16:01:50" for the same
|
||
// instant — the same event looked like two different records depending on the screen.
|
||
var DOW = ['일', '월', '화', '수', '목', '금', '토'];
|
||
function formatTime(occurredAt) {
|
||
if (!occurredAt && occurredAt !== 0) return '-';
|
||
var d = new Date(Number(occurredAt) * 1000);
|
||
if (isNaN(d.getTime())) return '-';
|
||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
|
||
+ ' (' + DOW[d.getDay()] + ') '
|
||
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
||
}
|
||
|
||
function fmtDuration(s) {
|
||
var v = Number(s);
|
||
if (!isFinite(v)) return '-';
|
||
if (v < 60) return (Math.round(v * 10) / 10) + '초';
|
||
return Math.floor(v / 60) + '분 ' + Math.round(v % 60) + '초';
|
||
}
|
||
|
||
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 textNode(value, cls) {
|
||
var span = document.createElement('span');
|
||
if (cls) span.className = cls;
|
||
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
|
||
return span;
|
||
}
|
||
|
||
function addRow(label, valueNode) {
|
||
var row = document.createElement('div');
|
||
row.className = 'detail-row';
|
||
var k = document.createElement('dt');
|
||
k.textContent = label;
|
||
var v = document.createElement('dd');
|
||
v.appendChild(valueNode);
|
||
row.appendChild(k);
|
||
row.appendChild(v);
|
||
meta.appendChild(row);
|
||
}
|
||
|
||
// --- resolution actions --------------------------------------------------------------
|
||
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 () { /* the POST surfaces it as a 403 */ });
|
||
}
|
||
|
||
function post(path, body) {
|
||
var opt = { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } };
|
||
if (body !== undefined) {
|
||
opt.headers['Content-Type'] = 'application/json';
|
||
opt.body = JSON.stringify(body);
|
||
}
|
||
return fetch(B + '/events/' + encodeURIComponent(id) + path, opt).then(function (res) {
|
||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||
if (!res.ok) throw new Error('action_failed:' + res.status);
|
||
return res;
|
||
});
|
||
}
|
||
|
||
function setNote(text, isError) {
|
||
var note = document.createElement('p');
|
||
note.className = 'detail-action-note' + (isError ? ' is-error' : '');
|
||
note.textContent = text;
|
||
actionsBox.appendChild(note);
|
||
}
|
||
|
||
// 오탐 is two-step here for the same reason as in the stream: it asserts the detector was
|
||
// wrong, it moves a per-camera statistic, and there is no undo.
|
||
var fpArmed = false;
|
||
|
||
function renderActions() {
|
||
if (!actionsBox || !currentEvent) return;
|
||
actionsBox.hidden = false;
|
||
actionsBox.textContent = '';
|
||
fpArmed = false;
|
||
|
||
if (currentEvent.false_positive_by) {
|
||
setNote('오탐으로 판정됨 · ' + currentEvent.false_positive_by);
|
||
return;
|
||
}
|
||
if (currentEvent.acknowledged_by) {
|
||
setNote('확인 완료 · ' + currentEvent.acknowledged_by);
|
||
return;
|
||
}
|
||
|
||
var ack = document.createElement('button');
|
||
ack.type = 'button';
|
||
ack.className = 'btn-primary';
|
||
ack.textContent = '확인 처리';
|
||
ack.addEventListener('click', function () {
|
||
ack.disabled = true;
|
||
post('/ack')
|
||
.then(function (res) {
|
||
if (!res) return;
|
||
currentEvent.acknowledged_by = window.APP_USER || '확인';
|
||
renderActions();
|
||
renderMeta();
|
||
})
|
||
.catch(function () { ack.disabled = false; setNote('확인 처리에 실패했습니다.', true); });
|
||
});
|
||
actionsBox.appendChild(ack);
|
||
|
||
var fp = document.createElement('button');
|
||
fp.type = 'button';
|
||
fp.className = 'fp-btn';
|
||
fp.textContent = '오탐';
|
||
fp.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
|
||
fp.addEventListener('click', function () {
|
||
if (!fpArmed) {
|
||
fpArmed = true;
|
||
fp.classList.add('is-armed');
|
||
fp.textContent = '오탐 확정?';
|
||
setTimeout(function () {
|
||
if (!fpArmed) return;
|
||
fpArmed = false; fp.classList.remove('is-armed'); fp.textContent = '오탐';
|
||
}, 4000);
|
||
return;
|
||
}
|
||
fp.disabled = true; ack.disabled = true;
|
||
post('/false-positive')
|
||
.then(function (res) {
|
||
if (!res) return;
|
||
currentEvent.acknowledged_by = window.APP_USER || '확인';
|
||
currentEvent.false_positive_by = window.APP_USER || '오탐';
|
||
renderActions();
|
||
renderMeta();
|
||
})
|
||
.catch(function () { fp.disabled = false; ack.disabled = false; setNote('오탐 처리에 실패했습니다.', true); });
|
||
});
|
||
actionsBox.appendChild(fp);
|
||
}
|
||
|
||
// --- 증거 보존 보류 -------------------------------------------------------------------
|
||
// 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙 자체는
|
||
// 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진이 사라졌다.
|
||
//
|
||
// renderActions 와 분리한 이유: 저쪽은 확인·오탐이 끝나면 조기 반환한다. 보류가 필요한
|
||
// 상황은 바로 그 "이미 확인된 진짜 사고"라서, 같은 함수에 두면 정작 필요할 때 안 보인다.
|
||
var holdBox = document.getElementById('detail-hold');
|
||
var HOLD_DAYS = 90;
|
||
|
||
function renderHold() {
|
||
if (!holdBox || !currentEvent) return;
|
||
if (window.APP_ROLE !== 'admin') { holdBox.hidden = true; return; }
|
||
holdBox.hidden = false;
|
||
holdBox.textContent = '';
|
||
|
||
var until = currentEvent.hold_until;
|
||
var held = typeof until === 'number' && until * 1000 > Date.now();
|
||
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = held ? 'fp-btn' : 'detail-link-btn';
|
||
btn.textContent = held ? '보존 보류 해제' : '증거 보존 (' + HOLD_DAYS + '일)';
|
||
btn.setAttribute('aria-label', held
|
||
? '증거 보존 보류를 해제합니다. 이후 보존기간이 지난 스냅샷은 정리 대상이 됩니다.'
|
||
: '이 사건의 스냅샷을 ' + HOLD_DAYS + '일간 보존기간 정리에서 제외합니다.');
|
||
btn.addEventListener('click', function () {
|
||
btn.disabled = true;
|
||
post('/hold', { days: held ? 0 : HOLD_DAYS })
|
||
.then(function (res) { return res ? res.json() : null; })
|
||
.then(function (d) {
|
||
if (!d) return;
|
||
currentEvent.hold_until = d.hold_until;
|
||
renderHold();
|
||
renderMeta();
|
||
})
|
||
.catch(function () {
|
||
btn.disabled = false;
|
||
var p = document.createElement('p');
|
||
p.className = 'detail-action-note is-error';
|
||
p.textContent = '보존 보류 처리에 실패했습니다.';
|
||
holdBox.appendChild(p);
|
||
});
|
||
});
|
||
holdBox.appendChild(btn);
|
||
|
||
var note = document.createElement('p');
|
||
note.className = 'detail-action-note';
|
||
note.textContent = held
|
||
? '보존 보류 중 — ' + new Date(until * 1000).toLocaleDateString('ko-KR')
|
||
+ '까지 이 사건의 사진이 자동 삭제되지 않습니다.'
|
||
: '조사가 진행 중이면 보존을 걸어두세요. 걸지 않으면 보존기간이 지난 사진은 자동으로 지워집니다.';
|
||
holdBox.appendChild(note);
|
||
}
|
||
|
||
function renderMeta() {
|
||
var ev = currentEvent || {};
|
||
meta.textContent = '';
|
||
meta.removeAttribute('aria-busy');
|
||
|
||
addRow('심각도', buildBadge(ev.severity));
|
||
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
|
||
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
|
||
addRow('카메라', textNode(DeviceScope.label(ev.camera_id), 'mono'));
|
||
addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono'));
|
||
addRow('지속', textNode(fmtDuration(ev.duration_s)));
|
||
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
|
||
if (ev.zone_id) addRow('구역', textNode(ev.zone_id, 'mono'));
|
||
|
||
var status = ev.false_positive_by
|
||
? '오탐 (' + ev.false_positive_by + ')'
|
||
: (ev.acknowledged_by ? '확인됨 (' + ev.acknowledged_by + ')' : '미확인');
|
||
addRow('처리 상태', textNode(status));
|
||
|
||
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
|
||
var metrics = ev.metrics || {};
|
||
Object.keys(metrics).forEach(function (key) {
|
||
addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[key])));
|
||
});
|
||
}
|
||
|
||
function renderHead() {
|
||
var ev = currentEvent || {};
|
||
if (titleEl) titleEl.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '이벤트 상세');
|
||
if (whenEl) {
|
||
whenEl.textContent = '';
|
||
whenEl.appendChild(textNode(deviceNames[ev.device_id] || ev.device_id, 'ev-src'));
|
||
whenEl.appendChild(textNode(ev.camera_id, 'ev-cam'));
|
||
whenEl.appendChild(textNode(formatTime(ev.occurred_at), 'ev-at'));
|
||
}
|
||
}
|
||
|
||
function renderSnapshots(snaps) {
|
||
if (!snapBox) return;
|
||
snapBox.textContent = '';
|
||
var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; });
|
||
if (kept.length === 0) {
|
||
var none = document.createElement('p');
|
||
none.className = 'detail-empty';
|
||
none.textContent = '이 이벤트에는 저장된 스냅샷이 없습니다 (미촬영이거나 PIPA 보존기간이 지나 삭제됨).';
|
||
snapBox.appendChild(none);
|
||
return;
|
||
}
|
||
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by
|
||
// position from the end — exact offsets are edge config the server doesn't know, and
|
||
// inventing seconds here would be a fabricated measurement on an accident record.
|
||
var CAPTIONS = ['이전', '직전', '발생 시점'];
|
||
kept.forEach(function (s, i) {
|
||
var fig = document.createElement('figure');
|
||
fig.className = 'detail-snap';
|
||
var img = document.createElement('img');
|
||
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||
var cap = kept.length > 1
|
||
? CAPTIONS[Math.max(0, CAPTIONS.length - kept.length + i)] : '발생 시점';
|
||
img.alt = '사고 스냅샷 — ' + cap;
|
||
img.className = 'detail-snapshot';
|
||
img.loading = 'lazy';
|
||
fig.appendChild(img);
|
||
var fc = document.createElement('figcaption');
|
||
fc.textContent = cap;
|
||
fig.appendChild(fc);
|
||
snapBox.appendChild(fig);
|
||
});
|
||
}
|
||
|
||
// --- outbound links + improvement action ---------------------------------------------
|
||
function renderLinks() {
|
||
var ev = currentEvent || {};
|
||
var actionCard = document.getElementById('detail-action-card')
|
||
|| document.getElementById('detail-action-form');
|
||
if (actionCard) actionCard.hidden = !isCameraEvent(ev);
|
||
|
||
var pkg = document.getElementById('detail-package');
|
||
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
|
||
var zones = document.getElementById('detail-zones');
|
||
if (zones) {
|
||
// Deep-link the zone editor straight at this camera. Reading an incident and then having
|
||
// to re-select the camera by hand on the next screen is the same "the system knows this
|
||
// and asks you anyway" problem the editor's own picker exists to remove.
|
||
zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
|
||
+ '&camera=' + encodeURIComponent(ev.camera_id || '');
|
||
// 노드 단위 사건(장치 이상·감시 성능 저하)에는 카메라가 없다. 링크를 남겨 두면
|
||
// ?camera=__device__ 로 편집기가 열리고, 그 값이 그대로 저장되면 어떤 카메라와도
|
||
// 매칭되지 않는 구역이 '활성'으로 남는다. 서버도 막지만 여기서도 내보내지 않는다.
|
||
zones.hidden = !ev.camera_id || DeviceScope.is(ev.camera_id);
|
||
}
|
||
}
|
||
|
||
/** 이 사건이 카메라에 대한 것인가. 개선 조치는 카메라 단위 개념이라 노드 사건에는 없다. */
|
||
function isCameraEvent(ev) {
|
||
return !!(ev && ev.camera_id) && !DeviceScope.is(ev.camera_id);
|
||
}
|
||
|
||
function wireActionForm() {
|
||
var form = document.getElementById('detail-action-form');
|
||
var note = document.getElementById('detail-action-note');
|
||
var status = document.getElementById('detail-action-status');
|
||
if (!form) return;
|
||
form.addEventListener('submit', function (e) {
|
||
e.preventDefault();
|
||
var text = (note.value || '').trim();
|
||
if (!text || !currentEvent) return;
|
||
status.textContent = '기록 중…';
|
||
fetch(B + '/api/actions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
|
||
body: JSON.stringify({
|
||
camera_id: currentEvent.camera_id,
|
||
note: text,
|
||
event_id: currentEvent.event_id
|
||
})
|
||
})
|
||
.then(function (r) {
|
||
if (r.status === 401) { window.location.href = B + '/login'; return; }
|
||
if (!r.ok) throw new Error('action_failed');
|
||
note.value = '';
|
||
status.textContent = '기록되었습니다. 안전 리포트의 [개선 조치 효과]에서 전후 비교를 볼 수 있습니다.';
|
||
})
|
||
.catch(function () { status.textContent = '기록에 실패했습니다. 권한을 확인하세요.'; });
|
||
});
|
||
}
|
||
|
||
function render(data) {
|
||
currentEvent = data.event || {};
|
||
renderHead();
|
||
renderMeta();
|
||
renderActions();
|
||
renderHold();
|
||
renderLinks();
|
||
renderSnapshots(data.snapshots);
|
||
}
|
||
|
||
// Device NAMES, so the incident record says "1공장 프레스라인" and not "pi-a1" — the same
|
||
// provenance the stream row shows. A failure here is non-fatal: the raw id stands in.
|
||
function loadDevices() {
|
||
return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
|
||
.then(function (res) { return res.ok ? res.json() : null; })
|
||
.then(function (d) {
|
||
if (!d || !Array.isArray(d.devices)) return;
|
||
d.devices.forEach(function (dev) { if (dev.name) deviceNames[dev.device_id] = dev.name; });
|
||
})
|
||
.catch(function () { /* raw id stands in */ });
|
||
}
|
||
|
||
loadCsrf();
|
||
wireActionForm();
|
||
// Names first so the record never paints an id and then swap it for a name a beat later.
|
||
loadDevices().then(function () {
|
||
return fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||
.then(function (res) {
|
||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||
if (!res.ok) throw new Error('detail_failed:' + res.status);
|
||
return res.json();
|
||
})
|
||
.then(function (data) { if (data) render(data); })
|
||
.catch(function () {
|
||
meta.textContent = '불러오기 실패';
|
||
meta.removeAttribute('aria-busy');
|
||
});
|
||
});
|
||
})();
|