/** * 시스템 설정: 통보 채널 + 감지 임계값. * * 이 두 화면이 없던 동안 납품된 현장은 (1) 감지되어도 아무에게도 알릴 수 없었고 * (2) 오탐이 나도 임계값을 고칠 방법이 phpMyAdmin 뿐이었다. 콘솔의 오탐율 패널이 * "이 카메라를 재튜닝하라"고 알려주는데 재튜닝할 화면이 없는 상태였다. * * 임계값 입력은 자유 텍스트가 아니라 서버가 내려준 카탈로그(범위·단위·설명)로 렌더한다. * 범위 밖 값은 엣지가 기동 시 거부하는데, 그때는 이미 공장 안 장비가 재시작 루프에 빠진 * 뒤다 — 키보드 앞에서 막는 편이 낫다. * * XSS-safe: 모든 동적 값은 textContent. innerHTML 금지 (프로젝트 규약). */ (function () { 'use strict'; var B = window.APP_BASE || ''; var root = document.querySelector('.admin-page'); if (!root) return; var csrf = null; var catalog = {}; var devices = []; 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 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 () { /* 저장 시 403 으로 드러난다 */ }); } 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 }; }); }); } // 서버 오류 코드 -> 사람이 고칠 수 있는 문장. 코드를 그대로 보여주면 운영자는 // 무엇을 고쳐야 할지 알 수 없다. var ERRORS = { out_of_range: '허용 범위를 벗어났습니다.', not_numeric: '숫자를 입력하세요.', not_finite: '유효한 숫자가 아닙니다.', not_integer: '정수만 입력할 수 있습니다.', unknown_key: '알 수 없는 항목입니다.', unknown_device: '등록되지 않은 장치입니다.', invalid_kind: '채널 종류가 올바르지 않습니다.', invalid_name: '이름을 입력하세요.', invalid_config: '연결 설정을 확인하세요 (수신자 주소 / URL).', invalid_escalate_after: '에스컬레이션 지연은 30초 이상이어야 합니다.', invalid_step: '단계는 0~5 사이여야 합니다.', csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.', forbidden: '권한이 없습니다.', not_found: '대상을 찾을 수 없습니다.' }; function errText(code) { return ERRORS[code] || ('저장에 실패했습니다 (' + String(code) + ')'); } // ============================================================ 통보 채널 var chList = document.getElementById('channel-list'); var chForm = document.getElementById('channel-form'); var chStatus = document.getElementById('ch-status'); var KIND_LABELS = { local: '현장 경광등', email: '메일', webhook: '웹훅' }; var SEV_LABELS = { critical: '위급만', high: '높음 이상', warning: '경고 이상', info: '전부' }; function field(id) { return document.getElementById(id); } function showKind(kind) { ['local', 'email', 'webhook'].forEach(function (k) { var box = root.querySelector('.kind-' + k); if (box) box.hidden = (k !== kind); }); } function renderChannels(list) { chList.textContent = ''; if (!list.length) { var empty = el('p', 'admin-empty', '설정된 통보 경로가 없습니다. 지금은 감지가 되어도 화면을 보고 있는 사람에게만 전달됩니다.'); chList.appendChild(empty); return; } list.forEach(function (c) { var row = el('div', 'channel-row' + (c.enabled ? '' : ' is-off')); var main = el('div', 'channel-main'); main.appendChild(el('span', 'channel-kind', KIND_LABELS[c.kind] || c.kind)); main.appendChild(el('span', 'channel-name', c.name)); var meta = el('span', 'channel-meta'); var bits = [SEV_LABELS[c.min_severity] || c.min_severity]; if (c.step > 0) bits.push('에스컬레이션 ' + Math.round(c.escalate_after_s / 60) + '분 후'); else bits.push('즉시'); if (c.kind === 'local' && c.config && c.config.gpio_pin) bits.push('GPIO ' + c.config.gpio_pin); if (c.kind === 'email' && c.config && c.config.to) bits.push([].concat(c.config.to).join(', ')); if (c.kind === 'webhook' && c.config && c.config.url) bits.push(c.config.url); if (!c.enabled) bits.push('사용 안 함'); meta.textContent = bits.join(' · '); main.appendChild(meta); row.appendChild(main); var edit = el('button', null, '수정'); edit.type = 'button'; edit.addEventListener('click', function () { openForm(c); }); row.appendChild(edit); var del = el('button', 'zone-del', '삭제'); del.type = 'button'; var armed = false, timer = null; del.addEventListener('click', function () { if (!armed) { armed = true; del.classList.add('is-armed'); del.textContent = '정말 삭제?'; timer = setTimeout(function () { armed = false; del.classList.remove('is-armed'); del.textContent = '삭제'; }, 4000); return; } clearTimeout(timer); post('/api/admin/channels/' + c.channel_id + '/delete').then(function (r) { if (r && r.ok) loadChannels(); }); }); row.appendChild(del); chList.appendChild(row); }); } function openForm(c) { chForm.hidden = false; chStatus.textContent = ''; c = c || {}; var cfg = c.config || {}; field('ch-id').value = c.channel_id || ''; field('ch-kind').value = c.kind || 'local'; field('ch-name').value = c.name || ''; field('ch-severity').value = c.min_severity || 'high'; field('ch-step').value = c.step || 0; field('ch-after').value = c.escalate_after_s || 300; field('ch-enabled').checked = c.channel_id ? !!c.enabled : true; field('ch-pin').value = cfg.gpio_pin || 0; field('ch-mode').value = cfg.mode || 'latch'; field('ch-hold').value = cfg.hold_s || 30; field('ch-active-high').checked = !!cfg.active_high; field('ch-to').value = cfg.to ? [].concat(cfg.to).join(', ') : ''; field('ch-from').value = cfg.from || ''; field('ch-url').value = cfg.url || ''; // 토큰은 서버가 마스킹해서 준다. 그대로 두면 "변경 안 함"이 된다. field('ch-token').value = cfg.token || ''; showKind(field('ch-kind').value); field('ch-after-wrap').hidden = Number(field('ch-step').value) === 0; field('ch-name').focus(); } function collectConfig(kind) { if (kind === 'email') { return { to: field('ch-to').value, from: field('ch-from').value.trim() }; } if (kind === 'webhook') { var out = { url: field('ch-url').value.trim() }; var t = field('ch-token').value.trim(); if (t) out.token = t; return out; } return { gpio_pin: Number(field('ch-pin').value) || 0, mode: field('ch-mode').value, hold_s: Number(field('ch-hold').value) || 30, active_high: field('ch-active-high').checked }; } if (chForm) { field('ch-kind').addEventListener('change', function () { showKind(this.value); }); field('ch-step').addEventListener('input', function () { field('ch-after-wrap').hidden = Number(this.value) === 0; }); document.getElementById('ch-cancel').addEventListener('click', function () { chForm.hidden = true; }); document.getElementById('channel-new').addEventListener('click', function () { openForm(null); }); chForm.addEventListener('submit', function (ev) { ev.preventDefault(); var kind = field('ch-kind').value; var body = { channel_id: Number(field('ch-id').value) || 0, kind: kind, name: field('ch-name').value.trim(), min_severity: field('ch-severity').value, step: Number(field('ch-step').value) || 0, escalate_after_s: Number(field('ch-after').value) || 0, enabled: field('ch-enabled').checked ? 1 : 0, config: collectConfig(kind) }; chStatus.textContent = '저장 중…'; post('/api/admin/channels', body).then(function (r) { if (!r) return; if (!r.ok) { chStatus.textContent = errText(r.data && r.data.error); return; } chStatus.textContent = '저장되었습니다.'; chForm.hidden = true; loadChannels(); }).catch(function () { chStatus.textContent = '저장에 실패했습니다.'; }); }); } function loadChannels() { fetch(B + '/api/admin/channels', { headers: { Accept: 'application/json' } }) .then(function (r) { if (r.status === 401) { window.location.href = B + '/login'; return null; } return r.ok ? r.json() : null; }) .then(function (d) { if (d) renderChannels(d.channels || []); }) .catch(function () { chList.textContent = '통보 경로를 불러오지 못했습니다.'; }); } // ============================================================ 임계값 var thrDevice = document.getElementById('thr-device'); var thrList = document.getElementById('thr-list'); var thrStatus = document.getElementById('thr-status'); var values = {}; // device_id -> {key: value} function renderThresholds() { var dev = thrDevice.value; thrList.textContent = ''; if (!dev) { thrList.textContent = '장치를 선택하세요.'; return; } var current = values[dev] || {}; Object.keys(catalog).forEach(function (key) { var spec = catalog[key]; var row = el('div', 'thr-row'); var head = el('div', 'thr-head'); head.appendChild(el('span', 'thr-label', spec.label)); var val = el('span', 'thr-value'); row.appendChild(head); var input = document.createElement('input'); input.type = 'number'; input.min = spec.min; input.max = spec.max; input.step = spec.step; // 값이 없으면 장비가 쓰고 있는 기본값을 보여준다 — 빈칸이면 "설정 안 됨"과 // "기본값과 같음"을 구분할 수 없다. var isSet = Object.prototype.hasOwnProperty.call(current, key); input.value = isSet ? current[key] : spec['default']; head.appendChild(val); val.textContent = (spec.unit ? spec.unit : '') + (isSet ? '' : ' · 기본값'); var controls = el('div', 'thr-controls'); controls.appendChild(input); var save = el('button', 'btn-primary', '적용'); save.type = 'button'; save.addEventListener('click', function () { thrStatus.textContent = '적용 중…'; post('/api/admin/thresholds', { device_id: dev, key: key, value: input.value }) .then(function (r) { if (!r) return; if (!r.ok) { thrStatus.textContent = spec.label + ': ' + errText(r.data && r.data.error) + ' (허용 ' + spec.min + '~' + spec.max + ')'; return; } thrStatus.textContent = spec.label + ' 적용됨 — 다음 동기화 때 장치에 반영됩니다.'; loadThresholds(); }) .catch(function () { thrStatus.textContent = '적용에 실패했습니다.'; }); }); controls.appendChild(save); row.appendChild(controls); row.appendChild(el('p', 'thr-help', spec.help)); thrList.appendChild(row); }); } function loadThresholds() { fetch(B + '/api/admin/thresholds', { headers: { Accept: 'application/json' } }) .then(function (r) { if (r.status === 401) { window.location.href = B + '/login'; return null; } if (r.status === 403) { thrList.textContent = '임계값 편집 권한이 없습니다.'; return null; } return r.ok ? r.json() : null; }) .then(function (d) { if (!d) return; catalog = d.catalog || {}; devices = d.devices || []; values = {}; (d.thresholds || []).forEach(function (t) { if (t.camera_id) return; // 카메라별 재정의는 이 화면의 범위 밖 if (!values[t.device_id]) values[t.device_id] = {}; values[t.device_id][t.key] = t.value; }); var keep = thrDevice.value; thrDevice.textContent = ''; var first = document.createElement('option'); first.value = ''; first.textContent = devices.length ? '장치를 선택하세요' : '등록된 장치가 없습니다'; thrDevice.appendChild(first); devices.forEach(function (dev) { var o = document.createElement('option'); o.value = dev.device_id; o.textContent = (dev.name || dev.device_id) + ' · ' + dev.device_id; thrDevice.appendChild(o); }); if (keep) thrDevice.value = keep; renderThresholds(); }) .catch(function () { thrList.textContent = '임계값을 불러오지 못했습니다.'; }); } if (thrDevice) thrDevice.addEventListener('change', renderThresholds); loadCsrf().then(function () { loadChannels(); loadThresholds(); }); })();