// [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다.
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
if(health.bootstrap&&!health.bootstrap.complete)banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion||'확인 전')}, 첫 Agent ${health.bootstrap.firstAgent?'등록됨':'미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
relayPage('/ops/backup','backup','백업과 복원','backup');relayPage('/ops/relocation','relocation','서버 이전 · 소유권 이전','relocation');
// ---------------- 서버(§28)
app.get('/ops/servers',(ctx)=>shell(ctx,{title:'서버와 용량',nav:'servers',page:'servers',body:`<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>`}));
app.get('/ops/servers/:id',(ctx)=>{consts=core.getServer(d,ctx.params.id);if(!s)returnshell(ctx,{title:'서버',nav:'servers',page:'server',body:R.statePanel('failed','서버가 없습니다')});constps=core.publicServer(d,s);constjobs=d.jobs.list({subjectType:'server',subjectId:s.id,limit:50}).map(describe);constagentJobs=d.store.all('SELECT id, agent_id, kind, status, progress, created_at, ended_at, error, user_message FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50',s.agent_id||'');
app.get('/ops/releases',(ctx)=>shell(ctx,{title:'배포 릴리스',nav:'releases',page:'releases',body:R.releasesView({releases:core.listReleases(d),running:core.bootstrapState(d),modules:Object.keys(MODULES),deployOrder:DEPLOY_ORDER,targets:d.store.all('SELECT * FROM module_targets ORDER BY module, server_id')},changelog())}));
returnshell(ctx,{title:`작업 · ${j.label||j.kind}`,nav:'jobs',page:'job',id:j.id,body:`${R.jobBlock(v)}${R.childrenBlock(d.jobs.childrenSummary(j.id))}<h3>Agent 작업</h3>${R.agentJobsTable(d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at',j.id))}${j.result?`<h3>결과</h3><pre class="pre">${esc(JSON.stringify(redact(j.result),null,1))}</pre>`:''}<h3>기록</h3><pre class="pre">${esc((j.log||[]).map((l)=>`${l.at}${l.line}`).join('\n')||'없음')}</pre>`});});
// ---------------- 사건·알림(§31.2)
app.get('/ops/incidents',(ctx)=>shell(ctx,{title:'장애와 알림',nav:'incidents',page:'incidents',body:R.incidentsView(d.store.all('SELECT * FROM incidents ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200'),listNotifications(d.store,{groupId:null,limit:100}))+`<p class="muted">공개 상태 페이지: <a href="/status" target="_blank" rel="noopener">/status</a> — 개인정보·내부 위치 없음, 고객 영향 없는 내부 경고는 올리지 않음, 복구 완료는 실제 외부 확인 뒤 게시(§31.3).</p>`}));
app.get('/ops/recovery',(ctx)=>shell(ctx,{title:'플랫폼 복구',nav:'recovery',page:'recovery',body:`<div class="tabs"><a href="#emergency" class="on" data-tab="emergency">긴급 복구(§33)</a><a href="#dr" data-tab="dr">재해복구(§34)</a></div><section id="emergency">${R.emergencyView({sessions:emergency.listSessions(d),scopes:emergency.SCOPES,actions:emergency.ACTIONS})}</section><section id="dr" hidden>${R.drView({readiness:dr.readiness(d),snapshots:dr.listSnapshots(d),restores:d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'),drills:d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50'),hal:core.selfHealth(d)})}</section>`}));
// ---------------- 운영자 계정(§25·§3.3)
app.get('/ops/account',(ctx)=>shell(ctx,{title:'운영자 계정',nav:'account',page:'account',body:R.accountsView({me:ctx.operator,recentlyAuthed:session.recentlyAuthed(ctx.session),via:ctx.opsVia,sessions:session.listSessions(d.store,ctx.operator.id,'operator').map((s)=>({...s,current:s.id===ctx.session.id})),grants:d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 100'),accounts:d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount)})}));
// [ops UI 추가] 서버 화면용 읽기 전용: 릴리스 변경 이력(VERSION_HISTORY.md 절)
constmodState=(m)=>{constt=d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?',m);if(!t.length)returnnull;conststale=t.some((x)=>!x.last_check_at||Date.now()-Date.parse(x.last_check_at)>10*60*1000);return{ok:t.every((x)=>x.health==='ok'),stale,versions:[...newSet(t.map((x)=>x.actual_version))]};};
incidents:d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'),drift:d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'),dr:dr.readiness(d),emergencyOpen:d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''),generatedAt:nowIso()};
/** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */
functionmailServicesView(rb){
constwarn=[];if(rb.duplicates.length)warn.push(`<div class="alert critical">⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}</div>`);if(rb.secretLeaks)warn.push(`<div class="alert critical">⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인</div>`);
{l:'TLS·DNS',f:(s)=>`${s.evidence.tls?.validTo?'인증서 '+R.fmt(s.evidence.tls.validTo):'인증서 정보 없음'}<br><span class="muted">${esc((s.evidence.dns&&s.evidence.dns[s.domain]&&(s.evidence.dns[s.domain].mx||[]).join(', '))||'MX 정보 없음')}</span>`},
],rb.services,{empty:'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다'});
returnwarn.join('')+t+`<p class="muted">총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).</p>`;