/**
* 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 `${sym} ${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 `
`; }
/** 중계(포털) 호출 실패 → 정보 지연. 포털 데이터로 정상 판정하지 않는다. */
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) => `${esc(c.l)} `).join('')} ${rows.map((r) => `${cols.map((c) => `${c.f ? c.f(r) : esc(r[c.k])} `).join('')} `).join('')}
`; }
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])}
${(j.steps || []).map((s) => `${stepSym[s.state] || '○'} ${esc(s.title)}${s.detail ? ` — ${esc(s.detail)} ` : ''}${s.error ? ` — ${esc(s.error)} ` : ''} `).join('')}
${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)} · ${esc(s.location || '위치 미입력')} ` },
{ 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) */
export function releasesView(data, changelog = {}) {
const running = data.running || {}; const targets = data.targets || [];
const byModule = (v) => (data.modules || []).map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === v).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '배치 없음 '}`; }).join(' · ');
return `배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2) . HAL 실행 버전: ${esc(running.hal_running_version || '확인 전')} ${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · 부트스트랩 미완료 '}
${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: 'HAL 배포', f: (r) => r.hal_deployed_at ? badge('done', fmt(r.hal_deployed_at)) : badge('neutral', '아직') },
{ l: '모듈별 배치', f: (r) => byModule(r.version) },
{ l: '변경 이력', f: (r) => changelog[r.version] ? `보기 ${esc(changelog[r.version])} ` : 'VERSION_HISTORY 항목 없음 ' },
{ l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).map((m) => `${esc(m)} `).join(' ') : '승인 후 ' },
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })}
모듈 배치 상태(대상별) ${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `${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) }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `${esc(t.last_error || '')} ` }, { l: '', f: (t) => `건강 확인 되돌리기 ` }], targets, { empty: '배치된 모듈이 없습니다' })}
목표·실제 상태 맞추기(전체)
`;
}
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) => `${esc(L[st])} `).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' ? `${approvals.length + 1}단계 승인 ` : ''}${s.status === 'approved' ? `자격 발급(1회 표시) ` : ''}${['credential_issued', 'performing'].includes(s.status) ? `작업 수행 결과 확인·자격 폐기 ` : ''}${s.status === 'verified' ? `HAL 상태 조정 ` : ''}${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', '재인증 필요(고위험 작업)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}
재인증 비밀번호 변경 ${data.me.mfaEnabled ? `MFA 해제 ` : `MFA 등록 `}
로그인 세션 ${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: '발급된 접근권이 없습니다' })}접근권 발급(URL 1회 표시)
운영자 계정 ${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 = (c) => `${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}대 ` : '없음']])}
서버와 용량
진행 중 작업 ${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 `${row('주문 승인', p.orders, '/ops/orders')}${row('복원 승인', p.restores, '/ops/backup')}${row('서버 이전 승인', p.relocations, '/ops/relocation')}${row('소유권 이전 심사', p.transfers, '/ops/relocation?tab=transfer')} ${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) => `새 버전 만들기 ${p.published ? '비공개' : '공개'} ` }], d.products || [], { empty: '상품이 없습니다 — "검증 Catalog 넣기"로 시작' })}이미 판매된 조건은 바꾸지 않고 새 버전을 만듭니다(§27.2). 기존 고객은 자기 버전을 유지합니다.
검증 Catalog 넣기
`; }
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) => `외부 송수신 시험 DNS 보안 확인 운영 상태 정지·해지 ` }], 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: '소유권 이전 요청이 없습니다' })}`; }