"""Frozen presentation content; installed ct code remains the operational authority."""
from html import escape

PAGES = []
COMMANDS = []

def ref(key):
    return f'<a class="xref" data-ref="{key}">{key}</a>'

def p(value):
    return f'<p>{value}</p>'

def h(value):
    return f'<h2>{value}</h2>'

def note(title, value):
    return f'<aside><strong>{title}</strong><p>{value}</p></aside>'

def code(value, where='AI 서버 · si 터미널', placeholder=False):
    COMMANDS.extend({'command': line, 'where': where, 'placeholder': placeholder} for line in value.splitlines())
    return f'<div class="command"><div class="where">{where}</div><pre>{escape(value)}</pre></div>'

def table(headers, rows, widths=None):
    columns = '' if not widths else '<colgroup>' + ''.join(f'<col style="width:{w}%">' for w in widths) + '</colgroup>'
    return '<table>' + columns + '<thead><tr>' + ''.join(f'<th>{x}</th>' for x in headers) + '</tr></thead><tbody>' + ''.join('<tr>' + ''.join(f'<td>{x}</td>' for x in row) + '</tr>' for row in rows) + '</tbody></table>'

def page(key, category, title, situation, body, sources, quick=False):
    PAGES.append(dict(id=key, category=category, title=title, situation=situation, body=body, sources=sources, quick=quick))

page('G-00','먼저 읽기','Contextra 현장 운영·복구',
     '2026년 9월 30일 준비본 · 10월 1일 현장 첫 연결부터 사용하는 게임북',
     note('인쇄는 통합본 한 파일이면 충분합니다', '빠른 대응본은 이 책의 핵심 페이지를 추린 휴대용입니다. 전체 절차는 통합본에 모두 들어 있습니다. PDF 한 파일만으로 읽을 수 있으며 인터넷이 필요하지 않습니다.')
     + h('지금 준비된 것과 현장에서 할 것')
     + table(['준비 완료','현장에서 진행'],[
         ('field 프로필 선택<br>다음 부팅 고정 IP 192.168.105.40/24<br>현장 공개 CA·계정 파일 저장',
          '현장 Web 전원·주소 확인<br>로그인·인증서 발급·앱 설정 적용<br>서비스와 bbox 확인')],[50,50])
     + note('첫 부팅에 서비스가 안 켜져도 바로 초기화하지 마세요', '마지막 앱 실행 설정은 회사망입니다. 현장 NIC와 주소가 다르면 기동 보호가 작동할 수 있습니다. '+ref('F-01')+'부터 현장 연결을 끝내면 됩니다.')
     + h('현재 상황으로 이동')
     + table(['상황','시작 항목'],[
         ('현장에 처음 가져와서 켰다',ref('F-01')+' → '+ref('F-02')+' → '+ref('F-03')+' → '+ref('F-04')+' → '+ref('F-05')),
         ('현장 연결을 끝낸 뒤 다시 켰다',ref('R-01')),
         ('주소·계정·CA를 바꾸고 싶다',ref('C-01')+' / '+ref('C-03')+' / '+ref('C-04')),
         ('오류가 떴고 원인을 모르겠다',ref('G-01')+'에서 증상으로 찾기'),
         ('Web 담당자에게 보여줄 내용이 필요하다',ref('W-01'))],[58,42])
     + p('기본 계정은 <b>si</b>입니다. 명령은 한 줄씩 실행하고 성공을 확인합니다. 지면 폭 때문에 접힌 긴 명령은 <b>이어서 한 줄로 입력</b>합니다. <b>비밀번호는 이 책에 적지 않습니다.</b>'),
     ['field-nextboot','reboot-acceptance'],True)

page('G-01','찾아보기','증상별 이동표와 전체 목차',
     '화면의 오류 단어나 현재 하고 싶은 작업을 찾으세요. 항목 번호는 두 PDF와 GPT 참고 문서에서 같습니다.',
     '<div id="toc-table"></div>', ['all'])

page('F-01','현장 첫 연결 1/5','부팅하고 주소·디스크 확인',
     '현장 유선망에 처음 연결했습니다. Web은 192.168.105.42, AI는 192.168.105.40을 사용합니다.',
     h('1. 연결하고 si로 로그인')
     + p('준비 때 사용한 유선 LAN 포트에 랜선을 연결하고 서버를 켭니다. 물리 콘솔에서 si로 로그인합니다. 네트워크 대기는 최대 60초로 설정했지만 다른 디스크·서비스 때문에 전체 부팅은 더 걸릴 수 있습니다.')
     + code('ct lang en\nip -4 -br addr')
     + p('물리 콘솔 한글이 깨질 때는 영어 모드를 사용합니다. 유선 <code>enp128s31f6</code>에 <b>192.168.105.40/24</b>가 있어야 합니다. 없으면 '+ref('E-01')+'.')
     + h('2. 현장 프로필·데이터 디스크 확인')
     + code('ct profile select field\nct net status')
     + p('선택은 NIC를 바꾸지 않습니다. 이번에는 다음 부팅용 고정 IP가 이미 준비됐습니다. 주소가 맞으면 <b>ct net apply를 다시 하지 않습니다.</b>')
     + code('findmnt /backup\nfindmnt /training')
     + p('두 명령 모두 해당 디스크 마운트가 나와야 합니다. 비어 있으면 '+ref('E-08')+'에서 확인하며 데이터를 다른 폴더에 대신 저장하지 않습니다.')
     + note('Windows에서 접속할 때', '<code>ssh -p 1023 si@192.168.105.40</code><br>주소 변경 후 host key 경고가 나오면 이전 서버의 키 지문과 대조합니다. 무조건 known_hosts를 지우지 않습니다.')
     + p('<b>정상:</b> 유선 .40/24, field 선택, 두 데이터 디스크 확인 → '+ref('F-02')),
     ['field-nextboot','field_network.py','field_cli.py'],True)

page('F-02','현장 첫 연결 2/5','연결 준비 상태 확인',
     'NIC는 현장 주소입니다. 아직 앱 설정이 회사망 주소인 것은 이번 첫 연결에서 예상한 상태입니다.',
     h('1. 현재값을 먼저 읽기')
     + code('ct config status\nct config version status')
     + table(['확인 항목','기대하는 값'],[
         ('선택 환경 / 실제 NIC','field / 192.168.105.40'),
         ('선택 Web / AI ID','https://192.168.105.42 / AI-01'),
         ('포트 / Redis 허용','WSS 38443 · Redis 16379 · SSH 1023 / 192.168.105.42'),
         ('계정 등록 / CA 준비','True / True'),
         ('진행 중 pending','새 준비 전에는 없음. 있으면 '+ref('E-07'))],[37,63])
     + p('앱 설정이 아직 회사망 주소여도 정상적인 전환 전 상태입니다. 계정·CA가 준비돼 있으면 다시 입력하거나 교체할 필요가 없습니다.')
     + h('2. 연결 전에 서비스 정지 확인')
     + code('ct status')
     + p('Contextra 컨테이너가 실행 중이면 Web의 카메라를 먼저 Stop하고 <code>ct stop</code>을 실행합니다. 이미 모두 중지됐다면 Stop을 반복하지 않습니다. 정지 오류는 '+ref('E-08')+'.')
     + note('연결 버전은 회사망의 2를 그대로 정답으로 쓰지 않습니다', '현장 Web의 최신 번호는 별개입니다. auto는 로컬 제안일 뿐 원격 확정값이 아닙니다. Web 담당자가 알려준 번호보다 새 접속정보의 번호가 커야 합니다. 번호를 지정해야 하면 prepare 전에 '+ref('E-05')+'를 따릅니다.')
     + p('<b>정상:</b> field 값·계정·CA와 정지 상태 확인 → '+ref('F-03')),
     ['field_cli.py','connection_versions.py','field-nextboot'],True)

page('F-03','현장 첫 연결 3/5','준비하고 연결 요청 1회',
     '서비스가 중지돼 있고 field 주소·계정·CA가 맞습니다. 이제 Web에 실제 요청을 보냅니다.',
     h('1. 새 준비본 만들기')
     + code('ct config prepare')
     + p('확인 입력: <kbd>PREPARE</kbd>. 출력한 환경·CA 지문·연결 버전을 확인합니다. <code>PREPARED_NOT_APPLIED</code>는 준비만 끝난 것이며 아직 로그인하거나 앱 설정을 적용한 상태가 아닙니다.')
     + note('현재 저장한 현장 CA의 SHA256 인증서 지문', '<span class="fingerprint">DC:CB:F6:2F:48:9F:6D:EF:E3:6A:94:6B:C3:5C:99:CF:1F:5A:A0:CF:4E:7C:B6:C5:0E:12:F8:5D:04:82:1B:59</span><br>2026-09-18~2036-09-15 유효기간. Web이 CA를 교체했다면 이 값도 달라지므로 '+ref('C-04')+'로 이동합니다.')
     + h('2. 명시적으로 로그인·인증서 요청')
     + code('ct config connect')
     + p('출력 Web이 <b>https://192.168.105.42</b>인지 확인한 후 <kbd>CONNECT</kbd>를 입력합니다. 이 요청은 기존 AI 세션에 영향을 줄 수 있으므로 반복하지 않습니다.')
     + table(['출력 / 단계','다음'],[
         ('CERTIFICATE_VERIFIED',ref('F-04')+'로 이동. 발급과 검증 완료'),
         ('LOGIN + HTTP401',ref('E-04')+' 계정 확인'),
         ('CONNECTION_VERSION_CONFLICT / STALE_CONNECTION_VERSION',ref('E-05')+' 개정 번호 확인'),
         ('CERTIFICATE + HTTP500',ref('E-06')+' Web 서명 설정 확인'),
         ('통신 오류 / 결과 불명확',ref('E-03')+' 및 '+ref('E-07')+'; 자동 재시도하지 않음')],[68,32])
     + p('<b>성공해도 아직 서비스·WSS·bbox 성공은 아닙니다.</b> 다음은 발급 결과를 로컬 실행 설정에 반영하는 단계입니다.'),
     ['field_cli.py','network_prepare.py','ca-backup'],True)

page('F-04','현장 첫 연결 4/5','설정 적용 후 서비스 기동',
     '직전 connect에서 CERTIFICATE_VERIFIED를 확인했습니다. 준비본·프로필·계정·CA를 더 수정하지 않은 상태입니다.',
     h('1. 검증된 설정 적용')
     + code('ct config apply')
     + p('확인 입력: <kbd>APPLY</kbd>. 정상 출력은 <b>설정 반영 완료·재부팅 유지</b>입니다. 이 단계는 서비스를 자동 Start하지 않습니다.')
     + p('오류가 나오면 <code>ct config status</code>를 읽고 '+ref('E-07')+'로 이동합니다. SHA를 직접 고치거나 같은 apply를 무작정 반복하지 않습니다.')
     + h('2. 서비스만 기동')
     + code('ct start\nct status')
     + p('<code>MAINTENANCE_TRANSACTION_VERIFIED</code>와 서비스 기동 결과를 확인합니다. PostgreSQL·Redis·Sidecar·Gateway·Analytics의 <b>5개 컨테이너</b>가 실행돼야 합니다. Gateway는 health 표시 없이 Running일 수 있습니다.')
     + table(['확인','기대 상태'],[
         ('실제 NIC / 앱 설정 AI 주소','둘 다 192.168.105.40'),
         ('선택 / 실행 Web','현장 Web 192.168.105.42'),
         ('카메라','ct는 자동 Start하지 않음. Web에서 이후 수동 Start'),
         ('LLM 실행 경고','LLM을 종료한 후 기동. '+ref('A-01'))],[37,63])
     + note('기동 보호를 우회하지 않습니다', '디스크 누락·주소 불일치·적용 중단·image/source 무결성 오류는 각 원인을 먼저 해결합니다. 컨테이너를 수동 docker run으로 새로 만들지 않습니다.')
     + p('<b>정상:</b> 5서비스 실행·현장 앱 주소 일치 → '+ref('F-05')),
     ['field_runtime.py','ct.py','maintenance_guard.py'],True)

page('F-05','현장 첫 연결 5/5','WSS·bbox·Stop 확인',
     'AI 서비스가 실행 중입니다. AI 계측 상태와 실제 브라우저 표시를 각각 확인합니다.',
     h('1. AI에서 WSS 확인')
     + code('ct config status')
     + p('<b>현재 실행 Sidecar</b>에서 <code>connected: true</code>, <code>auth_error: null</code>을 확인합니다. Web 기동 직후라면 잠시 기다린 뒤 상태만 다시 봅니다. 새 로그인을 반복하지 않습니다. 계속 미연결이면 '+ref('E-09')+'.')
     + h('2. Windows Web 브라우저에서 확인')
     + table(['순서','할 일'],[
         ('①','현장 Web 화면에서 살아 있는 입력의 카메라 1개를 선택한다.'),
         ('②','Start를 누르고 cameraId를 기록한다. 시험할 시나리오가 On인지 확인한다.'),
         ('③','영상이 나오고 사람 등 실제 대상이 있을 때 bbox·표시를 확인한다.'),
         ('④','Stop을 누르고 Web 상태가 stopped로 바뀌는지 확인한다.')],[13,87])
     + p('이전 회사망 camera3는 시험 이력일 뿐 현장 시험 카메라를 강제하지 않습니다. 탐지 대상이 없는 영상은 bbox가 없어도 정상일 수 있습니다.')
     + note('알람은 오는데 bbox가 없으면', 'Event 전송과 bbox Redis 소비는 별도 경로입니다. 새 ID를 만들거나 인증서를 지우지 말고 '+ref('E-10')+'을 확인합니다.')
     + h('3. 성공한 내용만 남기기')
     + code('ct config confirm')
     + p('예: “현장 연결, WSS, 카메라 번호, bbox, 수동 Stop 확인”. 실제 보지 않은 항목은 적지 않습니다. 기록 반출은 '+ref('O-03')+'.')
     + p('<b>다음 부팅:</b> 설정이 그대로면 '+ref('R-01')+'만 따릅니다. prepare/connect/apply를 다시 하지 않습니다.'),
     ['field_cli.py','reboot-acceptance'],True)

page('R-01','일상 운영','재부팅 후 확인과 안전한 종료',
     '현장 연결과 설정 적용을 이미 한 번 끝냈고 주소·계정·CA가 바뀌지 않았을 때 사용합니다.',
     h('1. 켠 뒤 자동 기동부터 확인')
     + code('ct status\nct config status')
     + p('5서비스 실행·주소 일치·WSS 연결이면 그대로 Web에서 사용합니다. <b>수동 ct start 전에 자동 기동 여부를 확인</b>하세요. 카메라는 자동 복구를 기대하지 말고 Web에서 확인합니다.')
     + p('서비스가 안 켜졌으면 '+ref('E-08')+', WSS만 안 붙으면 '+ref('E-09')+'. LLM 유지보수 정지처럼 이유가 명확하고 모든 보호 조건이 정상이면 <code>ct start</code>를 한 번 실행합니다.')
     + h('2. 끄거나 이동하기 전')
     + p('Web에서 실행 중인 카메라를 Stop합니다. LLM·관측을 켰다면 '+ref('A-01')+' / '+ref('A-04')+'의 종료 명령을 먼저 사용합니다.')
     + code('ct stop\nct status')
     + p('모두 중지됐는지 확인합니다. <code>STOPPED_WITH_FORCED_GATEWAY</code> 등 오류는 '+ref('E-08')+'에서 실제 중지 여부와 원인을 확인합니다. 오류를 정상 종료로 기록하지 않습니다.')
     + code('sudo poweroff')
     + p('SSH가 끊기는 것은 예상 동작입니다. 장비가 완전히 꺼진 것을 확인한 뒤 전원과 랜선을 분리합니다. 재부팅이 목적이면 마지막 명령만 <code>sudo reboot</code>로 바꿉니다.')
     + note('이번 준비본은 다음 부팅부터 현장 IP입니다', '회사망에서도 192.168.105.40으로 올라옵니다. 기존 회사망 주소로 SSH가 안 되는 것을 서버 고장으로 판단하지 않습니다. 회사망 복귀는 '+ref('N-01')+'.'),
     ['ct.py','field-nextboot','reboot-acceptance'],True)

page('C-01','설정 변경','무엇을 바꿀지 먼저 선택',
     '메뉴와 VSCode가 같은 프로필 원본을 사용합니다. 파일 저장과 실제 적용은 별개입니다.',
     code('ct profile paths\nct profile edit')
     + p('<code>ct profile edit</code>은 질문에 답하는 방식입니다. Enter는 표시된 현재값 유지이며, <code>-=비우기</code>가 있는 항목만 <kbd>-</kbd>로 비웁니다. JSON을 직접 열려면 <code>ct profile file</code>을 씁니다.')
     + table(['바꾸는 대상','저장 후 적용 경로'],[
         ('AI 유선 IP/prefix/Gateway/DNS',ref('E-01')+'에서 물리 콘솔 NIC 적용 → '+ref('F-02')+'부터 앱 전환'),
         ('Web 주소·Event 주소·WSS/Redis 포트·Redis 허용 목록',ref('C-02')+' 편집 → 서비스 정지 → '+ref('F-02')+'부터'),
         ('AI 연결 계정·비밀번호',ref('C-03')+' 저장 → 서비스 정지 → 새 준비·연결·적용'),
         ('Web 공개 CA',ref('C-04')+' 또는 '+ref('C-05')+' → 새 준비·연결·적용'),
         ('Runtime/env를 이미 직접 고쳤다',ref('C-06')+' 지원 항목을 프로필에 수용'),
         ('Python 코드',ref('A-03')+' 오프라인 APP image 패치')],[43,57])
     + note('prepare 후에는 준비 기준을 바꾸지 않습니다', '프로필·계정·CA를 수정하면 준비본과 불일치해 후속 작업을 차단합니다. pending이 있으면 먼저 '+ref('E-07')+'에서 발급·적용 상태를 구분합니다.')
     + p('프로필 생성은 <code>ct profile create</code>, 선택은 <code>ct profile select</code>, 목록은 <code>ct profile list</code>입니다. 생성 시 일반 설정만 복사되고 계정·CA는 자동 복사되지 않습니다.'),
     ['field_cli.py','field_profiles.py'])

page('C-02','설정 변경','직접 편집할 파일과 필드',
     '추천 원본은 field.json입니다. VSCode로 수정해도 되며 JSON 쉼표·따옴표·자료형을 유지합니다.',
     p('<b>현장 원본</b><br><code class="path">/workspace/contextra/setup/profiles/field.json</code>')
     + table(['필드','용도 / 현장 예시'],[
         ('network.interface / mode','enp128s31f6 / static'),
         ('network.address','192.168.105.40/24. NIC와 앱 적용을 둘 다 해야 함'),
         ('network.gateway / dns','미지정은 "" / []. 같은 /24 안의 통신은 Gateway 없이 가능'),
         ('network.ssh_port','1023 유지. 값을 바꿔도 SSH 리스너를 바꿔 주지 않음'),
         ('control_port / redis_port','38443 / 16379. 정수이며 포트를 서로 겹치지 않게 함'),
         ('redis_allow','["192.168.105.42"]. 허용 Web IPv4 목록'),
         ('artifact_lan_cidr','192.168.105.0/24. 선택 Web을 포함해야 함'),
         ('webs[].host / manager','192.168.105.42 / https://192.168.105.42'),
         ('webs[].event_url','https://192.168.105.42/api/v1/events'),
         ('webs[].server_id / connection_version','AI-01 / 선택 Web의 접속정보 개정 번호'),
         ('selected_web / media','제어 Web의 id / 선택적인 미디어 진단 목록')],[46,54])
     + p('<b>유지할 값:</b> 파일 맨 위 <code>version: 1</code>은 형식 버전입니다. 파일명과 <code>id: field</code>도 맞아야 합니다. 연결 버전과 혼동하지 않습니다.')
     + code('ct config status')
     + p('문법·주소·선택 Web을 확인한 후 '+ref('C-01')+'의 적용 경로로 이동합니다. profile 저장만으로 Runtime SHA나 실행 설정을 손대지 않습니다.'),
     ['field_profiles.py','field_cli.py'])

page('C-03','설정 변경','계정 변경과 복수 Web 관리',
     '계정 저장은 로그인 요청이 아닙니다. AI 연결용 계정은 Web에 먼저 등록돼 있어야 합니다.',
     h('1. 선택 환경과 Web을 먼저 확인')
     + code('ct config status\nct profile credentials')
     + p('login ID는 AI 연결용 값을 입력합니다. 비밀번호는 화면에 표시되지 않으며 새 값은 두 번 입력합니다. Enter는 기존 값 유지입니다. 저장만 하고 로그인하지 않습니다.')
     + p('<b>현장 계정 파일</b><br><code class="path">/workspace/contextra/setup/profiles/field.private/main.credentials.json</code><br>소유자 전용 파일입니다. Git·채팅·스크린샷에 내용을 올리지 않습니다.')
     + h('2. Web 후보가 여러 개라면')
     + p('<code>ct profile file</code>의 <code>webs</code> 배열에 서로 다른 <code>id</code>로 등록합니다. 각 항목은 id·label·host·manager·event_url·server_id가 필요합니다. 자동 로그인 우회는 하지 않습니다.')
     + code('ct profile web')
     + p('번호로 제어 Web 하나를 선택합니다. <code>redis_allow</code>에 그 Web을 포함하고, 해당 Web 전용 계정·CA를 준비합니다. 파일은 <code>field.private/&lt;web-id&gt;.credentials.json</code>과 <code>&lt;web-id&gt;.ca.crt</code>입니다.')
     + note('실패한 후보가 있어도 모든 기능이 멈추지는 않습니다', '진단에서 후보별 실패를 표시합니다. 단, 선택한 제어 Web이 실패했다고 다른 후보로 Event·로그인을 자동 전환하지 않습니다. media 목록도 참고용이며 실제 입력은 Web의 rtspUrl을 사용합니다.')
     + p('<b>다음:</b> pending이 없으면 서비스 정지 후 '+ref('F-02')+'. 이미 pending이 있으면 먼저 '+ref('E-07')+'.'),
     ['field_cli.py','field_profiles.py'])

page('C-04','CA 반입 · USB','공개 CA 선택과 안전한 USB 해제',
     '현재 field CA는 준비돼 있습니다. Web이 CA를 바꿨거나 파일을 새로 받을 때만 교체합니다.',
     h('1. USB를 꽂고 식별')
     + code('ct usb status\nct ca')
     + p('CA 메뉴에서 <b>2 = USB</b>를 선택합니다. 장치·용량·filesystem·라벨을 보고 번호를 고릅니다. 미마운트 USB는 <kbd>MOUNT</kbd> 확인 후 읽기 전용으로 마운트합니다.')
     + h('2. 파일을 고르고 지문 확인')
     + p('한국어 SSH 화면은 방향키·Enter로 탐색하고 Backspace로 상위 폴더에 갑니다. 영어/작은 터미널은 번호 선택입니다. 번호 방식의 <kbd>0</kbd>은 상위, <kbd>q</kbd>는 취소입니다. .crt/.pem/.cer 공개 CA만 선택합니다.')
     + p('출력된 <b>인증서 fingerprint</b>를 Web 담당자의 별도 전달값과 대조합니다. 파일 SHA256과 인증서 지문은 다른 값입니다. 맞으면 <kbd>TRUST</kbd>를 입력합니다.')
     + p('field/main 선택 시 복사 위치:<br><code class="path">/workspace/contextra/setup/profiles/field.private/main.ca.crt</code><br>USB 원본은 보존되며 기존 CA도 이전 지문 이름으로 백업합니다.')
     + h('3. 안전하게 해제')
     + code('ct usb unmount')
     + p('USB를 열고 있는 파일·탐색기·터미널을 닫습니다. 대상 번호를 고르고 <kbd>UNMOUNT</kbd>를 입력합니다. <b>USB unmounted</b>를 확인한 뒤 뽑습니다. busy이면 사용 중인 프로그램을 닫고 다시 확인합니다.')
     + note('데이터 디스크는 절대 해제하지 않습니다', '/backup·/training·시스템 디스크는 선택에서 보호합니다. 강제(-f)·지연(-l) 해제나 포맷은 하지 않습니다. 읽기 전용 USB에 반출 ZIP을 쓰려 하지 말고 '+ref('O-03')+'을 사용합니다.')
     + p('<b>다음:</b> 새 CA 적용은 '+ref('C-01')+'. pending이 있으면 '+ref('E-07')+'부터 확인합니다.'),
     ['field_ca.py','field_usb.py'])

page('C-05','CA 반입 · SSH','Windows에서 CA 파일 보내기',
     'USB 대신 Windows PC에 있는 공개 CA를 내부망 SSH로 보냅니다. Web CA private key는 보내지 않습니다.',
     h('1. Windows에서 공개 CA를 짧은 이름으로 준비')
     + p('탐색기에서 공개 CA 파일을 <code>manager-ca.crt</code>라는 이름으로 준비합니다. 해당 폴더에서 PowerShell을 엽니다. SSH 연결과 서버 키 확인이 먼저 돼 있어야 합니다.')
     + code('scp -P 1023 .\u005cmanager-ca.crt si@192.168.105.40:/workspace/contextra/setup/inbox/', 'Windows · PowerShell')
     + p('위 scp는 <b>접혀 보여도 한 줄로 입력</b>하고 마지막에 Enter를 누릅니다. 포트 옵션은 대문자 <b>-P</b>입니다. si SSH 인증을 사용하며 비밀번호를 명령 뒤에 붙이지 않습니다.')
     + h('2. AI에서 반입 폴더의 파일 선택')
     + code('ct ca')
     + p('Enter 또는 <kbd>1</kbd>로 기본 반입 폴더를 엽니다. <code>manager-ca.crt</code> 선택 → fingerprint 대조 → <kbd>TRUST</kbd> 순서입니다. 긴 절대 경로를 다시 입력할 필요가 없습니다.')
     + h('3. 현재 CA를 어디서 확인하나')
     + code('ct profile paths')
     + p('선택한 프로필·Web의 CA 경로가 출력됩니다. 현장과 회사망 CA는 별도 파일이며 둘을 같은 CA로 가정하지 않습니다.')
     + note('세 가지 파일의 역할', '<b>공개 CA:</b> Web이 제공한 신뢰 기준.<br><b>AI leaf 인증서:</b> AI가 만든 CSR에 대해 Web이 발급하며 AI IP가 포함됨.<br><b>CA 서명 private key:</b> Web에만 보관. AI에 복사하지 않음.')
     + p('<b>다음:</b> 새 CA 반영은 '+ref('C-01')+'. TLS 오류는 '+ref('E-03')+'.'),
     ['field_ca.py','network_prepare.py'])

page('C-06','긴급 설정 복구','직접 수정한 설정을 수용하기',
     '일반 설정을 직접 저장해 SHA가 달라졌습니다. 메뉴·진단은 계속 사용할 수 있습니다.',
     code('ct profile paths\nct emergency')
     + p('긴급 기능은 <b>전역 SHA 무시 모드가 아닙니다.</b> 지원된 일반 설정 변경을 프로필로 가져온 후 새 준비·정상 적용으로 새로운 기준을 만듭니다.')
     + table(['원본 선택','가져오는 항목'],[
         ('1 실행 Compose','Redis bind AI 주소, 선택 Web/Manager/Event 주소, serverId, WSS·Redis 포트, 미디어 허용 CIDR'),
         ('2 기존 env/overlay 재해석','위와 같은 지원 항목. 원본 경로는 ct profile paths 출력 사용'),
         ('3 site AI 주소','site.json의 ai_ip를 선택 프로필 고정 IP로 수용')],[34,66])
     + p('화면에 제시한 변경 내용을 확인하고 <kbd>IMPORT</kbd>를 입력합니다. 아직 서비스·로그인을 바꾸지는 않습니다. 프로필과 충돌한 값은 가져온 값으로 바뀌므로 방향을 확인하세요.')
     + h('직접 편집 위치와 보호 경계')
     + p('<code class="path">/workspace/contextra/runtime/state/site.json</code><br><code class="path">/workspace/contextra/data/site/runtime.env</code><br><code class="path">/workspace/contextra/data/site/secure-bridge.env</code><br>실행 Compose의 정확한 경로는 <code>ct profile paths</code>에서 찾습니다.')
     + note('임의 편집하지 않을 것', 'active.json의 hash, 진행 중 stage/intent, private key·token, image/source 기준, DB·원장. 인증서 오류나 디스크 누락은 긴급 수용으로 우회되지 않습니다. 지원 밖 env 변경도 자동 반영한다고 가정하지 않습니다.')
     + p('<b>다음:</b> 바뀐 값이 맞으면 '+ref('C-01')+' 적용 경로. pending 충돌은 '+ref('E-07')+'. 성공 후 '+ref('O-03')+'으로 변경 내용을 남깁니다.'),
     ['field_runtime.py','field_cli.py'])

page('E-01','네트워크·SSH','현장 IP가 없거나 SSH가 안 될 때',
     'IP를 바꿔야 한다면 물리 콘솔에서 진행합니다. 현재 SSH 연결이 끊어질 수 있습니다.',
     code('ip -4 -br addr\nct net status')
     + table(['상태','대응'],[
         ('enp128s31f6에 .40/24가 있음','다른 PC와 같은 현장망인지 확인. 아래 SSH 확인으로 이동'),
         ('주소 없음 / 이전 회사망 주소','field 프로필 확인 후 아래 NIC 적용'),
         ('NO-CARRIER / 랜선 링크 없음','같은 유선 포트·랜선·스위치 전원 확인. 주소만 바꿔 해결하지 않음'),
         ('다른 PC도 .40 사용','IP 충돌 해소를 담당자와 협의. 임의 주소를 여러 개 추가하지 않음')],[41,59])
     + h('NIC 적용이 필요한 경우만')
     + code('ct profile select field\nct net apply')
     + p('<kbd>NET</kbd> 확인 뒤 netplan의 유지 확인 질문에 <b>실제 접속 상태가 맞을 때 Enter</b>로 확정합니다. netplan try는 60초 안에 확인하지 못하면 복구를 시도합니다. 다른 주소가 필요하면 먼저 '+ref('C-01')+'에서 편집합니다.')
     + h('주소는 맞지만 SSH만 안 된다면')
     + code('sudo systemctl status ssh.socket --no-pager\nsudo ss -ltnp', 'AI 서버 · 물리 콘솔 si')
     + p('TCP1023 리스닝을 찾습니다. 기본22가 아닙니다. Windows 명령은 <code>ssh -p 1023 si@192.168.105.40</code>입니다. 같은 /24 망은 Gateway·인터넷 없이 연결할 수 있으나 VLAN·배선은 같아야 합니다.')
     + p('진행 중 NIC 적용을 복구할 때만 <code>ct net restore</code>와 <kbd>RESTORE</kbd>를 사용합니다. 임의로 현장 고정 설정 전체를 지우지 않습니다. 정상 → '+ref('F-02')+'.'),
     ['field_network.py','field-nextboot'])

page('E-02','권한·언어','sudo·한글·폴더 접근 문제',
     'Web 로그인 비밀번호와 Linux si의 sudo 비밀번호는 서로 다른 용도입니다.',
     h('1. 한글이 네모·기호로 나오면')
     + code('ct lang en')
     + p('물리 콘솔은 ASCII 영어를 권장합니다. SSH에서 한국어는 <code>ct lang ko</code>, 자동 선택은 <code>ct lang auto</code>입니다. 선택은 저장됩니다. 영어 모드의 한글 파일명은 번호로 고릅니다.')
     + h('2. sudo 입력과 계정 확인')
     + code('whoami\nsudo -v')
     + p('기본 계정은 si입니다. sudo 입력 중 글자·별표가 안 보이는 것은 정상입니다. <code>sudo -i</code>로 root가 됐다면 <code>exit</code>로 si로 돌아갑니다. <code>ct</code> 메뉴 안에서는 인증을 재사용하지만 sudo 권한을 영구 개방하지 않습니다.')
     + p('<b>LLM·agent·GUI는 sudo 없이 si로</b> 실행합니다. root에서 실행해 막혔다고 보호 검사를 지우지 않습니다.')
     + h('3. VSCode 폴더가 안 보이거나 저장이 거절되면')
     + code('ct access')
     + p('si ACL을 재적용한 뒤 VSCode 탐색기를 새로고침합니다. 필요하면 창을 다시 엽니다. 파일 소유자·내용은 바꾸지 않으며 별도 데이터 디스크와 인증 필수 보호 영역은 확인합니다.')
     + note('이 경우에는 권한을 억지로 열지 마세요', '/backup·/training이 마운트되지 않았다면 '+ref('E-08')+'부터 확인합니다. 인증 session/TLS/private key 영역은 의도된 보호 대상입니다. chmod777·chown 일괄 적용은 하지 않습니다.')
     + p('ct 명령을 찾을 수 없으면 <code>/usr/local/bin/ct status</code>로 확인합니다. 설치 스크립트를 일괄 재실행하지 않습니다.'),
     ['ct.py','field_ui.py','workspace_access.py'])

page('E-03','연결 실패','TCP·TLS·시간을 구분하기',
     'Web이 꺼졌거나 주소·포트·CA가 다를 수 있습니다. 인증 실패로 단정하기 전에 연결 단계부터 확인합니다.',
     code('ct diagnose\ntimedatectl status')
     + table(['결과','뜻과 다음 행동'],[
         ('TCP 연결 실패','선택 Web 전원·IP·HTTPS 포트·방화벽/배선 확인. 계정 비밀번호를 바꿀 단계가 아님'),
         ('TCP 연결됨','그 포트에 도달했다는 뜻. TLS·로그인·WSS까지 성공한 것은 아님'),
         ('TLS / certificate verify 실패','선택 프로필 CA, 서버 시간, Web 인증서의 IP/SAN·체인 확인. '+ref('C-04')),
         ('HTTP 응답이 나옴','통신 후 앱 응답. LOGIN/CERTIFICATE와 code를 보고 '+ref('G-01')+'로 이동')],[38,62])
     + h('현장 Web에 도달하는지 보조 확인')
     + code('ping -c 3 192.168.105.42\nct net arp status')
     + p('ping이 막혀도 TCP가 열려 있을 수 있습니다. ARP는 같은 망의 IP와 장치 MAC 대응을 보여 줍니다. 회사망에서 사용했던 MAC을 현장에 복사하지 않습니다.')
     + note('시계가 틀리면 인증서가 정상이어도 실패할 수 있습니다', '폐쇄망에서는 인터넷 시간 동기화가 없을 수 있습니다. 담당자와 정확한 현재 시간을 대조하고, 큰 오차가 있으면 서비스 정지 후 OS 시간을 바로잡습니다. 인증서 검증을 끄거나 가짜 날짜로 맞추지 않습니다.')
     + p('이전 connect의 결과가 불명확하면 원격 발급 여부부터 확인합니다. 네트워크가 나아졌다고 connect/retry를 계속 누르지 않습니다. 다음 → '+ref('E-07')+'.'),
     ['field_cli.py','field_diagnostics.py'])

page('E-04','로그인 오류','HTTP401: 계정부터 확인',
     '오류 출력의 phase가 LOGIN인지 CERTIFICATE인지 먼저 봅니다. 401은 버전 충돌400과 다릅니다.',
     code('ct config status')
     + table(['실패 단계','확인할 담당과 내용'],[
         ('LOGIN + 401','Web: 계정 존재·활성·AI 유형 확인. AI: 선택 Web·AI ID·비밀번호 확인'),
         ('CERTIFICATE + 401','로그인 뒤 발급 요청의 token/세션 문제일 수 있음. Web의 세션 상태 확인'),
         ('400 + VERSION code','비밀번호가 아니라 접속정보 개정 문제. '+ref('E-05'))],[41,59])
     + h('비밀번호가 바뀐 것이 확인된 경우')
     + p('서비스를 정지하고 pending을 먼저 확인합니다. <b>명확한 LOGIN_REJECTED이고 session·인증서가 없는 준비본</b>일 때만 아래처럼 선택 해제합니다.')
     + code('ct config cancel\nct profile credentials')
     + p('첫 명령은 <kbd>CANCEL</kbd> 확인입니다. 실패 준비본은 보존합니다. 두 번째 명령에서 새 계정 값을 숨김 입력합니다. 이후 '+ref('F-03')+'에서 새 prepare/connect를 진행합니다.')
     + note('이 상황이면 cancel하지 않습니다', 'session/certificate exists, 결과 불명확, apply 진행 중이면 '+ref('E-07')+'로 갑니다. token 파일을 손으로 지우거나 다른 환경의 세션을 복사하지 않습니다.')
     + p('새 ID로 바꾸려면 Web에 먼저 AI 계정을 등록해야 합니다. AI의 요청만으로 Web 사용자 계정을 생성하지는 않습니다. ID나 비밀번호를 바꿨다고 연결 버전을 자동으로 올릴 필요도 없습니다.'),
     ['field_cli.py','field_diagnostics.py','auth_control.py'])

page('E-05','연결 버전 오류','HTTP400: 접속정보 개정 번호',
     'CONNECTION_VERSION_CONFLICT 또는 STALE_CONNECTION_VERSION일 때만 이 절차를 사용합니다.',
     table(['같은 Web·AI ID의 요청','Web의 해석'],[
         ('같은 번호 + 같은 접속정보','같은 설정 재요청으로 허용'),
         ('같은 번호 + 다른 접속정보','충돌. 새 접속정보에는 더 큰 번호 필요'),
         ('더 작은 번호','오래된 개정으로 거절'),
         ('더 큰 유효 번호','갱신 후보. 반드시 기존 번호 +1만 가능한 것은 아님')],[48,52])
     + p('접속정보에는 AI 주소·controlPort·Redis 접속정보 등이 포함됩니다. 프로필 맨 위 <code>version: 1</code>은 다른 값입니다. Web의 최신 번호를 로컬 이력만으로 확정할 수 없습니다.')
     + h('1. 거절된 미발급 준비본을 보존·선택 해제')
     + code('ct config status\nct config cancel')
     + p('LOGIN_REJECTED이고 session/인증서가 없는 경우에 <kbd>CANCEL</kbd>. 거절되면 '+ref('E-07')+'로 이동하고 파일을 직접 삭제하지 않습니다.')
     + h('2. 새 번호를 입력하고 새로 준비')
     + code('ct config version\nct config prepare')
     + p('버전 질문에 <b>Web 담당자가 확인한 현재 번호보다 큰 정수</b>를 입력합니다. 저장 후 출력값을 확인하고 prepare에는 <kbd>PREPARE</kbd>. 이후 '+ref('F-03')+'의 connect 단계부터 진행합니다.')
     + note('반복 시도로 큰 번호를 계속 올리지 않습니다', '허용 정수는 1~9007199254740991입니다. 원격 최신 번호가 모르면 모른다고 기록하고 담당자와 확인합니다. 같은 설정 재부팅이나 단순401에는 번호를 올리지 않습니다.'),
     ['connection_versions.py','field_cli.py','login-version-conflict'])

page('E-06','인증서 발급 오류','HTTP500: Web 서명 설정 확인',
     'LOGIN은 통과했지만 CERTIFICATE 단계에서 실패했습니다. WSS에 들어가기 전 단계입니다.',
     '<div class="flow"><span>AI 로그인</span><b>→</b><span>Web이 CSR 서명</span><b>→</b><span>AI 인증서 검증</span><b>→</b><span>apply · WSS</span></div>'
     + table(['reason / 상태','대응'],[
         ('CERTIFICATE_SIGNING_NOT_CONFIGURED','Web의 기존 CA 인증서·서명키 경로 설정 필요'),
         ('CERTIFICATE_SIGNING_FAILED','Web의 CA/key 일치·기간·읽기 권한·실제 예외 확인'),
         ('INTERNAL_ERROR만 있고 reason 없음','500만으로 원인을 확정하지 말고 Web 로그의 해당 요청 확인')],[55,45])
     + h('Web 담당자에게 전달')
     + p('“AI 로그인 뒤 인증서 발급 API에서 실패합니다. 실행 앱의 <code>PLATFORM_MANAGER_CA_CERT_FILE</code>과 <code>PLATFORM_MANAGER_CA_KEY_FILE</code> 및 실제 파일 접근을 확인해 주세요.” 자세한 체크는 '+ref('W-01')+'.')
     + p('CA와 서명 private key는 <b>Web이 소유</b>합니다. AI에 공개 CA가 있다고 Web 서명 설정까지 준비된 것은 아닙니다. 새 CA를 임의 생성하거나 Web private key를 AI에 복사하지 않습니다.')
     + h('Web 수정 완료 후 기존 준비본으로 1회 재시도')
     + code('ct config retry')
     + p('Web 수정과 이전 처리 결과를 확인한 뒤 <kbd>RETRY</kbd>, 이어서 <kbd>CONNECT</kbd>를 입력합니다. <code>CERTIFICATE_VERIFIED</code>이면 '+ref('F-04')+'로 갑니다. 같은 준비본·버전·세션을 보존합니다.')
     + note('하지 않을 것', '서명 설정 문제를 연결 버전 증가·계정 변경·CA 재생성·인증 폴더 삭제로 해결하지 않습니다. 여전히 실패하면 같은 요청을 반복하지 않고 새로운 Web 오류 근거를 확인합니다.'),
     ['field_diagnostics.py','network_prepare.py','manual-checkpoint'])

page('E-07','중단·복구','pending 상태별 다음 행동',
     '준비·원격 요청·로컬 적용 중 어디서 멈췄는지를 먼저 구분합니다. 모두 같은 초기화 대상이 아닙니다.',
     code('ct config status')
     + table(['상태','허용되는 다음 행동'],[
         ('미요청 준비본 / 명확한 로그인 거절<br>session·인증서 없음','ct config cancel → CANCEL<br>필요한 값 수정 후 새 prepare'),
         ('TCP timeout / 원격 결과 불명확','Web에서 처리·발급 여부 확인. 확인 없이 retry/cancel하지 않음'),
         ('CERTIFICATE 실패<br>이미 session 있음','기존 준비본 보존. Web 원인 해결 후 retry 1회'),
         ('CERTIFICATE_VERIFIED<br>아직 적용 전','ct config apply → APPLY'),
         ('apply 도중 중단 / apply-pending','원인 확인·해결 후 ct config resume → APPLY'),
         ('로컬 적용을 되돌려야 함','복구 범위를 확인한 뒤 ct config restore → RESTORE')],[49,51])
     + h('준비 후 프로필·계정·CA를 바꿨다면')
     + p('준비본 지문이 달라 후속 요청을 차단합니다. 미발급이면 도구의 cancel을 사용합니다. 이미 세션·인증서가 있으면 값과 원격 상태를 먼저 정합화해야 합니다. stage의 JSON이나 SHA를 손으로 맞추지 않습니다.')
     + note('restore가 되돌리지 않는 것', '이 적용이 바꾼 로컬 설정만 대상으로 합니다. Web DB 개정·이전 token의 유효성·DB·미디어·원장은 함께 되돌리지 않습니다. 이후 사용자 변경과 충돌하면 복구를 멈춰 보존합니다.')
     + p('<b>최종 Compose 재해석 불일치</b>가 다시 나오면 이번 설치에는 ports 교체 수정이 포함됐으므로 다른 차이를 조사해야 합니다. 검사 해제·구버전 도구 복사·같은 apply 무한 반복은 하지 않습니다.'),
     ['field_cli.py','field_runtime.py','side-ports-fix'])

page('E-08','서비스·보호','컨테이너가 안 켜지거나 Stop 오류',
     '하드웨어·데이터 디스크·적용 상태를 지키기 위한 기동 차단인지 먼저 확인합니다.',
     code('ct status\nsudo systemctl status contextra-runtime --no-pager')
     + table(['관측','확인 / 다음'],[
         ('첫 현장 부팅의 주소 불일치','회사망 앱 설정이 남아 있을 수 있음. '+ref('F-02')+'부터 현장 적용'),
         ('/backup·/training 또는 bind mount 누락','아래 mount 확인. 빈 폴더를 대체 저장소로 사용하지 않음'),
         ('inflight / apply 진행 중','원인 확인 후 '+ref('E-07')+'에서 resume/restore'),
         ('source/image 지문 오류','승인한 APP 패치 기록과 대조. active hash 수동 수정 금지'),
         ('LLM 실행 중','ct-llm stop 후 GPU 반환 확인. '+ref('A-01'))],[44,56])
     + code('findmnt /backup\nfindmnt /training')
     + p('디스크가 빠졌거나 mount가 실패하면 접속·전원·시스템 로그를 확인합니다. fstab을 지우거나 /backup·/training을 USB처럼 해제하지 않습니다.')
     + h('Stop에서 Gateway 강제 종료 경고가 나왔다면')
     + p('<code>STOPPED_WITH_FORCED_GATEWAY</code>는 서비스가 멈췄어도 정상 graceful 종료로 확인되지 않았다는 뜻입니다. <code>ct status</code>로 잔여 실행을 확인하고 아래 로그를 읽습니다. 실행 중이면 전원을 뽑지 않습니다.')
     + code('ct logs ai-gateway\nsudo journalctl -u contextra-runtime -n 40 --no-pager')
     + p('로그 외부 전달 전 비밀정보를 확인합니다. 같은 실패에서 반복 Start·timeout 확대·docker 강제 재생성으로 보호를 우회하지 않습니다.'),
     ['ct.py','maintenance_guard.py','boot-dependency-verification'])

page('E-09','WSS','서비스는 켜졌지만 연결이 안 됨',
     'WSS는 Web과 AI의 제어 연결입니다. 인증서 발급 성공과 같은 상태가 아닙니다.',
     code('ct config status\nct diagnose')
     + table(['관측','다음 행동'],[
         ('STATUS_UNAVAILABLE','Sidecar가 실행·healthy인지 ct status 확인. 중지 상태에서는 조회 불가가 예상됨'),
         ('connected:false, Web도 꺼짐','Web 전원·앱 기동을 먼저 해결. 연결 지연 동안 무조건 재로그인하지 않음'),
         ('주소·CA·인증 오류 표시','실패 단계대로 '+ref('E-03')+' / '+ref('E-04')+' 확인'),
         ('주소 정상·auth_error 없음, 계속 false','Web의 AI 등록 주소·controlPort·인증 체인·WSS 요청 상태 확인'),
         ('connected:true','제어 연결 확인. bbox는 '+ref('F-05')+' / '+ref('E-10')+'에서 별도 확인')],[48,52])
     + code('ct logs ai-control-sidecar\nct logs ai-gateway')
     + p('최근 로그만 확인하고 Web 담당자에게 같은 시각의 로그를 요청합니다. HTTPS 로그인 대상은 Web이고, WSS는 등록한 AI control 주소·포트와 인증 상태도 맞아야 합니다.')
     + note('계정이 바뀐 것이 확실한 경우만 재연결', '선택 프로필과 실제 실행 환경이 같음을 확인하고 '+ref('C-03')+'부터 새 준비를 진행합니다. 기존 ct auth는 현재 Runtime용 호환 경로로, 선택 프로필의 새 주소를 자동 적용하는 명령이 아닙니다.')
     + p('Web이 늦게 켜졌을 때 상태 조회로 회복을 확인합니다. 인증·TLS 오류가 계속되면 동일한 로그인 반복 대신 원인을 고칩니다.'),
     ['auth_control.py','field_cli.py','manual-checkpoint'])

page('E-10','브라우저 표시','알람은 오지만 bbox가 안 보임',
     'Event 알람·WSS 제어·Redis bbox 메타데이터는 서로 다른 경로입니다.',
     '<div class="flow"><span>AI 추론</span><b>→</b><span>AI 호스트 Redis</span><b>→</b><span>Web 소비</span><b>→</b><span>브라우저 표시</span></div>'
     + h('1. 영상과 시나리오 상태부터')
     + p('영상이 재생되는지, 해당 시나리오가 On인지, 실제 탐지 대상이 있는지 봅니다. 대상이 없거나 시각화 score를 넘지 못하면 bbox가 없을 수 있습니다. cameraId·시각·영상 유무를 기록합니다.')
     + code('ct config status\nct logs analytics')
     + p('AI 앱 주소와 선택 Web이 현장값인지, Redis·Analytics가 실행 중인지 확인합니다. 오류 로그가 없다는 것만으로 Redis FPS나 Web 소비까지 성공했다고 단정하지 않습니다.')
     + h('2. Web 담당자가 확인할 것')
     + table(['확인','현장 기대'],[
         ('Web DB의 AI Redis host/port','192.168.105.40:16379. Docker 내부 주소나 예전 회사망 주소가 아님'),
         ('실행 환경 BBOX_REDIS_HOST','이전 서버 고정값이 남았는지 확인. DB 사용 모드와 충돌하지 않게 함'),
         ('기존 Redis 소비 연결','등록값을 바꿔도 기존 연결이 남을 수 있음. Web 소비자 재연결/재기동 확인'),
         ('같은 RTP의 provider 결과','여러 provider 메타데이터를 올바르게 소비하는지 Web 규칙 확인')],[44,56])
     + note('ID 변경으로 우회하지 않습니다', '같은 AI ID의 새 접속정보는 연결 버전으로 개정합니다. Web 고정 Redis 환경변수가 있으면 ID를 바꿔도 잘못된 서버를 계속 읽을 수 있습니다. '+ref('W-01')+'을 전달하세요.')
     + p('Web 수정 후 카메라 1개로 Start·표시·Stop만 다시 확인합니다. 단순히 알람이 울렸다고 bbox 정상으로 기록하지 않습니다.'),
     ['manual-checkpoint','reboot-acceptance'])

page('W-01','Web 담당자 전달용','AI 연결·인증서·Redis 체크리스트',
     'AI는 192.168.105.40, Web은 192.168.105.42입니다. Windows Docker에서도 실제 앱/컨테이너가 읽는 값을 확인합니다.',
     table(['단계','Web에서 확인할 항목'],[
         ('① AI 계정','AI-01 존재·활성·AI actor 유형, 올바른 serverId와 비밀번호. 브라우저 관리자 계정과 혼동하지 않음'),
         ('② 접속정보 개정','새 주소·control38443·Redis16379와 기존 연결 버전 비교. 다른 내용은 기존보다 큰 번호'),
         ('③ 인증서 발급','PLATFORM_MANAGER_CA_CERT_FILE<br>PLATFORM_MANAGER_CA_KEY_FILE<br>실행 앱이 읽는 CA/key 경로·읽기 권한·일치·기간 확인'),
         ('④ WSS','등록 AI 주소와38443 도달성, 발급 인증서/SAN·신뢰 체인, 연결 요청 로그'),
         ('⑤ bbox Redis','BBOX_REDIS_HOST 고정값이 DB 등록값을 가리지 않는지, 실제 소비 host/port·ACL·TLS 확인'),
         ('⑥ 소비자 갱신','실행 환경 반영 후 이전 Redis 연결이 아닌 새 주소로 소비하는지 확인')],[22,78])
     + h('Windows Docker에서 특히 주의')
     + p('CA/key의 <b>호스트 파일 mount</b>와 <b>컨테이너 내부 경로</b>가 모두 맞아야 합니다. 새 환경변수·mount를 Compose에 추가한 경우, 기존 컨테이너 restart만으로 반영되지 않을 수 있으므로 해당 앱 컨테이너의 <b>재생성</b> 여부를 확인합니다. DB·volume은 지우지 않습니다.')
     + note('전달할 짧은 문장', '“AI의 실패 phase·HTTP·code·reason과 시각을 전달합니다. 로그인 계정/버전, Web 서명 CA/key, WSS 대상, bbox Redis 실제 소비 주소를 해당 단계에서 확인해 주세요. 수정 완료 뒤 AI는 필요한 요청만 1회 재시도하겠습니다.”')
     + p('CA private key는 Web에만 보관합니다. 새 CA를 임의 생성하지 말고 기존 신뢰 체계를 유지합니다. AI 변경 없이 Web 설정 보완으로 해결되는 경우도 있습니다.'),
     ['manual-checkpoint','web-share-brief'],True)

page('O-01','자원·VSCode','CPU·메모리가 계속 높을 때',
     'Contextra 부하와 VSCode 자동 탐색을 구분합니다. CPU가 높다는 이유만으로 프로세스를 모두 종료하지 않습니다.',
     code('ct resources\nct vscode status')
     + p('<code>ct resources</code>의 CPU는 <b>논리코어 1개 = 100%</b>입니다. 1000%는 약 10코어 사용을 뜻하며 전체 서버 평균100%와 다릅니다. RAM은 프로세스 RSS이고 공유 페이지가 중복될 수 있습니다.')
     + table(['role / 관측','대응'],[
         ('vscode-file-search','불필요한 파일 탐색인지 확인한 뒤 해당 PID만 선택 종료 가능'),
         ('vscode-extensionHost','먼저 파일 저장·확장 작업 중지. 종료 시 확장 기능이 잠시 멈춤'),
         ('other / Runtime / SSH','ct 선택 종료 보호 대상. 용도를 모르고 종료하지 않음'),
         ('managed differences 있음','원격 설정이 바뀐 항목을 검토. 필요한 경우만 ct vscode apply')],[46,54])
     + h('선택 종료가 필요한 경우만')
     + code('ct resources stop PID', placeholder=True)
     + p('<b>PID</b>는 방금 확인한 숫자로 바꿉니다. 확인 입력은 <kbd>STOP 실제PID</kbd>입니다. si의 VSCode 탐색/extensionHost만 지원하며 PID 재사용을 검사합니다. 종료 실패를 강제 kill로 자동 확대하지 않습니다.')
     + p('extensionHost 종료 뒤 화면에서 필요하면 F1 → <b>Developer: Restart Extension Host</b>. 전체 SSH·터미널을 종료하지 않습니다.')
     + note('지속 설정', '원격 설정은 <code>/home/si/.vscode-server/data/Machine/settings.json</code>입니다. / 탐색 접근은 유지하면서 가상 filesystem·대용량 자동 검색, Pylance 전체 색인을 제한합니다. 사용자 workspace 설정이 덮어쓸 수 있습니다.')
     + p('설정 적용/해제는 <code>ct vscode apply</code> / <code>ct vscode restore</code>와 해당 확인 입력입니다. 프로세스를 자동 재시작하지 않습니다. Linux cache 비우기·광범위 pkill은 하지 않습니다.'),
     ['field_resources.py','vscode_safety.py'])

page('O-02','저장 경로','데이터 위치와 용량 보호',
     '/workspace/contextra 아래에서 모아 볼 수 있지만 모델·자료의 실제 저장 디스크는 별도입니다.',
     table(['용도','통합 보기 / 실제 저장'],[
         ('설치·실행','/workspace/contextra/runtime'),
         ('편집 소스','/workspace/contextra/source/crowd-0f172d94'),
         ('모델','/workspace/contextra/data/models<br>/training/contextra/models'),
         ('Event 미디어','/workspace/contextra/data/artifacts<br>/backup/contextra/artifacts'),
         ('학습 스냅샷','/workspace/contextra/data/training<br>/backup/contextra/training'),
         ('반입 / 반출','/workspace/contextra/setup/inbox<br>/workspace/contextra/setup/outbox')],[24,76])
     + code('df -h / /backup /training\nct status')
     + table(['영역','보관 설정'],[
         ('Event','상한4TB / 정리 목표3.6TB'),
         ('학습','상한2TB / 정리 목표1.8TB'),
         ('filesystem','최소 여유100GB 보호. TB/GB는10진 기준'),
         ('루트 /','최소 여유15GiB 확보. 임의 Docker prune으로 자료를 지우지 않음'),
         ('보호','작성·전송·재시도 등 보호 작업과 소유권 불명 자료는 임의 삭제하지 않음')],[27,73])
     + note('용량 경고가 나도 폴더를 통째로 지우지 않습니다', 'Event와 학습은 독립 상한입니다. 원장·미디어 정합성이 있으므로 DB나 camera 폴더를 수동 초기화하지 않습니다. 디스크 누락·공간 부족은 기록 후 승인된 정리 정책으로 대응합니다.')
     + p('공용 bind view는 파일의 별도 복사본이 아닙니다. 양쪽 경로에서 삭제하면 원본 자료에 영향을 줄 수 있습니다. 데이터 디스크 마운트 확인은 '+ref('E-08')+'.'),
     ['ICERTI_OFFLINE_MAINTENANCE.ko.md','CT_REUSE_HANDOFF.ko.md'])

page('O-03','기록·반출','오늘 바꾼 내용을 다음 작업에 전달',
     '설정 변경 기록과 Python 코드 변경 기록은 서로 다른 기능입니다. 비밀번호·token·private key는 반출하지 않습니다.',
     h('1. 설정 적용이 완료된 경우')
     + code('ct config note\nct config confirm')
     + p('수동으로 한 작업과 실제 확인한 항목만 적습니다. 이 명령은 <b>마지막으로 적용한 설정</b>의 기록에 붙습니다. 아직 적용이 실패한 pending 작업의 로그를 자동 수집하는 기능은 아닙니다.')
     + code('ct config export')
     + p('메모를 포함해 비밀정보가 없음을 검토하고 <kbd>EXPORT</kbd>. 출력된 ZIP 절대 경로를 기록합니다. 포함 파일은 SUMMARY.ko.md·changes.redacted.json·verification.json입니다.')
     + h('2. Windows로 가져온 뒤 USB에 보관')
     + code('scp -P 1023 si@192.168.105.40:/workspace/contextra/setup/outbox/*.zip .', 'Windows · 저장할 폴더의 PowerShell')
     + p('이 예시는 outbox의 ZIP들을 가져옵니다. 한 개만 필요하면 <code>*.zip</code> 대신 출력된 파일명을 씁니다. Windows에서 USB로 복사한 뒤 안전하게 제거합니다. AI의 ct USB 마운트는 읽기 전용이라 반출 쓰기용이 아닙니다.')
     + h('3. 아직 실패 중이라면')
     + p('<code>ct config status</code>에서 phase·HTTP·error·code·reason과 시각을 적습니다. 전체 session 파일 대신 화면의 비민감 오류 요약만 전달합니다. 별도 메모는 <code>/workspace/contextra/setup/notes</code>에 둘 수 있습니다.')
     + p('Python 변경 기록은 '+ref('A-03')+'의 <code>ct work</code>를 사용합니다. 자동으로 모든 shell 명령을 녹화하거나 모든 secret을 제거해 주지는 않습니다.'),
     ['field_cli.py','work_record.py'])

page('A-01','부록 · 로컬 LLM','인터넷 없이 한국어로 질문하기',
     '이미 준비된 Qwen GGUF와 실행기를 사용합니다. 추가 다운로드 없이 Contextra와 GPU를 번갈아 사용합니다.',
     h('1. 분석 서비스를 먼저 정지')
     + p('Web 카메라를 모두 Stop한 뒤 아래를 실행합니다. LLM 사용 중에는 실시간 영상 분석을 동시에 시험하지 않습니다.')
     + code('ct stop\nct-llm status')
     + p('Contextra 실행 목록이 없어야 합니다. 다른 GPU 작업·잔여 메모리가 있으면 도구가 시작을 거절할 수 있습니다. 관련 없는 프로세스를 임의로 죽이지 않습니다.')
     + h('2. si에서 한국어 CLI 시작')
     + code('ct-llm chat')
     + p('sudo 없이 실행합니다. 한글이 깨지는 물리 콘솔에서는 SSH 터미널이나 '+ref('A-05')+'의 GUI를 사용합니다. 짧은 질문부터 입력하고 긴 로그 전체보다 오류·목적·관련 코드를 좁혀 전달합니다.')
     + note('질문 예시', '“Contextra 연결 중 CERTIFICATE 단계 HTTP500이 발생했습니다. 지금은 코드를 수정하지 말고 원인 후보와 확인 순서를 한국어로 설명해 주세요. 비밀번호나 token은 요구하지 마세요.”')
     + h('3. 종료하고 GPU 반환 후 분석 복귀')
     + p('LLM 터미널에서 Ctrl+C로 종료하거나 다른 si 터미널에서 아래를 실행합니다.')
     + code('ct-llm stop\nct-llm status')
     + p('종료가 확인되면 <code>ct start</code> → '+ref('F-05')+' 상태 확인. GPU 사용이 남아 있으면 원인부터 확인합니다. <code>ct-llm cpu</code>는 CPU 대안이지만 느리며 Contextra 정지 조건은 동일합니다.'),
     ['ct_llm.py','install_local_tools.py','offline-prep-validation'])

page('A-02','부록 · LLM 도구 연결','agent와 Windows SSH 터널',
     'LLM API는 AI 서버 localhost18080에서만 열립니다. 외부 전체 주소로 바인딩하거나 방화벽을 새로 열 필요가 없습니다.',
     h('AI에서 API와 agent 사용')
     + p(ref('A-01')+'의 정지·자원 확인을 먼저 끝냅니다. si 터미널1에서 API를 켜 두고, 터미널2에서 승인된 소스 폴더의 agent를 시작합니다.')
     + code('ct-llm serve', 'AI · si 터미널1, 실행 상태 유지')
     + code('cd /workspace/contextra/source/crowd-0f172d94\nct-agent', 'AI · si 터미널2')
     + p('명령 실행·파일 수정은 개별 승인합니다. 관리 명령 자동 승인·sudo 우회는 기본 기능이 아닙니다. 수정 기록은 '+ref('A-03')+'에서 먼저 시작합니다.')
     + h('Windows 브라우저/API로 연결')
     + code('ssh -N -p 1023 -L 18081:127.0.0.1:18080 si@192.168.105.40', 'Windows · PowerShell')
     + p('이 창을 켜 둔 채 브라우저에서 <b>http://127.0.0.1:18081</b>, API는 끝에 <code>/v1</code>을 사용합니다. API key 인증이 필요한 경우 설치된 개인 설정의 키를 안전하게 사용하며 문서나 채팅에 붙이지 않습니다.')
     + p('서버 측 키 위치:<br><code class="path">/home/si/.local/opt/contextra-offline/20260928-r01/api-key</code><br>Windows OpenCode를 쓸 경우 해당 PC에 프로그램·설정이 미리 있어야 합니다. SSH 원격 agent는 AI 서버 파일을, Windows 로컬 agent는 Windows 파일을 다룹니다.')
     + note('사용 후', 'Windows 터널은 Ctrl+C, agent는 해당 세션 종료, AI LLM은 ct-llm stop. GPU 반환 확인 후에만 ct start. 터널만 닫아서는 AI의 LLM이 종료되지 않습니다.'),
     ['ct_llm.py','agent.py','Open-LlmTunnel.ps1'])

page('A-03','부록 · 긴급 코드 패치','Python 수정·기록·APP image 적용',
     '이미 설치한 SDK·모델·의존성은 그대로 두고 승인한 Python 앱 코드만 수정할 때 사용합니다.',
     h('1. 정지와 변경 전 기록')
     + code('ct stop\nct work begin /workspace/contextra/source/crowd-0f172d94')
     + p('실제 편집본을 바꾼 경우 그 경로를 사용합니다. VSCode로 작업본의 <code>app</code> 아래를 수정합니다. Runtime은 이 폴더를 직접 bind하지 않으므로 저장만으로 실행본이 바뀌지 않습니다.')
     + h('2. 검사와 변경 기록')
     + code('ct work note "수정 이유와 실제 검사 결과"\nct work end')
     + p('SUMMARY.ko.md·changes.diff를 검토합니다. 실행하지 않은 테스트를 성공으로 쓰지 않습니다. 모델 추측만으로 운영 패치를 적용하지 않습니다. LLM을 썼다면 적용·기동 전에 종료합니다.')
     + h('3. 검토한 APP 변경 적용')
     + code('ct apply /workspace/contextra/source/crowd-0f172d94')
     + p('확인 입력 <kbd>APPLY</kbd>. 현재 image 위에 Python APP만 담은 오프라인 image를 만들며 자동 Start/rollback하지 않습니다. <b>ct config apply</b>는 연결 설정 적용이므로 서로 다른 명령입니다.')
     + p('성공 후 <code>ct start</code>와 '+ref('F-05')+'의 최소 확인. 실패하면 stage·이전 image를 보존하고 같은 작업을 무작정 반복하지 않습니다. SDK/native/새 dependency는 이 APP-only 경로의 범위가 아닙니다.')
     + h('4. 작업 기록 반출')
     + code('ct work export RECORD --reviewed', placeholder=True)
     + p('<b>RECORD</b>는 work end가 출력한 기록 폴더 이름입니다. 결과는 ZIP/outbox가 아니라 아래 폴더의 <b>tar.gz</b>입니다. 출력된 파일명만 가져와도 됩니다.')
     + code('scp -P 1023 si@192.168.105.40:.local/state/contextra-work/*.tar.gz .', 'Windows · 저장할 폴더의 PowerShell')
     + p('내용을 검토해 가져오며 DB·원본영상·인증은 일반 패치 묶음에 넣지 않습니다.'),
     ['apply_source.py','work_record.py','ct.py'])

page('A-04','부록 · 서버 관측','Grafana·Prometheus 수동 사용',
     '설치된 관측 기능은 기본 Off입니다. 별도 project로 실행하며 Contextra의 모델·batch 설정을 바꾸지 않습니다.',
     code('ct observe start\nct observe status')
     + p('호스트 CPU/RAM·GPU·디스크와 실행 중인 Triton/Contextra 지표를 봅니다. analytics가 중지돼 있으면 그 target은 관측되지 않는 것이 정상입니다. 새 컨테이너로 바뀌었으면 관측 Stop/Start로 target을 갱신합니다.')
     + table(['접속','주소'],[
         ('Grafana','AI localhost33000'),
         ('Prometheus','AI localhost39090'),
         ('수집 / 보관','15초 / 7일 또는 retained blocks10GiB 기준'),
         ('자료','/backup/contextra/observability')],[29,71])
     + h('Windows에서 Grafana 보기')
     + code('ssh -N -p 1023 -L 33000:127.0.0.1:33000 si@192.168.105.40', 'Windows · PowerShell')
     + p('브라우저에서 <b>http://127.0.0.1:33000</b>. 기본 관리자 ID는 admin이며 비밀번호는 서버 보호 파일에서 관리합니다. 공개 PDF·일반 작업 ZIP에 넣지 않습니다.')
     + p('<code class="path">/etc/contextra-maintenance/observability/grafana-password</code>')
     + note('수치 읽기', 'Grafana CPU는 전체 논리코어 평균0~100%입니다. ct resources의 코어당100%와 분모가 다릅니다. GPU는 NVIDIA-SMI 표본입니다. 저장 상한은 WAL/compaction까지 정확히10GiB를 보장하는 값이 아닙니다.')
     + code('ct observe stop')
     + p('사용 후 관측을 중지하고 Windows 터널도 Ctrl+C로 닫습니다. 성능시험 중에는 관측 On/Off 조건을 기록합니다.'),
     ['observability.py','OFFLINE_OPERATIONS.ko.md','offline-prep-validation'])

page('A-05','부록 · 임시 GUI','물리 콘솔에서만 GUI 사용',
     '기본은 CLI입니다. 임시 Xfce는 보조 기능이며 실제 모니터 화면·한글 입력은 현장에서 확인합니다.',
     h('1. 물리 콘솔 si 세션에서 시작')
     + code('ct stop\nct-gui', 'AI · 물리 콘솔 si, sudo 없이')
     + p('SSH나 root에서는 실행을 거절합니다. Contextra가 실행 중이거나 이미 Display :0이 있으면 자동 종료하지 않고 멈춥니다.')
     + h('2. GUI가 열리면')
     + p('파일·터미널 확인용으로 사용합니다. LLM이 필요하면 '+ref('A-01')+'의 자원 조건을 따릅니다. 해상도·입력기 설정 변경은 실제 모니터에서 확인하고, 네트워크를 임의 DHCP로 바꾸지 않습니다.')
     + h('3. 다시 CLI로 돌아가기')
     + p('GUI 메뉴의 <b>로그아웃</b>으로 세션을 끝냅니다. 전원을 뽑아 종료하지 않습니다. LLM도 종료한 뒤 <code>ct start</code>로 분석 서비스를 복귀합니다.')
     + note('GUI가 안 열려도 ct는 사용할 수 있습니다', '물리 콘솔의 영어 ct 명령 또는 Windows SSH 터미널을 사용하세요. GUI 실패만으로 OS·드라이버 재설치나 display 서비스 강제 종료를 하지 않습니다.')
     + table(['기능','기본 상태'],[
         ('Ubuntu 부팅','CLI / multi-user.target'),
         ('ct 관리','si 터미널, 필요한 동작만 sudo'),
         ('LLM / GUI / 관측','수동 실행, 기본 Off'),
         ('Contextra Runtime','완료된 설정과 기동 보호 조건이 맞으면 systemd 자동 기동')],[45,55]),
     ['ct-gui','gui-session.sh','offline-prep-validation'])

page('N-01','스인 회사망 전용','현장에서 회사망으로 다시 가져올 때',
     '이 페이지는 스마트인사이드 회사망 전용입니다. 현장망 .40/.42 설정과 섞지 않습니다.',
     h('1. 물리 콘솔에서 회사 프로필과 DHCP 선택')
     + code('ct stop\nct profile select office')
     + code('ct net apply')
     + p('<kbd>NET</kbd>와 netplan 유지 확인을 거쳐 DHCP로 전환합니다. 오늘 회사망 주소는 192.168.50.7이었지만 <b>다음 DHCP 주소가 같다고 보장하지 않습니다.</b> 실제 <code>ip -4 -br addr</code> 출력으로 SSH 접속합니다.')
     + h('2. 회사 Web과 계정·CA 별도 확인')
     + p('회사 Web은 192.168.50.101입니다. office의 계정·CA·연결 버전은 field와 별개입니다. 같은 AI-01을 다른 AI 장비가 사용 중이면 먼저 조율하고, 신규 연결은 '+ref('F-02')+'의 절차를 office 값으로 진행합니다.')
     + h('3. 회사 Web ARP가 잘못됐을 때만')
     + code('ct net arp status')
     + p('담당자가 확인한 MAC과 다를 때만 <code>ct net arp set MAC</code>과 <kbd>ARP</kbd>를 사용합니다. MAC은 그날 확인한 실제 값으로 바꿉니다. <code>ct net arp clear</code>는 동적 탐색으로 되돌립니다.')
     + note('회사망 MAC 예외는 현장에 가져가지 않습니다', '이 기능은 현재 kernel의 임시 매핑입니다. PERMANENT라는 출력도 재부팅 영속 설정을 뜻하지 않습니다. 회사망 예외를 현장 Web에 복사하거나 자동 boot 규칙으로 추가하지 않습니다.')
     + p('현장으로 다시 가져갈 때는 물리 콘솔에서 field 선택·고정 IP 적용을 확인합니다. 회사망에서 고정 IP로 전환하면 현재 SSH가 끊길 수 있습니다.'),
     ['field_diagnostics.py','field_network.py','field-nextboot'])

page('Z-01','현장 기록지','확인 결과와 도움 요청 양식',
     '다음 문의 때 항목 번호와 비민감 결과만 전달하면 같은 설명을 반복하지 않아도 됩니다.',
     table(['확인','현장 기록'],[
         ('날짜·담당자','________________________________________'),
         ('AI 실제 IP / 선택 프로필','________________________________________'),
         ('선택 Web / AI ID / 연결 버전','________________________________________'),
         ('로그인·인증서 발급','□ 확인  □ 실패  □ 미실행'),
         ('설정 apply / 5서비스 / WSS','□ 확인  □ 실패  □ 미실행'),
         ('cameraId / bbox / Stop','________________________________________'),
         ('실패 항목 번호 / 시각','________________________________________'),
         ('phase / HTTP / code / reason','________________________________________'),
         ('수정 파일·필드 / 결과','________________________________________'),
         ('반출 ZIP 위치','________________________________________')],[45,55])
     + note('인터넷 GPT 또는 동료에게 전달할 문장', '“Contextra 현장 매뉴얼의 [항목 번호]를 진행 중입니다. 직전 성공 단계는 [단계]이고 지금 출력은 [비민감 오류]입니다. 같은 요청을 반복하지 않았습니다. AI에서 할 일과 Web 담당자가 할 일을 구분해서 다음 한 단계만 안내해 주세요.”')
     + p('비밀번호·token·private key·전체 session 파일은 전달하지 않습니다. Git 참고 문서의 주소 자리표시자는 실제 현장값을 확인한 뒤 사용합니다.')
     + h('문서 기준')
     + p('2026-09-30 보존 코드·설치·수동시험 기록 기준입니다. 회사망 재부팅 및 camera3 시각화는 확인됐고, 이후 field 다음 부팅 설정을 준비했습니다. 현장 실연결·물리 GUI·Windows 도구 전체 동작을 완료로 표시하지 않습니다. 기능을 추가하거나 서버에 재접속해 만든 문서가 아닙니다.'),
     ['all'])

QUICK_IDS = ['G-00','F-01','F-02','F-03','F-04','F-05','R-01','W-01']
