/** * 설치 점검 (온보딩). * * 납품 직후가 이 제품에서 가장 위험한 구간이다. 대시보드는 멀쩡히 뜨고, 로그인도 되고, * 화면 어디에도 오류가 없는데 — 장치가 등록되지 않았거나, 존을 안 그렸거나, 통보 경로가 * 비어 있으면 시스템은 "감시 중"으로 보이면서 실제로는 아무것도 하지 않는다. 그 상태가 * 몇 주씩 유지되다가 사고가 나서야 발견되는 것이 최악의 시나리오다. * * 그래서 각 단계는 "설정했다고 표시"가 아니라 **실제 데이터를 조회해** 판정한다. 체크가 * 켜졌다면 그 단계는 정말로 동작하고 있다. * * XSS-safe: 모든 동적 값은 textContent. */ (function () { 'use strict'; var B = window.APP_BASE || ''; var listEl = document.getElementById('setup-steps'); if (!listEl) return; 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 getJson(path) { return fetch(B + path, { headers: { Accept: 'application/json' } }) .then(function (r) { if (r.status === 401) { window.location.href = B + '/login'; return null; } // 403 은 "권한 없음"이지 "미설정"이 아니다. 단계는 판정 보류로 남긴다. if (r.status === 403) return { _forbidden: true }; return r.ok ? r.json() : null; }) .catch(function () { return null; }); } function render(steps) { listEl.textContent = ''; steps.forEach(function (s, i) { var li = el('li', 'setup-step is-' + s.state); var mark = el('span', 'setup-mark'); mark.textContent = s.state === 'ok' ? '✓' : (s.state === 'unknown' ? '?' : String(i + 1)); mark.setAttribute('aria-hidden', 'true'); li.appendChild(mark); var body = el('div', 'setup-body'); body.appendChild(el('h2', 'setup-title', s.title)); body.appendChild(el('p', 'setup-status', s.status)); // 이 단계가 안 되어 있으면 무엇이 벌어지는가 — 체크리스트를 지시가 아니라 이유로 만든다. if (s.state !== 'ok' && s.why) body.appendChild(el('p', 'setup-why', s.why)); if (s.href) { var a = el('a', 'setup-link', s.linkText || '설정하러 가기 →'); a.href = B + s.href; body.appendChild(a); } li.appendChild(body); listEl.appendChild(li); }); } Promise.all([ getJson('/api/devices'), getJson('/api/events?limit=200'), getJson('/api/admin/channels') ]).then(function (res) { var devicesRes = res[0] || {}; var eventsRes = res[1] || {}; var channelsRes = res[2] || {}; var devices = devicesRes.devices || []; var online = devices.filter(function (d) { return d.online; }); var events = eventsRes.events || []; var cameras = {}; events.forEach(function (e) { if (!e.camera_id || DeviceScope.is(e.camera_id)) return; // 노드 단위 사건은 카메라가 아니다 cameras[e.camera_id] = true; }); var cameraCount = Object.keys(cameras).length; var steps = []; // 1. 장치 등록 steps.push(devices.length === 0 ? { state: 'todo', title: '① 장치 등록', status: '등록된 엣지 장치가 없습니다.', why: '장치 행이 없으면 현장 노드가 보내는 이벤트는 인증에서 거부됩니다. 장치별 비밀키는 관리자가 DB에 직접 발급합니다 (dist/PROVISION_SECRETS.md).', } : { state: 'ok', title: '① 장치 등록', status: devices.length + '대 등록됨 · 현재 온라인 ' + online.length + '대' }); // 2. 장치 통신 (하트비트) steps.push(online.length === 0 ? { state: devices.length ? 'todo' : 'blocked', title: '② 장치 통신', status: devices.length ? '하트비트를 보내는 장치가 없습니다.' : '장치 등록 후 확인할 수 있습니다.', why: '장치가 서버에 닿지 못하면 감지는 노드 안에서만 일어나고 대시보드는 영원히 비어 있습니다. 노드의 CAFE24_URL·DEVICE_SECRET 과 방화벽을 확인하세요.', } : { state: 'ok', title: '② 장치 통신', status: online.length + '대가 하트비트를 보내고 있습니다.' }); // 3. 카메라 감지 steps.push(cameraCount === 0 ? { state: online.length ? 'todo' : 'blocked', title: '③ 카메라 감지', status: online.length ? '아직 어떤 카메라도 이벤트를 보낸 적이 없습니다.' : '장치 통신 후 확인할 수 있습니다.', why: '카메라가 붙어 있어도 이벤트가 한 번도 없으면 실제로 사람을 검출하고 있는지 알 수 없습니다. 감시 구역에서 한 번 앉았다 일어나 보고, 그 사건이 스트림에 뜨는지 확인하세요.', href: '/', linkText: '감지 스트림 보기 →' } : { state: 'ok', title: '③ 카메라 감지', status: cameraCount + '대의 카메라가 이벤트를 보냈습니다.' }); // 4. 위험구역 getJson('/api/zones/' + encodeURIComponent(Object.keys(cameras)[0] || '_none_')) .then(function (zoneRes) { var zones = (zoneRes && zoneRes.zones) || []; steps.push(zones.length === 0 ? { state: cameraCount ? 'todo' : 'blocked', title: '④ 위험구역 설정', status: cameraCount ? '첫 카메라에 저장된 구역이 없습니다.' : '카메라 감지 후 설정할 수 있습니다.', why: '구역이 없으면 넘어짐·부동 감지는 동작하지만 제한구역 침입·협착·2인1조 같은 규칙은 전부 비활성입니다.', href: '/zones', linkText: '구역 설정으로 →' } : { state: 'ok', title: '④ 위험구역 설정', status: '첫 카메라에 구역 ' + zones.length + '개가 설정되어 있습니다.' }); // 5. 통보 경로 — 이 제품에서 가장 자주 비어 있는 칸이다. if (channelsRes._forbidden) { steps.push({ state: 'unknown', title: '⑤ 통보 경로', status: '이 계정에는 통보 설정 권한이 없습니다. 관리자 계정으로 확인하세요.' }); } else { var chans = (channelsRes.channels || []).filter(function (c) { return c.enabled; }); var local = chans.filter(function (c) { return c.kind === 'local'; }); steps.push(chans.length === 0 ? { state: 'todo', title: '⑤ 통보 경로', status: '사용 중인 통보 경로가 없습니다.', why: '지금 상태에서는 감지가 되어도 브라우저를 보고 있는 사람에게만 전달됩니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다. 현장 경광등(엣지 GPIO)부터 설정하세요 — 인터넷이 끊겨도 동작합니다.', href: '/admin', linkText: '통보 경로 설정 →' } : { state: local.length ? 'ok' : 'warn', title: '⑤ 통보 경로', status: chans.length + '개 사용 중' + (local.length ? ' (현장 경광등 포함)' : ' — 현장 경광등 없음'), why: local.length ? null : '메일·웹훅은 서버와 네트워크가 살아 있을 때만 닿습니다. 정전이나 회선 장애 때도 울릴 경로가 하나는 있어야 합니다.', href: '/admin', linkText: '통보 경로 보기 →' }); } // 6. 통보 디스패처 — 서버 상태로는 알 수 없다. 정직하게 '확인 필요'로 남긴다. steps.push({ state: 'unknown', title: '⑥ 통보 디스패처 (크론)', status: '이 화면에서는 확인할 수 없습니다.', why: '메일·웹훅 통보와 미확인 에스컬레이션은 tools/dispatch_alerts.php 가 크론으로 돌아야 동작합니다. 호스팅 관리자 화면의 크론 설정에서 1분 주기로 등록되어 있는지 직접 확인하세요. (--dry-run 으로 먼저 시험할 수 있습니다.)' }); // 7. 위험성평가 getJson('/api/risks').then(function (riskRes) { var controls = (riskRes && riskRes.controls) || []; steps.push(controls.length === 0 ? { state: 'todo', title: '⑦ 위험성평가 연계', status: '등록된 위험요인이 없습니다.', why: '없어도 감지는 동작합니다. 다만 사고조사 패키지와 관리체계 증빙에서 "무엇을 통제하고 있었나" 칸이 비게 됩니다.', href: '/compliance', linkText: '안전 관리로 →' } : { state: 'ok', title: '⑦ 위험성평가 연계', status: controls.length + '개 항목이 등록되어 있습니다.' }); render(steps); }); }); }); })();