/** * hal/ui/render.mjs — 운영자 콘솔 화면 조각 렌더러(순수 함수, Node·브라우저 공용). * * 서버(hal/ui.mjs)는 HAL 자체 데이터로 첫 화면을 그리고, 브라우저(ops.js)는 같은 함수로 /ops/api 응답을 다시 그린다. * 그래서 이 파일은 node 모듈을 import 하지 않는다(브라우저가 /ops/ui/render.mjs 로 그대로 받는다). * 규칙(§2.2·§2.3): 상태는 글자+기호로(색은 보조), 오래된 정보는 '정보 지연', 일부 실패는 '부분 실패'로 드러내고 전체 성공처럼 보이지 않는다. */ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); /** 시각을 KST 로 */ export function fmt(iso) { if (!iso) return '—'; const t = Date.parse(iso); if (isNaN(t)) return esc(iso); const k = new Date(t + 9 * 3600 * 1000); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; } export const ago = (iso) => { if (!iso) return '확인 전'; const s = Math.round((Date.now() - Date.parse(iso)) / 1000); return s < 60 ? `${s}초 전` : s < 3600 ? `${Math.round(s / 60)}분 전` : s < 86400 ? `${Math.round(s / 3600)}시간 전` : `${Math.round(s / 86400)}일 전`; }; export const bytes = (n) => { n = Number(n || 0); if (!n) return '0'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; }; export const pct = (a, b) => (b > 0 ? Math.round((a / b) * 100) : null); const JOB_LABEL = { not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중', awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료', needs_user: '사용자 확인 필요', cancelled: '취소됨' }; const HEALTH_LABEL = { ok: '정상', degraded: '성능 저하', partial_outage: '일부 장애', down: '중단', unknown: '확인 전', stale: '정보 확인 지연', not_provided: '제공되지 않음' }; const SERVER_LABEL = { ready: '배치 가능', pending: '등록 대기', maintenance: '유지보수', isolated: '격리', offline: '응답 없음' }; export const jobLabel = (s) => JOB_LABEL[s] || s || ''; /** 상태 배지(글자+기호) */ export function badge(kind, label) { const sym = { ok: '●', done: '✔', failed: '✘', warn: '!', info: 'ⓘ', running: '◌', waiting: '◔', paused: '⏸', neutral: '○', stale: '⧖' }[kind] || '○'; return ` ${esc(label)}`; } export const jobBadge = (status) => badge(status === 'done' ? 'done' : status === 'failed' ? 'failed' : ['running', 'verifying', 'reverting'].includes(status) ? 'running' : ['awaiting_external', 'needs_user', 'awaiting_approval', 'awaiting_payment', 'queued'].includes(status) ? 'waiting' : status === 'paused' ? 'paused' : 'neutral', jobLabel(status)); export const healthBadge = (h) => badge(h === 'ok' ? 'ok' : h === 'down' ? 'failed' : h === 'stale' || h === 'unknown' ? 'stale' : 'warn', HEALTH_LABEL[h] || h); /** 공통 상태 패널 */ export function statePanel(kind, text, { href, label } = {}) { const L = { loading: '불러오는 중', empty: '비어 있음', stale: '정보 지연', partial: '부분 실패', failed: '전체 실패', forbidden: '권한 없음' }; return `
${esc(L[kind] || kind)}${text ? `

${esc(text)}

` : ''}${href ? `${esc(label || '이동')}` : ''}
`; } /** 중계(포털) 호출 실패 → 정보 지연. 포털 데이터로 정상 판정하지 않는다. */ export function relayError(e) { const code = e && (e.code || e.error) || ''; if (code === 'portal_unreachable' || code === 'portal_not_linked' || code === 'service_unreachable') return statePanel('stale', `포털에서 정보를 받지 못했습니다(${code}). 마지막으로 확인한 정보가 없어 상태를 판정할 수 없습니다 — 연결 복구 후 다시 확인하세요`); if (code === 'unauthenticated' || code === 'access_denied') return statePanel('forbidden', e.message || '권한이 없습니다'); if (code === 'reauth_required') return statePanel('forbidden', '최근 재인증이 필요합니다(10분). 상단의 "재인증"을 누르세요'); return statePanel('failed', (e && e.message) || '오류'); } /** 표. cols: [{k, l, f?(row)}]. 360px 에서는 table.r 로 카드처럼 접힌다. */ export function table(cols, rows, { empty = '항목이 없습니다', cls = '' } = {}) { if (!rows || !rows.length) return statePanel('empty', empty); return `${cols.map((c) => ``).join('')}${rows.map((r) => `${cols.map((c) => ``).join('')}`).join('')}
${esc(c.l)}
${c.f ? c.f(r) : esc(r[c.k])}
`; } export function kv(pairs) { return `
${pairs.filter((p) => p).map(([k, v]) => `
${esc(k)}
${v == null || v === '' ? '—' : v}
`).join('')}
`; } const stepSym = { done: '✔', running: '◌', failed: '✘', waiting: '◔', needs_user: '!', pending: '○' }; /** 작업 상세 블록(§29): 번호·종류·대상·시작 주체·현재 단계·진행률·시각·외부 대기·재시도·되돌리기·오류 */ export function jobBlock(j, { actions = true } = {}) { if (!j) return statePanel('empty', '작업이 없습니다'); const subj = j.subject || {}; const target = subj.target || subj.module || (subj.payload && (subj.payload.siteId || subj.payload.domain)) || j.subjectId || ''; return `
${jobBadge(j.status)} ${esc(j.label || j.kind)} ${esc(j.id)}${j.stale ? ' ' + badge('stale', '정보 확인 지연') : ''}
${kv([['종류', esc(j.kind)], ['대상', esc(target)], ['시작 주체', esc(j.actorType === 'operator' ? `운영자 ${j.createdBy || ''}` : j.actorType === 'system' ? `자동(${j.createdBy || 'system'})` : `사용자 ${j.createdBy || ''}`)], ['고객 그룹', esc(j.groupId || '—')], ['현재 단계', j.currentStep ? esc(j.currentStep.title) + (j.currentStep.detail ? ` — ${esc(j.currentStep.detail)}` : '') : '—'], ['진행률', `${j.progress || 0}%`], ['시작·갱신', `${fmt(j.startedAt)} · ${fmt(j.updatedAt)}`], ['예상 완료', j.etaAt ? fmt(j.etaAt) : esc(j.etaUnknownReason || '산정할 수 없음')], j.waitReason ? ['외부 대기', esc(j.waitReason)] : null, j.maxAttempts > 1 ? ['자동 재시도', `${j.attempt}/${j.maxAttempts}회${j.nextRunAt && j.status === 'queued' ? ' · 다음 ' + fmt(j.nextRunAt) : ''}`] : null, j.userMessage ? ['필요한 조치', esc(j.userMessage)] : null, j.error ? ['오류 요약', `${esc(j.error)} (${esc(j.errorCode || '')})`] : null])} ${actions ? `
${j.canRetry ? `` : ''}${j.canResume ? `` : ''}${j.canCancel ? `` : ''}${['done', 'failed', 'needs_user'].includes(j.status) ? `` : ''}${['queued', 'awaiting_external'].includes(j.status) ? `` : ''}
` : ''}
`; } /** 자식(대상별) 결과: 일부 실패는 '부분 실패' */ export function childrenBlock(c) { if (!c || !c.total) return ''; const overall = c.overall === 'done' ? badge('done', '모든 대상 완료') : c.overall === 'partial' ? badge('warn', '부분 실패 — 대상별 확인 필요') : c.overall === 'failed' ? badge('failed', '모든 대상 실패') : badge('running', '대상별 진행 중'); return `

대상별 결과 ${overall}

${table([{ l: '대상', f: (k) => `${esc(k.label || k.id)}
${esc((k.subject && (k.subject.serverId || k.subject.target)) || '')}` }, { l: '상태', f: (k) => jobBadge(k.status) }, { l: '시도', f: (k) => `${k.attempt || 0}${k.next_run_at ? ' · 다음 ' + fmt(k.next_run_at) : ''}` }, { l: '오류', f: (k) => `${esc(k.error || '')}` }], c.children)}`; } export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r) => `${esc(r.id)}
${esc(r.kind)}` }, { l: 'Agent', f: (r) => `${esc(r.agent_id || '')}` }, { l: '상태', f: (r) => jobBadge(r.status) }, { l: '진행', f: (r) => `${r.progress || 0}%` }, { l: '오류·조치', f: (r) => `${esc(r.error || '')} ${esc(r.user_message || '')}` }, { l: '시각', f: (r) => `${fmt(r.created_at)} → ${fmt(r.ended_at)}` }], rows, { empty: 'Agent 작업이 없습니다' }); } /** 서버 표(§28.1) */ export function serversTable(servers) { return table([ { l: '서버', f: (s) => `${esc(s.name)}
${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(s.location || '위치 미입력')}${s.slots && Object.keys(s.slots).length ? `
슬롯 ${esc(Object.entries(s.slots).map(([k, v]) => `${k} ${s.slotsFree && s.slotsFree[k] != null ? s.slotsFree[k] + '/' : ''}${v}`).join(', '))}` : ''}` }, { l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}
마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}` }, { l: '건강', f: (s) => healthBadge(s.stale ? 'stale' : s.health) }, { l: 'CPU·메모리', f: (s) => s.metrics ? `${esc(String(s.metrics.cpu ?? '—'))}% · ${esc(String(s.metrics.memPct ?? pct(s.metrics.memUsed, s.metrics.memTotal) ?? '—'))}%` : '정보 없음' }, { l: '디스크·inode', f: (s) => s.capacity.diskTotalBytes ? `${pct(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes, s.capacity.diskTotalBytes)}% (${bytes(s.capacity.diskFreeBytes)} 여유)${s.metrics && s.metrics.inodePct != null ? ` · inode ${s.metrics.inodePct}%` : ''}${s.capacity.exhaustEta ? `
고갈 예상 ${fmt(s.capacity.exhaustEta)}` : ''}` : '정보 없음' }, { l: 'I/O·네트워크', f: (s) => s.metrics ? `${bytes(s.metrics.ioRead)}/${bytes(s.metrics.ioWrite)} · ↓${bytes(s.metrics.netIn)} ↑${bytes(s.metrics.netOut)}` : '정보 없음' }, { l: '배치·예약', f: (s) => `${s.capacity.sitesHosted || 0}개 · 예약 ${bytes(s.capacity.reservedBytes)} · 안전 여유 ${bytes(s.capacity.safetyMarginBytes)}` }, { l: '신규 배치', f: (s) => s.acceptsNew ? badge('ok', '허용') : badge('warn', '중지') + (s.rawStatus === 'maintenance' ? `
${esc(s.maintenanceNote || '유지보수')}` : s.rawStatus === 'isolated' ? `
격리: ${esc(s.isolatedReason || '')}` : '') }, { l: '시계·인증서', f: (s) => `${s.clockSkewMs == null ? '—' : Math.abs(s.clockSkewMs) > 60000 ? `${s.clockSkewMs}ms` : `${s.clockSkewMs}ms`}
${s.facts && s.facts.certExpiresAt ? '인증서 ' + fmt(s.facts.certExpiresAt) : '인증서 정보 없음'}` }, { l: '모듈 버전', f: (s) => Object.entries(s.modules || {}).map(([m, v]) => `${esc(m)} ${v.drift ? `${esc(v.actual || '없음')}→${esc(v.target)}` : esc(v.actual || v.target || '—')} ${healthBadge(v.health)}`).join('
') || '배치 없음' }, ], servers, { empty: '등록된 서버가 없습니다' }); } export function serverDetail(s, { jobs = [], agentJobs = [] } = {}) { const webmakerData = s.facts?.webmakerInventory || null; const webmakerSites = Array.isArray(webmakerData?.sites) ? webmakerData.sites.filter((x) => x && typeof x === 'object' && !Array.isArray(x)) : []; const inventoryErrors = Array.isArray(webmakerData?.errors) ? webmakerData.errors : []; const webmakerInventory = webmakerData ? `

Webmaker 사이트 디렉터리 인벤토리

Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조한 운영 인벤토리입니다. 전체 ${Number(webmakerData.total || 0).toLocaleString('ko-KR')}개 · 수집 ${Number(webmakerData.returned || 0).toLocaleString('ko-KR')}개${webmakerData.truncated ? ' · 일부만 표시됨' : ''}. 페이지 본문·프롬프트·연락처·인증정보는 HAL로 보내지 않습니다.

${inventoryErrors.length || webmakerData.errorsTruncated ? statePanel('failed', `인벤토리 스캔 오류 ${inventoryErrors.length}건${webmakerData.errorsTruncated ? ' 이상(전송 한도에 따라 일부 오류만 표시)' : ''}: ${inventoryErrors.join(', ')}`) : ''}${table([ { l: '사이트', f: (x) => `${esc(x.id)}
${esc(x.title || '제목 정보 없음')}` }, { l: '계정 연결', f: (x) => esc(x.ownerId || '메타데이터 없음') }, { l: '배포 상태', f: (x) => badge(x.deploymentState === 'deployed' ? 'ok' : x.deploymentState === 'unknown' ? 'warn' : 'neutral', x.deploymentState || 'unknown') }, { l: '서비스 상태', f: (x) => esc(x.status || '—') }, { l: '도메인', f: (x) => (Array.isArray(x.domains) ? x.domains : []).map(esc).join('
') || '—' }, { l: '크기·파일', f: (x) => `${bytes(x.bytes || 0)} · ${Number(x.files || 0).toLocaleString('ko-KR')}개` }, { l: '최근 변경', f: (x) => fmt(x.updatedAt) }, { l: '메타데이터', f: (x) => badge(x.metadata === 'ok' ? 'ok' : 'warn', x.metadata || 'unknown') }, ], webmakerSites, { empty: '사이트 디렉터리가 없습니다' })}` : ''; return `

연결·역할

${kv([['역할·위치', `${esc(s.role)} · ${esc(s.location || '—')}`], ['연결 상태', badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : 'warn', SERVER_LABEL[s.status] || s.status)], ['마지막 heartbeat', `${fmt(s.lastSeenAt)} (${esc(ago(s.lastSeenAt))})`], ['Agent', `${esc(s.agentId || '미연결')} ${esc(s.agentVersion || '')}`], ['시계 차이', s.clockSkewMs == null ? '—' : `${s.clockSkewMs}ms`], ['승인', s.approvedAt ? fmt(s.approvedAt) : badge('waiting', '승인 대기')], ['신규 배치', s.acceptsNew ? '허용' : '중지'], ['유지보수·격리', esc(s.maintenanceNote || s.isolatedReason || '—')]])}

용량

${kv([['디스크', s.capacity.diskTotalBytes ? `${bytes(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes)} / ${bytes(s.capacity.diskTotalBytes)} (여유 ${bytes(s.capacity.diskFreeBytes)})` : '정보 없음'], ['예약량', bytes(s.capacity.reservedBytes)], ['안전 여유', bytes(s.capacity.safetyMarginBytes)], ['배치 서비스', `${s.capacity.sitesHosted || 0}개`], ['고갈 예상', s.capacity.exhaustEta ? fmt(s.capacity.exhaustEta) : '예상 없음'], ['백업 저장소', s.facts && s.facts.backupStore ? `${bytes(s.facts.backupStore.usedBytes)} / ${bytes(s.facts.backupStore.totalBytes)} · 최근 검증 ${fmt(s.facts.backupStore.lastVerifiedAt)}` : '해당 없음']])}

모듈(목표 vs 실제)

${table([{ l: '모듈', k: 'name' }, { l: '목표', k: 'target' }, { l: '실제', f: (m) => m.drift ? `${esc(m.actual || '없음')}` : esc(m.actual || '—') }, { l: '건강', f: (m) => healthBadge(m.health) }, { l: '마지막 확인', f: (m) => fmt(m.lastCheckAt) }, { l: '오류', f: (m) => `${esc(m.lastError || '')}` }], Object.entries(s.modules || {}).map(([name, m]) => ({ name, ...m })), { empty: '배치된 모듈이 없습니다' })}
${webmakerInventory}

대기열·반복 실패

${agentJobsTable(agentJobs)}

이 서버의 작업

${table([{ l: '작업', f: (j) => `${esc(j.label)}` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], jobs)}`; } /** 90일 지표 SVG(§28.1). samples 는 최신순. key: 'cpu'|'disk'|'mem'|'net'. */ export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) { const rows = (samples || []).slice().reverse(); if (!rows.length) return statePanel('empty', '지표가 아직 없습니다'); const val = (r) => key === 'cpu' ? Number(r.cpu || 0) : key === 'mem' ? (r.mem_total ? (r.mem_used / r.mem_total) * 100 : 0) : key === 'disk' ? (r.disk_total ? (r.disk_used / r.disk_total) * 100 : 0) : key === 'inode' ? (r.inode_total ? (r.inode_used / r.inode_total) * 100 : 0) : Number(r.net_out || 0); const vals = rows.map(val); const max = key === 'net' ? Math.max(1, ...vals) : 100; const t0 = Date.parse(rows[0].at); const t1 = Date.parse(rows[rows.length - 1].at) || t0 + 1; const pts = rows.map((r, i) => `${(((Date.parse(r.at) - t0) / Math.max(1, t1 - t0)) * (w - 40) + 30).toFixed(1)},${(h - 20 - (vals[i] / max) * (h - 40)).toFixed(1)}`).join(' '); const label = { cpu: 'CPU %', mem: '메모리 %', disk: '디스크 %', inode: 'inode %', net: '송신 바이트' }[key] || key; return `${esc(label)}${key === 'net' ? bytes(max) : '100%'}${fmt(rows[0].at)}${fmt(rows[rows.length - 1].at)}`; } /** 릴리스 화면(§32.1) */ /** * [0.3.17] 배포 릴리스 화면: 기본은 "모듈별 최신 릴리스 배포" — 모듈마다 최신 승인 릴리스·기능 설명·필요한 것(requires)·제공(provides)·설치 방법·현재 배치, 상호 연결, 역할별 통합 배포. * HAL 은 배포 대상이 아니다(먼저 설치하는 도구). 과거 버전은 접힌 표에서 보고 대상별 되돌리기로 돌아간다. */ export function releasesView(data, changelog = {}) { const running = data.running || {}; const targets = data.targets || []; const servers = data.servers || {}; const latest = data.latest || {}; const guides = data.guides || {}; const modules = data.modules || []; const approved = (data.releases || []).filter((r) => r.status === 'approved'); const sname = (id) => (servers[id] && servers[id].name) || id; const placed = (m) => { const ts = targets.filter((t) => t.module === m); return ts.length ? ts.map((t) => `${esc(sname(t.server_id))} ${t.actual_version ? `${esc(t.actual_version)}` : '버전 없음'}${t.target_version && t.actual_version !== t.target_version ? ` (목표 ${esc(t.target_version)})` : ''}${t.active === 0 ? ' 비활성' : ''} ${healthBadge(t.health)}`).join('
') : '배치 없음'; }; const cards = modules.map((m) => { const g = guides[m] || { label: m, description: '', requires: [], provides: [], install: '' }; const v = latest[m]; return `

${esc(g.label)} (${esc(m)}) ${v ? badge('ok', `최신 ${v}`) : badge('warn', '승인된 릴리스 없음')}

${esc(g.description)}

필요한 것
${(g.requires || []).map(esc).join(', ') || '—'}
제공
${(g.provides || []).map(esc).join(', ') || '—'}
서버 역할
${(g.roles || []).map(esc).join('·')}
설치 방법
${esc(g.install)}
현재 배치
${placed(m)}
${v ? `` : ''}${g.singleton ? ` ` : ''}
`; }).join(''); const history = table([ { l: '버전', f: (r) => `${esc(r.version)}${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 버전') : ''}` }, { l: 'main 커밋', f: (r) => `${esc(String(r.main_commit).slice(0, 12))}` }, { l: '빌드', f: (r) => fmt(r.built_at) }, { l: '검사', f: (r) => r.checks ? (r.checks.testsPassed === false ? badge('failed', '테스트 실패') : r.checks.testsPassed ? badge('ok', '테스트 통과') : badge('neutral', '검사 없음')) + (r.checks.dirty ? ' ' + badge('warn', '수정본(dirty)') : '') : badge('neutral', '검사 없음') }, { l: '승인', f: (r) => r.status === 'approved' ? badge('ok', `승인 ${r.approved_by || ''}`) + `
${fmt(r.approved_at)}` : badge('waiting', '승인 대기') + ` ` }, { l: '모듈별 배치', f: (r) => modules.map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === r.version).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '—'}`; }).join(' · ') }, { l: '변경 이력', f: (r) => changelog[r.version] ? `
보기
${esc(changelog[r.version])}
` : '항목 없음' }, { l: '이 버전 배포', f: (r) => r.status === 'approved' ? modules.filter((m) => r.artifacts && (r.artifacts[m] || (guides[m] && /Agent 안의/.test(guides[m].description)))).map((m) => ``).join(' ') : '승인 후' }, ], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' }); return `

HAL 실행 버전: ${esc(running.hal_running_version || '확인 전')}${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · 부트스트랩 미완료'} — HAL 은 배포 대상이 아닙니다(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.

최신 릴리스 배포(모듈별)

${cards}

상호 연결(어떤 모듈이 있어야 무엇이 동작하는가)

역할별 서버 프로필

${table([{ l: '프로필', f: (p) => `${esc(p.label)} (${esc(p.id)})` }, { l: '역할', k: 'role' }, { l: '배포 모듈(순서)', f: (p) => p.modules.map(esc).join(' → ') }, { l: '설명', f: (p) => esc(p.description) + (p.singleton ? ' 하나만' : '') }, { l: '슬롯', f: (p) => esc(Object.entries(p.slots || {}).map(([k, v]) => `${k} ${v}`).join(', ') || '—') }], data.profiles || [], { empty: '프로필 없음' })}
과거 버전 보기 — 등록된 릴리스 ${(data.releases || []).length}개(승인 ${approved.length}). 이전 버전으로 돌아가려면 아래 배치 상태의 "되돌리기" 또는 그 버전의 배포 단추를 쓰세요.${history}

모듈 배치 상태(대상별)

${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `${esc(sname(t.server_id))} ${esc(t.server_id)}` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `${esc(t.actual_version || '없음')}` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) + (t.active === 0 ? ' ' + badge('neutral', '비활성') : '') }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `${esc(t.last_error || '')}` }, { l: '', f: (t) => ` ` }], targets, { empty: '배치된 모듈이 없습니다' })}`; } /** [0.3.17] 작업 진행 창(dock): 진행 중 작업과 최근 히스토리. 두 목록을 모두 그리고 탭은 hidden 으로 전환한다. */ export function jobsDockHtml({ active = [], history = [], tab = 'active' } = {}) { const row = (j) => `
${esc(j.label || j.kind)} ${esc(j.kind)} ${jobBadge(j.status)}${j.currentStep ? ` ${esc(j.currentStep.title)}` : ''}${j.progress != null && !['done', 'failed', 'cancelled'].includes(j.status) ? ` ${Math.max(0, Math.min(100, Number(j.progress) || 0))}%` : ''}${j.error ? ` ${esc(j.error)}` : ''} ${esc(ago(j.updatedAt))}
`; return `
${active.length ? active.map(row).join('') : '
진행 중인 작업이 없습니다.
'}
${history.length ? history.map(row).join('') : '
최근 작업 기록이 없습니다.
'}
`; } /** [0.3.17] 대상 서버 선택 목록: 라디오(단일) 또는 체크(여러 개). 준비(ready)되지 않은 서버는 고를 수 없다. eligible 이 있으면 그 서버만 미리 체크. */ export function serverChoiceHtml(servers, { name = 'serverId', multi = false, eligible = null } = {}) { if (!servers || !servers.length) return '
선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.
'; return `
${servers.map((s) => { const ok = s.status === 'ready'; const checked = multi && ok && (!eligible || eligible.includes(s.id)); return ``; }).join('')}
`; } // ---------------- [0.4.0] 통합 설정 관제 const SRC_LABEL = { global: '전체', server: '이 서버' }; const showVal = (v) => Array.isArray(v) ? v.join(', ') : typeof v === 'boolean' ? (v ? '켬' : '끔') : String(v); /** 설정 입력 폼. cat = 설정 목록의 모듈 하나(groups·keys), scope = 'global' | 'server:'. 비밀값은 값을 보이지 않고 설정됨/없음과 새 값 입력만. 전체 범위에는 서버별 항목이 없다. */ export function settingsFormHtml(cat, { scope = 'global', values = {}, secrets = {}, effective = null } = {}) { const isServer = String(scope).startsWith('server:'); const field = (k) => { const v = values[k.key]; const has = v !== undefined && v !== null; const inh = isServer && effective && effective.sources && effective.sources[k.key] === 'global' && !has ? `전체 값 ${esc(showVal(effective.values[k.key]))}` : ''; const tags = `${k.restart ? ' 재시작 필요' : ''}`; const id = `set-${esc(k.key.replace(/[^a-zA-Z0-9_-]/g, '_'))}`; let input; if (k.type === 'secret') { const on = !!secrets[k.key]; input = ` ${on ? '● 설정됨' : '○ 없음'}${on ? ` ` : ''}`; } else if (k.type === 'bool') input = ``; else if (k.type === 'enum') input = ``; else input = ``; return `
${input} ${inh}
`; }; const groups = (cat.groups || []).map((g) => { const keys = g.keys.filter((k) => isServer || k.scope !== 'server'); if (!keys.length) return ''; return `
${esc(g.label)}${keys.map(field).join('')}
`; }).join(''); return `
${groups}
비운 칸은 지정을 지웁니다(${isServer ? '전체 값을 따름' : '모듈 기본값을 따름'}). 비밀값은 새 값을 넣을 때만 바뀝니다. 저장만으로는 서버에 반영되지 않습니다 — 아래 "서버에 적용"을 누르세요.
`; } /** 서버별 적용 상태 표 */ export function settingsStateHtml(rows, { module = '' } = {}) { const st = (r) => r.status === 'failed' ? badge('failed', '적용 실패') : r.status === 'ok' && r.upToDate ? badge('ok', '적용됨') : r.status === 'ok' ? badge('warn', '적용 필요(설정 변경됨)') : badge('warn', '적용 필요'); return `${table([{ l: '서버', f: (r) => `${esc(r.serverName || r.serverId)}
${esc(r.serverStatus || '')}` }, { l: '상태', f: st }, { l: '적용 시각', f: (r) => fmt(r.appliedAt) }, { l: '적용된 설정', f: (r) => `${esc(String(r.appliedHash || '—').slice(0, 19))}` }, { l: '오류', f: (r) => r.error ? `${esc(r.error)}` : '—' }, { l: '', f: (r) => `` }], rows || [], { empty: '이 모듈이 배치된 서버가 없습니다 — 배포 릴리스에서 먼저 배포하세요' })}
`; } /** 통합 설정 관제 화면 본문 */ export function settingsPageHtml({ modules = [], catalog = {}, module, scope = 'global', placements = [], stored = {}, effective = null, overview = {} }) { const cat = catalog[module] || { groups: [] }; const ov = overview[module] || { servers: [] }; const tabs = `
${modules.map((m) => `${esc((catalog[m] || {}).label || m)}${overview[m] && overview[m].stale ? ` ${overview[m].stale}` : ''}`).join('')}
`; const scopeSel = `
`; return `${tabs}

${esc(cat.description || '')}

${scopeSel}${settingsFormHtml(cat, { scope, values: stored.values || {}, secrets: stored.secrets || {}, effective })}

서버별 적용 상태

${settingsStateHtml(ov.servers, { module })}
`; } export function incidentsView(incidents, notifications = []) { return `

사건(같은 원인은 하나로 묶임)

${table([{ l: '사건', f: (i) => `${esc(i.title)}
${esc(i.key)}` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}
${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `
${esc(i.public_note || '')}` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : ` ` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}

운영자 알림

${table([{ l: '분류', k: 'category' }, { l: '내용', f: (n) => `${esc(n.title)}${n.count > 1 ? ` ×${n.count}` : ''}
${esc(n.body || '')}` }, { l: '심각도', f: (n) => badge(n.severity === 'critical' ? 'failed' : n.severity === 'warning' ? 'warn' : 'info', n.severity) }, { l: '최근', f: (n) => fmt(n.last_at) }, { l: '상태', f: (n) => n.state === 'open' ? badge('running', '열림') : badge('done', '해결') }], notifications, { empty: '운영자 알림이 없습니다' })}`; } export function auditTable(entries) { return table([{ l: '시각', f: (a) => fmt(a.at) }, { l: '행위자', f: (a) => `${esc(a.actor_type)} ${esc(a.actor_id || '')}${a.automatic ? ' (자동)' : ''}` }, { l: '행동', f: (a) => `${esc(a.action)}${a.immutable ? ' (삭제 불가)' : ''}` }, { l: '대상', f: (a) => `${esc(a.subject_type || '')} ${esc(a.subject_id || '')}` }, { l: '결과', f: (a) => a.outcome === 'ok' ? badge('ok', '성공') : badge('failed', a.outcome) }, { l: '상세', f: (a) => a.detail ? `
보기
${esc(JSON.stringify(a.detail, null, 1))}
` : '' }], entries, { empty: '기록이 없습니다' }); } /** 긴급 복구(§33) */ export function emergencyView(data) { const steps = ['declared', 'approving', 'approved', 'credential_issued', 'performing', 'verified', 'reconciled', 'closed']; const L = { declared: '선언', approving: '승인 중', approved: '승인 완료', credential_issued: '자격 발급', performing: '수행 중', verified: '결과 확인·자격 폐기', reconciled: 'HAL 상태 조정', closed: '사건 종료' }; const sess = (s) => { const idx = steps.indexOf(s.status); const approvals = s.approvals || []; return `
${esc(s.id)} ${badge(s.status === 'closed' ? 'done' : 'warn', L[s.status] || s.status)} 사건 ${esc(s.incident_key)} · 선언 ${fmt(s.declared_at)} (${esc(s.declared_by)})
    ${steps.map((st, i) => `
  1. ${esc(L[st])}
  2. `).join('')}
${kv([['이유', esc(s.reason)], ['허용 대상', (s.scope || []).map(esc).join(', ')], ['승인 단계', approvals.length ? approvals.map((a) => `${a.step}단계 ${esc(a.by)} ${fmt(a.at)} — ${esc(a.impactText || a.finalConfirm != null ? (a.impactText || (a.revertible ? '되돌리기 가능' : '되돌리기 불가')) : (a.identifiers || []).join(','))}`).join('
') : '없음 (3단계 모두 필요)'], ['자격', s.credential_issued_at ? `발급 ${fmt(s.credential_issued_at)} · 만료 ${fmt(s.credential_expires_at)} · ${s.credential_revoked_at ? '폐기 ' + fmt(s.credential_revoked_at) : '유효'}` : '미발급(한 번만 표시되며 다시 볼 수 없음)'], ['수행 기록', (s.actions || []).length ? (s.actions || []).map((a) => `${fmt(a.at)} ${esc(a.action)} ${esc(a.target || '')} → ${a.ok ? '성공' : `거부/실패: ${esc(a.error || '')}`}`).join('
') : '없음'], ['결과 확인', s.verify ? `HAL 건강 ${s.verify.halHealth ? '정상' : '비정상'} ${fmt(s.verify.at)}` : '—'], ['상태 조정', s.reconcile ? `차이 ${(s.reconcile.drift || []).length}건 · 응답 없는 서버 ${(s.reconcile.offlineServers || []).length}대` : '—']])}
${s.status === 'declared' || s.status === 'approving' ? `` : ''}${s.status === 'approved' ? `` : ''}${['credential_issued', 'performing'].includes(s.status) ? ` ` : ''}${s.status === 'verified' ? `` : ''}${s.status !== 'closed' ? `` : ''}
`; }; return `

HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. 허용 대상: ${(data.scopes || []).map(esc).join(', ')} · 허용 작업: ${Object.keys(data.actions || {}).map(esc).join(', ')} — 일반 고객 서비스 배포는 금지(거부됨).

${(data.sessions || []).length ? data.sessions.map(sess).join('') : statePanel('empty', '긴급 복구 세션이 없습니다')}`; } /** 재해복구(§34) */ export function drView(data) { const r = data.readiness || {}; const cfg = r.config || {}; return `

준비 상태

${r.ready ? badge('ok', r.label) : badge('warn', r.label)}${kv([['목표 복구 시간(RTO)', cfg.rto_minutes ? `${cfg.rto_minutes}분` : '미설정'], ['허용 데이터 시점(RPO)', cfg.rpo_minutes ? `${cfg.rpo_minutes}분` : '미설정'], ['복구 사본 주기·보존', cfg.snapshot_interval_minutes ? `${cfg.snapshot_interval_minutes}분 · ${cfg.retention_count}개` : '미설정'], ['외부 보관', esc(cfg.external_store || '미설정')], ['최근 훈련', r.lastDrill ? `${r.lastDrill.result === 'success' ? badge('ok', '성공') : badge('failed', r.lastDrill.result)} ${fmt(r.lastDrill.at)} · 측정 RTO ${r.lastDrill.measuredRtoMinutes ?? '—'}분 / RPO ${r.lastDrill.measuredRpoMinutes ?? '—'}분 (목표 ${cfg.rto_minutes ?? '—'}/${cfg.rpo_minutes ?? '—'})` : '실제 복구 훈련 전 — 정식 운영 준비 완료로 표시하지 않음'], ['다음 훈련', r.lastDrill && r.lastDrill.nextDueAt ? fmt(r.lastDrill.nextDueAt) : '예정 없음'], ['운영 중 HAL', data.hal && data.hal.bootstrap ? `${esc(data.hal.bootstrap.live_instance_id || '')} ${badge('ok', 'HAL 하나만 운영 상태')}` : '—']])}

복구 사본

${table([{ l: '사본', f: (s) => `${esc(s.id)}
${esc(s.kind)}` }, { l: '생성', f: (s) => fmt(s.created_at) }, { l: '크기', f: (s) => bytes(s.size_bytes) }, { l: '검증', f: (s) => s.verify_state === 'ok' ? badge('ok', `검증 ${fmt(s.verified_at)}`) : badge('failed', s.verify_state) }, { l: '외부 보관', f: (s) => s.external_copied_at ? badge('ok', fmt(s.external_copied_at)) : badge('warn', '외부 사본 없음') }, { l: '', f: (s) => s.verify_state === 'ok' && !s.deleted_at ? ` ` : '' }], data.snapshots || [], { empty: '복구 사본이 없습니다' })}

복구 요청(별도 승인 + 기존 제어 위치 중지 확인)

${table([{ l: '요청', f: (x) => `${esc(x.id)}
사본 ${esc(x.snapshot_id)}` }, { l: '상태', f: (x) => jobBadge(x.status === 'requested' ? 'awaiting_approval' : x.status === 'approved' ? 'queued' : x.status === 'running' ? 'running' : x.status) }, { l: '승인', f: (x) => x.approved_at ? `${esc(x.approved_by)} ${fmt(x.approved_at)}` : '대기' }, { l: '기존 제어 중지 확인', f: (x) => x.old_control_stopped_confirmed_at ? fmt(x.old_control_stopped_confirmed_at) : '미확인' }, { l: '확인·차이', f: (x) => x.checks_json ? `
${esc(x.checks_json)}
` : '—' }, { l: '', f: (x) => x.status === 'requested' ? `` : x.status === 'approved' ? `` : x.status === 'running' ? `` : '' }], data.restores || [], { empty: '복구 요청이 없습니다' })}

복구 훈련

${table([{ l: '훈련', f: (x) => `${esc(x.id)}` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` ` : x.result === 'success' ? badge('ok', '성공') : badge('failed', x.result) }, { l: '측정 RTO/RPO', f: (x) => `${x.measured_rto_minutes ?? '—'} / ${x.measured_rpo_minutes ?? '—'}분` }, { l: '실패·조치 항목', f: (x) => x.findings_json ? `
${esc(x.findings_json)}
` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`; } export function accountsView(data) { return `

내 계정

${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` ${esc(data.me.securityHint || '')}`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '만료(10분 지남)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}
${data.me.mfaEnabled ? `` : ``}

로그인 세션

${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' (현재)' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `` }], data.sessions || [])}

접근권(허용 네트워크 밖 기기)

${table([{ l: '계정', k: 'account_id' }, { l: '발급', f: (g) => `${esc(g.created_by || '')} ${fmt(g.created_at)}` }, { l: '만료', f: (g) => fmt(g.expires_at) }, { l: '기기 묶임', f: (g) => g.bound_at ? `${fmt(g.bound_at)} (${esc(g.ip || '')})` : '아직' }, { l: '상태', f: (g) => g.revoked_at ? badge('neutral', '회수') : Date.parse(g.expires_at) < Date.now() ? badge('neutral', '만료') : badge('ok', '유효') }, { l: '사유', k: 'reason' }, { l: '', f: (g) => g.revoked_at ? '' : `` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}

운영자 계정

${table([{ l: '아이디', f: (a) => `${esc(a.login)} ${esc(a.displayName)}` }, { l: '보안', f: (a) => a.mfaEnabled ? badge('ok', 'MFA') : badge('warn', '비밀번호만') }, { l: '상태', f: (a) => a.lockedUntil ? badge('warn', `잠김 ${fmt(a.lockedUntil)}`) : badge('ok', a.status) }, { l: '마지막 로그인', f: (a) => fmt(a.lastLoginAt) }], data.accounts || [])}
`; } /** 운영 현황(§26.1) */ export function overviewView(o) { const COMP_LABEL = { HAL: 'HAL', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye' }; // [0.3.17] 구성요소 표시 이름(observability → HawkEye) const comp = (c) => `
${esc(COMP_LABEL[c.name] || c.name)} ${COMP_LABEL[c.name] && COMP_LABEL[c.name] !== c.name ? esc(c.name) : ''} ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}
${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}
`; const jobs = o.jobs || {}; const srv = o.servers || {}; return `
${(o.components || []).map(comp).join('')}

서버

${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `${srv.offline}대` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `${srv.diskWarning}대` : '없음'], o.settings ? ['설정 적용 필요', o.settings.stale ? `${o.settings.stale}건` : '없음'] : null])}서버와 용량 통합 설정

진행 중 작업

${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `${jobs.needsUser}` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}작업 화면

목표·실제 차이

${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}

재해복구·긴급

${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}
${(o.emergencyOpen || []).length ? `긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중` : '긴급 복구 없음'}
플랫폼 복구

장애·유지보수

${(o.incidents || []).length ? table([{ l: '사건', f: (i) => `${esc(i.title)}` }, { l: '심각도', k: 'severity' }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음') }, { l: '시작', f: (i) => fmt(i.started_at) }], o.incidents) : badge('ok', '진행 중인 사건 없음')}

처리 대기 승인(포털)

${statePanel('loading', '포털에서 승인 대기 항목을 불러오는 중')}

HAL 자체 상태 ${fmt(o.generatedAt)} 기준. 오래된 정보는 정상으로 표시하지 않습니다.

`; } /** 처리 대기 승인 요약(포털 중계 결과) */ export function pendingApprovals(p) { const row = (label, n, href) => `
  • ${esc(label)} ${n}건
  • `; return `${p.partial ? statePanel('partial', `일부 항목을 받지 못했습니다: ${p.partial.join(', ')}`) : ''}`; } // ---------------- 포털 중계 화면(§26.2·§27·§30) export function customersSearch(d) { return `

    사용자 그룹

    ${table([{ l: '그룹', f: (g) => `${esc(g.name)} ${g.kind === 'personal' ? '개인' : '팀'}` }, { l: '구성원', k: 'members' }, { l: '연락처', f: (g) => esc(g.contactEmail || '—') }, { l: '상태', f: (g) => badge(g.status === 'active' ? 'ok' : 'warn', g.status) }, { l: '가입', f: (g) => fmt(g.createdAt) }], d.groups || [], { empty: '검색 결과가 없습니다' })}

    사용자

    ${table([{ l: '이메일', f: (u) => `${esc(u.email)}
    ${esc(u.displayName)}` }, { l: '확인', f: (u) => u.verified ? badge('ok', '이메일 확인') : badge('warn', '미확인') }, { l: 'MFA', f: (u) => u.mfa ? '사용' : '—' }, { l: '잠김', f: (u) => u.locked ? badge('warn', '잠김') : '—' }, { l: '마지막 로그인', f: (u) => fmt(u.lastLoginAt) }], d.users || [], { empty: '검색 결과가 없습니다' })}`; } export function customerView(c) { const g = c.group || {}; const SL = { draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' }; return `

    ${esc(g.name)} (${g.kind === 'personal' ? '개인' : '팀'})

    ${kv([['id', `${esc(g.id)}`], ['연락처', `${esc(g.contactEmail || '—')} ${esc(g.contactPhone || '')}`], ['상태', badge(g.status === 'active' ? 'ok' : 'warn', g.status)], ['조회 기준', fmt(c.generatedAt)]])}

    활성 상품·권리

    ${table([{ l: '자원', k: 'resource' }, { l: '양', k: 'amount' }, { l: '출처', f: (e) => `${esc(e.source_type || '')} ${esc(e.source_id || '')}` }, { l: '기간', f: (e) => `${fmt(e.starts_at)} ~ ${e.ends_at ? fmt(e.ends_at) : '무기한'}` }, { l: '', f: (e) => e.active ? badge('ok', '활성') : badge('neutral', '비활성') }], c.entitlements || [], { empty: '활성 상품이 없습니다' })}

    구성원

    ${table([{ l: '이메일', f: (m) => `${esc(m.email)}
    ${esc(m.displayName)} · ${esc(m.alias || '')}` }, { l: '역할', k: 'role' }, { l: '상태', f: (m) => m.status === 'suspended' ? badge('warn', `정지 ${fmt(m.suspendedAt)}`) : badge('ok', m.status) }, { l: '확인·MFA', f: (m) => `${m.verified ? '이메일 확인' : '미확인'}${m.locked ? ' · 잠김' : ''}` }, { l: '마지막 로그인', f: (m) => fmt(m.lastLoginAt) }], c.members || [])}

    사이트

    ${table([{ l: '사이트', f: (s) => `${esc(s.name)}
    ${esc(s.primary_domain || '대표 도메인 없음')}` }, { l: '상태', f: (s) => badge(s.lifecycle === 'live' ? 'ok' : 'neutral', SL[s.lifecycle] || s.lifecycle) }, { l: '배포', f: (s) => `${esc(s.deploy_mode || '미선택')} · ${esc(s.current_version || '—')}` }, { l: '사이트 수', k: 'site_count_state' }, { l: '건강', f: (s) => healthBadge(s.health) }, { l: '보존·해지', f: (s) => s.cancel_at || s.retention_until || s.delete_after ? `${s.cancel_at ? '해지 ' + fmt(s.cancel_at) : ''} ${s.retention_until ? '보존 ' + fmt(s.retention_until) : ''} ${s.delete_after ? '삭제 ' + fmt(s.delete_after) : ''}` : '—' }], c.sites || [], { empty: '사이트가 없습니다' })}

    메일

    ${table([{ l: '서비스', f: (m) => `${esc(m.name)}
    ${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'}` }, { l: '상태', f: (m) => jobBadge(m.status) }, { l: 'DNS·송수신', f: (m) => `${esc(m.dns_state || '—')} · ${esc(m.send_state || '—')}/${esc(m.receive_state || '—')}` }, { l: '보존', f: (m) => m.retention_until ? fmt(m.retention_until) : '—' }], c.mail || [], { empty: '메일 서비스 없음' })}

    도메인

    ${table([{ l: '도메인', f: (d) => `${esc(d.name)}${d.is_primary ? ' (대표)' : ''}` }, { l: '소유·DNS', f: (d) => `${esc(d.ownership_state)} · ${esc(d.dns_state)}` }, { l: '인증서', f: (d) => `${esc(d.cert_state)} ${d.cert_expires_at ? fmt(d.cert_expires_at) : ''}` }, { l: '메일 보안', f: (d) => esc(d.mail_dns_state || '—') }], c.domains || [], { empty: '도메인 없음' })}

    백업

    ${table([{ l: '사이트', k: 'site_id' }, { l: '상태', k: 'status' }, { l: '최근 성공·실패', f: (b) => `${fmt(b.last_success_at)} / ${fmt(b.last_failure_at)} (연속 실패 ${b.consecutive_failures || 0})` }, { l: '다음', f: (b) => fmt(b.next_run_at) }], c.backups || [], { empty: '백업 옵션 없음' })}

    사용량·제한

    ${table([{ l: '자원', k: 'label' }, { l: '구매', k: 'purchased' }, { l: '배정', k: 'allocated' }, { l: '예약', k: 'reserved' }, { l: '사용', k: 'used' }, { l: '미배정', k: 'unallocated' }], c.usage || [])}${(c.limits || []).length ? `

    제한 적용: ${c.limits.map((l) => `${esc(l.resource)} ${l.level}%`).join(', ')}

    ` : ''}

    주문·결제

    ${table([{ l: '주문', f: (o) => `${esc(o.order_no)} ${esc(o.kind)}` }, { l: '상태', f: (o) => jobBadge(o.status) }, { l: '금액', f: (o) => `${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)}` }, { l: '기한·입금·승인', f: (o) => `${fmt(o.due_at)} / ${fmt(o.paid_at)} / ${fmt(o.approved_at)}` }, { l: '거절 사유', k: 'rejected_reason' }], c.orders || [], { empty: '주문 없음' })}

    정지·보존

    ${kv([['정지된 구성원', `${(c.suspensions && c.suspensions.members.length) || 0}명`], ['일시 중지·유지보수 사이트', `${(c.suspensions && c.suspensions.sites.length) || 0}개`], ['보존 중 사이트·메일·백업', `${(c.retention && c.retention.sites.length) || 0} · ${(c.retention && c.retention.mail.length) || 0} · ${(c.retention && c.retention.backups.length) || 0}`]])}

    고객 화면 오류·실패 작업

    ${table([{ l: '작업', f: (j) => `${esc(j.label)}` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '오류', f: (j) => `${esc(j.error || j.userMessage || '')}` }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], c.failedJobs || [], { empty: '실패한 작업 없음' })}${auditTable(c.errors || [])}

    일시 접근 기록

    ${table([{ l: '대상', f: (a) => `${esc(a.target_type)} ${esc(a.target_id || '')}` }, { l: '이유', k: 'reason' }, { l: '범위', f: (a) => (a.scope || []).map(esc).join(', ') }, { l: '요청·승인', f: (a) => `${esc(a.requested_by)} / ${esc(a.approved_by || '대기')}` }, { l: '만료', f: (a) => fmt(a.expires_at) }, { l: '상태', f: (a) => a.state === 'active' ? badge('ok', '유효') : badge('neutral', a.state) }, { l: '', f: (a) => a.state === 'active' ? `` : '' }], c.supportAccess || [], { empty: '일시 접근 기록 없음' })}

    운영자 대신 변경 기록

    ${table([{ l: '시각', f: (x) => fmt(x.created_at) }, { l: '항목', f: (x) => `${esc(x.field)} ${esc(x.subject_id)}` }, { l: '변경 전 → 후', f: (x) => `${esc(JSON.stringify(x.before))} → ${esc(JSON.stringify(x.after))}` }, { l: '사유', k: 'reason' }, { l: '운영자·승인자', f: (x) => `${esc(x.operator)} / ${esc(x.approver || '—')}` }], c.changes || [], { empty: '변경 기록 없음' })}`; } export function ordersView(d) { return table([{ l: '주문', f: (o) => `${esc(o.order_no)}
    ${esc(o.group && o.group.name)} · ${esc(o.product && o.product.name)} v${esc(o.product && o.product.version)}` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.statusLabel ? `
    ${esc(o.statusLabel)}` : '') }, { l: '대상·금액', f: (o) => `${esc(o.target_type || '그룹')} ${esc(o.target_id || '')}
    ${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)} (세금 ${Number(o.tax).toLocaleString('ko-KR')})` }, { l: '권리 변화', f: (o) => (o.entitlementDelta || []).map((e) => `${esc(e.label || e.resource)} +${e.amount}${e.target ? ' → ' + esc(e.target) : ''}`).join('
    ') || '—' }, { l: '현재 사용량', f: (o) => (o.currentUsage || []).filter((u) => u.purchased || u.used).map((u) => `${esc(u.label)} ${u.used}/${u.purchased}`).join('
    ') || '—' }, { l: '기한·입금', f: (o) => `${fmt(o.due_at)}
    ${o.paid_at ? '입금 ' + fmt(o.paid_at) + ' ' + esc(o.paid_ref || '') : '입금 미확인'}` }, { l: '', f: (o) => o.status === 'awaiting_payment' ? ` ` : o.status === 'awaiting_approval' ? ` ` : o.status === 'approved' ? `` : '' }], d.orders || [], { empty: '주문이 없습니다' }); } export function productsView(d) { return `${table([{ l: '상품', f: (p) => `${esc(p.name)} ${esc(p.product_id)} v${p.version}` }, { l: '공개', f: (p) => p.published ? badge('ok', '공개') : badge('neutral', '비공개') }, { l: '적용 기간', f: (p) => `${fmt(p.effective_from)} ~ ${p.effective_to ? fmt(p.effective_to) : ''}` }, { l: '가격', f: (p) => `${Number(p.price).toLocaleString('ko-KR')} ${esc(p.currency)} / ${esc(p.billing_cycle)}` }, { l: '가입자', k: 'subscribers' }, { l: '미확정 정책', f: (p) => (p.deferred || []).length ? `${p.deferred.map((x) => esc(x.key || x)).join(', ')}` : '없음' }, { l: '', f: (p) => ` ` }], d.products || [], { empty: '상품이 없습니다 — "검증 Catalog 넣기"로 시작' })}

    이미 판매된 조건은 바꾸지 않고 새 버전을 만듭니다(§27.2). 기존 고객은 자기 버전을 유지합니다.

    `; } export function sitesView(d) { return table([{ l: '사이트', f: (s) => `${esc(s.name)}
    ${esc(s.primaryDomain || s.primary_domain || '—')} · 그룹 ${esc(s.groupId || '')}` }, { l: '상태', f: (s) => jobBadge(s.lifecycle || s.status) }, { l: '배포', f: (s) => `${esc(s.deployMode || s.deploy_mode || '미선택')} · ${esc(s.currentVersion || s.current_version || '—')}` }, { l: '건강', f: (s) => healthBadge(s.health) }, { l: '서버', f: (s) => esc(s.serverId || '—') }, { l: '조치 필요', f: (s) => s.actionNeeded ? `${esc(s.actionNeeded)}` : '—' }], d.sites || [], { empty: '사이트가 없습니다' }); } export function siteDetailView(d) { const s = d.site || {}; return `

    ${esc(s.name)}

    ${kv([['그룹', `${esc(s.group_id)}`], ['상태', jobBadge(s.lifecycle)], ['배포', `${esc(s.deploy_mode || '미선택')} · ${esc(s.current_version || '—')} · ${esc(s.deploy_verify || '')}`], ['서버', esc(s.server_id || '—')], ['대표 도메인', esc(s.primary_domain || '—')], ['사이트 수', esc(s.site_count_state)], ['건강', healthBadge(s.health) + ' ' + fmt(s.health_checked_at)]])}

    외부 Agent

    ${d.externalAgent ? kv([['상태', esc(d.externalAgent.status)], ['마지막 확인', fmt(d.externalAgent.lastSeenAt)], ['버전', esc(d.externalAgent.version || '')]]) : '관리형 또는 미연결'}

    배포 이력

    ${table([{ l: '배포', f: (x) => `${esc(x.id)}
    ${esc(x.version_label || x.version_ref || '')}` }, { l: '상태', f: (x) => jobBadge(x.status) + (x.verify_state ? ` ${esc(x.verify_state)}` : '') }, { l: '시각', f: (x) => `${fmt(x.created_at)} → ${fmt(x.ended_at)}` }], d.deployments || [], { empty: '배포 이력 없음' })}

    도메인

    ${table([{ l: '도메인', f: (x) => `${esc(x.name)}${x.is_primary ? ' (대표)' : ''}` }, { l: '소유·DNS·인증서', f: (x) => `${esc(x.ownership_state)} · ${esc(x.dns_state)} · ${esc(x.cert_state)}` }, { l: '마지막 확인', f: (x) => fmt(x.last_checked_at) }], d.domains || [], { empty: '도메인 없음' })}

    작업

    ${(d.jobs || []).map((j) => jobBlock(j, { actions: false })).join('') || statePanel('empty', '작업 없음')}

    감사 기록

    ${auditTable(d.audit || [])}`; } export function mailView(d) { return `

    메일 서버

    ${table([{ l: '서버', f: (s) => `${esc(s.name || s.id)}
    ${esc(s.baseUrl || s.base_url || '')}` }, { l: '연결', f: (s) => s.ok === false || s.error ? badge('failed', '연결 실패') + ` ${esc(s.error || '')}` : s.ok ? badge('ok', `정상 v${esc(s.version || '')}`) : badge('stale', '확인 전') }, { l: '도메인·계정', f: (s) => s.totals ? `${s.totals.domains}개 · ${s.totals.accounts}계정 · ${bytes(s.totals.storageBytes)}` : '—' }, { l: '', f: (s) => `` }], d.servers || [], { empty: '등록된 메일 서버가 없습니다' })}

    메일 서비스

    ${table([{ l: '서비스', f: (m) => `${esc(m.name)}
    ${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'} · 그룹 ${esc(m.group_id)}` }, { l: '상태', f: (m) => jobBadge(m.status) + (m.migrating ? ' ' + badge('warn', '이전 중') : '') }, { l: 'DNS·송신·수신', f: (m) => `${esc(m.dns_state || '—')} · ${esc(m.send_state || '—')} · ${esc(m.receive_state || '—')}` }, { l: '메일함·공간·통수', f: (m) => m.figures ? Object.values(m.figures).map((f) => `${esc(f.label || f.resource)} ${f.used ?? 0}/${f.allocated ?? 0}`).join('
    ') : '—' }, { l: '보존', f: (m) => m.retention_until ? `${fmt(m.retention_until)} · 삭제 ${fmt(m.delete_after)}` : '—' }, { l: '', f: (m) => ` ` }], d.services || [], { empty: '메일 서비스가 없습니다' })}

    메일 원문은 운영 화면에서 열람하지 않습니다. 진단 조회는 접근 사유를 기록합니다(§30.2).

    `; } export function domainsView(d, src) { return `

    도메인·인증서(§30.1)

    ${table([{ l: '도메인', f: (x) => `${esc(x.name)}${x.is_primary ? ' (대표)' : ''}
    ${esc(x.purpose)} · 그룹 ${esc(x.group_id)} · 사이트 ${esc(x.site_id || '—')}` }, { l: '소유 확인', f: (x) => x.ownership_state === 'verified' ? badge('ok', '확인') : badge('waiting', x.ownership_state) }, { l: 'DNS', f: (x) => x.dns_state === 'ok' ? badge('ok', '반영') : badge('warn', x.dns_state) }, { l: '인증서', f: (x) => (x.cert_state === 'ok' || x.cert_state === 'issued' ? badge('ok', '유효') : x.cert_state === 'failed' ? badge('failed', '실패') : badge('neutral', x.cert_state)) + (x.cert_expires_at ? `
    만료 ${fmt(x.cert_expires_at)}` : '') }, { l: '메일 보안', f: (x) => esc(x.mail_dns_state || '—') }, { l: '마지막 외부 확인', f: (x) => fmt(x.last_checked_at) }, { l: '반복 실패', f: (x) => x.failures ? `${x.failures}회` : '—' }], (d.domains || []), { empty: '도메인이 없습니다' })}${d.duplicates && d.duplicates.length ? `

    여러 고객에게 연결된 도메인(활성화 차단): ${d.duplicates.map(esc).join(', ')}

    ` : ''}

    Source/Gitea(§30.5)

    ${table([{ l: '사이트', f: (o) => `${esc(o.site_name || o.site_id)} ${esc(o.site_id)}` }, { l: '상태', f: (o) => jobBadge(o.state || o.status) }, { l: '저장소', f: (o) => esc(o.repo || o.remote || '—') }, { l: '사용량·한도', f: (o) => o.usage ? `${bytes(o.usage.bytes)} / ${bytes(o.usage.limit)}` : '—' }, { l: '만료·삭제 예정', f: (o) => `${fmt(o.expires_at || o.readonly_until)} · ${fmt(o.delete_after)}` }, { l: '최근 배포 revision', f: (o) => `${esc(String(o.last_deployed_revision || '').slice(0, 10))}` }], (src && src.options) || [], { empty: 'Source 옵션이 없습니다' })}`; } export function backupView(d) { return `

    복원 승인(§30.3)

    ${table([{ l: '요청', f: (r) => `${esc(r.id)}
    ${esc(r.site_name)} ${esc(r.primary_domain || '')}
    ${esc(r.group_name)} · 백업 ${fmt(r.backup_created_at || r.backupAt)}` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `
    ${esc(r.approval_state)}` : '') }, { l: '비용·결제', f: (r) => `${Number(r.cost || 0).toLocaleString('ko-KR')}원 · ${esc(r.payment_state || r.order_id || '—')}` }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%` : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? ` ` : r.status === 'failed' ? `` : '' }], d.restores || [], { empty: '복원 요청이 없습니다' })}

    백업 옵션

    ${table([{ l: '사이트', f: (o) => `${esc(o.site_name || o.site_id)} ${esc(o.group_name || '')}` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.paused_reason ? ` ${esc(o.paused_reason)}` : '') }, { l: '최근 성공·실패', f: (o) => `${fmt(o.last_success_at)}
    ${o.consecutive_failures ? `연속 실패 ${o.consecutive_failures}` : '실패 없음'}` }, { l: '다음 예정', f: (o) => fmt(o.next_run_at) }, { l: '만료·삭제', f: (o) => `${fmt(o.ends_at)} · ${fmt(o.delete_after)}` }, { l: '', f: (o) => ` ` }], d.options || [], { empty: '활성 백업 옵션이 없습니다' })}

    백업 목록

    ${table([{ l: '백업', f: (b) => `${esc(b.id)}
    ${esc(b.site_name || b.site_id)}` }, { l: '상태', f: (b) => jobBadge(b.status) }, { l: '검증', f: (b) => b.verify_state === 'ok' || b.verify_state === 'verified' ? badge('ok', '검증') : badge('warn', b.verify_state) }, { l: '생성·만료', f: (b) => `${fmt(b.created_at)} · ${fmt(b.expires_at)}` }, { l: '크기·위치', f: (b) => `${bytes(b.size_bytes)} ${esc(b.location || '')}` }], d.backups || [], { empty: '백업이 없습니다' })}`; } export function relocationView(d, tr) { return `

    서버 이전(§30.4)

    ${table([{ l: '대상', f: (r) => `${esc(r.subjectName || r.subject_id)} ${esc(r.subject_type)} · ${esc(r.group_name || '')}
    ${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}
    ${esc(r.reason || '')}` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `
    승인 ${esc(r.approval_state)}` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `
    ${esc(r.job.waitReason)}` : ''}` : '—' }, { l: '전환 직전 재점검', f: (r) => r.precheckChanged || (r.boundaries && r.boundaries.autoStopped) ? badge('warn', '조건 변경 — 자동 중지') : r.boundaries && r.boundaries.switchAt ? `전환 ${fmt(r.boundaries.switchAt)}` : '—' }, { l: '고객 통지', f: (r) => r.notifiedAt || (r.boundaries && r.boundaries.notifiedAt) ? fmt(r.notifiedAt || r.boundaries.notifiedAt) : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? ` ` : ['failed', 'paused'].includes(r.status) ? ` ` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}

    소유권 이전 심사(§22)

    ${table([{ l: '요청', f: (t) => `${esc(t.id)}
    ${esc(t.fromGroupName || t.from_group_id)} → ${esc(t.toGroupName || t.to_group_id || t.to_email || '')}` }, { l: '상태', f: (t) => jobBadge(t.status) }, { l: '범위', f: (t) => t.scope ? Object.entries(t.scope).map(([k, v]) => `${esc(k)}: ${Array.isArray(v) ? v.length : esc(String(v))}`).join(', ') : '—' }, { l: '만료', f: (t) => fmt(t.expires_at) }, { l: '', f: (t) => ['reviewing', 'accepted'].includes(t.status) ? ` ` : t.status === 'disputed' ? `` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; }