Files
zzabisagent-labandClaude Fable 5.1 2b31543a86 HAL 0.4.1: 서버 등록 TLS 수정·"이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음
- hal/tls.mjs: 공개 주소 TLS 신뢰 확인(시스템 CA → 실패면 체인·공개키 핀), 신뢰 앵커(체인 자체 서명 / <data>/tls/hal-ca.pem), 10분 캐시, 핀 설치 명령(curl -k --pinnedpubkey … --hal-pin). 공개 경로 GET /agent/v1/hal-ca.pem · /agent/v1/tls, CLI tls-probe
- deploy/agent-install.sh: --hal-pin(핀으로 CA 받기)·--ca·--insecure, curl --cacert / Node NODE_EXTRA_CA_CERTS / register --ca / 유닛 Environment, 연결 실패 원인·조치 한 줄, 이미 등록된 서버 안내
- agent/halca.mjs + agent/index.mjs·config.mjs: caFile 런타임 적용(tls.setDefaultCACertificates), fetch 실패를 읽을 수 있는 한 줄로(종료 1), 이미 등록(종료 2, --force), HAL 거부(종료 3), heartbeat observability → hawkeye
- core.registerAgent: 같은 서버의 두 번째 토큰 409 server_already_registered / 쓴 토큰 409 token_used("이미 등록된 서버"), 거부 토큰 미소비, 재등록 허용(replace: 기존 Agent 해지, 감사 agent.replace), 마이그레이션 hal_0011_token_replace, API/CLI --replace --json(재인증), ops.js 재등록 확인·핀 안내
- deploy/hal-host-agent.sh(server-create --profile hal → server-token --json → agent-install loopback → server-approve), bootstrap-hal.sh 6/7 단계(--no-agent), CLI server-create·server-approve, SERVER_PROFILES.hal
- core.deploymentStatus/selfHealth.deployment, 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기: …" / "진행 중", 배포 릴리스 화면 문구, heartbeat canonicalModuleName(0.3.17 잔여 결함)
- 개발 지시 v0.4.1(Oz)·VERSION_HISTORY 0.4.1·README·docs/HAL-AGENT §6·RUNBOOK §11·AI 스킬, 도입 모듈 변경 없음(Webmaker 1.9.0·AriMail 1.1.0)
- test/hal-041.test.mjs RED → GREEN 7/7(실제 자체 서명 TLS 픽스처·별도 프로세스 CLI), 기존 배너·프로필·버전 테스트 갱신, 전체 32파일 통과, 로컬 E2E 보고서 2026-11-06

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 08:40:09 +00:00

238 lines
76 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<span class="badge ${esc(kind)}"><span aria-hidden="true">${sym}</span> ${esc(label)}</span>`; }
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 `<div class="aw-state ${esc(kind)}" role="status"><b>${esc(L[kind] || kind)}</b>${text ? `<p>${esc(text)}</p>` : ''}${href ? `<a class="btn" href="${esc(href)}">${esc(label || '이동')}</a>` : ''}</div>`; }
/** 중계(포털) 호출 실패 → 정보 지연. 포털 데이터로 정상 판정하지 않는다. */
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 `<table class="r ${esc(cls)}"><thead><tr>${cols.map((c) => `<th>${esc(c.l)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${cols.map((c) => `<td data-l="${esc(c.l)}">${c.f ? c.f(r) : esc(r[c.k])}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }
export function kv(pairs) { return `<dl class="kv">${pairs.filter((p) => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v == null || v === '' ? '—' : v}</dd>`).join('')}</dl>`; }
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 `<div class="job" data-job="${esc(j.id)}"><div class="row">${jobBadge(j.status)} <b>${esc(j.label || j.kind)}</b> <span class="mono muted">${esc(j.id)}</span>${j.stale ? ' ' + badge('stale', '정보 확인 지연') : ''}</div>
${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 ? ` <span class="muted">— ${esc(j.currentStep.detail)}</span>` : '') : '—'], ['진행률', `${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 ? ['오류 요약', `<span class="err">${esc(j.error)}</span> <span class="muted">(${esc(j.errorCode || '')})</span>`] : null])}
<ul class="steps">${(j.steps || []).map((s) => `<li class="${esc(s.state)}"><span class="s" aria-hidden="true">${stepSym[s.state] || '○'}</span><span>${esc(s.title)}${s.detail ? ` <span class="muted">— ${esc(s.detail)}</span>` : ''}${s.error ? ` <span class="err">— ${esc(s.error)}</span>` : ''}</span></li>`).join('')}</ul>
${actions ? `<div class="actions">${j.canRetry ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="retry">다시 시도</button>` : ''}${j.canResume ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="resume">이어서 진행</button>` : ''}${j.canCancel ? `<button class="btn danger" data-act="job" data-id="${esc(j.id)}" data-op="cancel">취소</button>` : ''}${['done', 'failed', 'needs_user'].includes(j.status) ? `<button class="btn danger" data-act="job" data-id="${esc(j.id)}" data-op="revert">되돌리기</button>` : ''}${['queued', 'awaiting_external'].includes(j.status) ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="pause">일시 중지</button>` : ''}</div>` : ''}</div>`;
}
/** 자식(대상별) 결과: 일부 실패는 '부분 실패' */
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 `<h3>대상별 결과 ${overall}</h3>${table([{ l: '대상', f: (k) => `<a href="/ops/jobs/${esc(k.id)}">${esc(k.label || k.id)}</a><br><span class="muted mono">${esc((k.subject && (k.subject.serverId || k.subject.target)) || '')}</span>` }, { l: '상태', f: (k) => jobBadge(k.status) }, { l: '시도', f: (k) => `${k.attempt || 0}${k.next_run_at ? ' · 다음 ' + fmt(k.next_run_at) : ''}` }, { l: '오류', f: (k) => `<span class="err">${esc(k.error || '')}</span>` }], c.children)}`; }
export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r) => `<span class="mono">${esc(r.id)}</span><br>${esc(r.kind)}` }, { l: 'Agent', f: (r) => `<span class="mono">${esc(r.agent_id || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) }, { l: '진행', f: (r) => `${r.progress || 0}%` }, { l: '오류·조치', f: (r) => `<span class="err">${esc(r.error || '')}</span> ${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) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(s.location || '위치 미입력')}</span>${s.slots && Object.keys(s.slots).length ? `<br><span class="muted small">슬롯 ${esc(Object.entries(s.slots).map(([k, v]) => `${k} ${s.slotsFree && s.slotsFree[k] != null ? s.slotsFree[k] + '/' : ''}${v}`).join(', '))}</span>` : ''}` },
{ l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}<br><span class="muted">마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}</span>` },
{ 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 ? `<br><span class="err">고갈 예상 ${fmt(s.capacity.exhaustEta)}</span>` : ''}` : '정보 없음' },
{ 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' ? `<br><span class="muted">${esc(s.maintenanceNote || '유지보수')}</span>` : s.rawStatus === 'isolated' ? `<br><span class="err">격리: ${esc(s.isolatedReason || '')}</span>` : '') },
{ l: '시계·인증서', f: (s) => `${s.clockSkewMs == null ? '—' : Math.abs(s.clockSkewMs) > 60000 ? `<span class="err">${s.clockSkewMs}ms</span>` : `${s.clockSkewMs}ms`}<br>${s.facts && s.facts.certExpiresAt ? '인증서 ' + fmt(s.facts.certExpiresAt) : '인증서 정보 없음'}` },
{ l: '모듈 버전', f: (s) => Object.entries(s.modules || {}).map(([m, v]) => `${esc(m)} ${v.drift ? `<span class="err">${esc(v.actual || '없음')}→${esc(v.target)}</span>` : esc(v.actual || v.target || '—')} ${healthBadge(v.health)}`).join('<br>') || '<span class="muted">배치 없음</span>' },
], 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 ? `<h2>Webmaker 사이트 디렉터리 인벤토리</h2><p class="muted">Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조한 운영 인벤토리입니다. 전체 ${Number(webmakerData.total || 0).toLocaleString('ko-KR')}개 · 수집 ${Number(webmakerData.returned || 0).toLocaleString('ko-KR')}개${webmakerData.truncated ? ' · 일부만 표시됨' : ''}. 페이지 본문·프롬프트·연락처·인증정보는 HAL로 보내지 않습니다.</p>${inventoryErrors.length || webmakerData.errorsTruncated ? statePanel('failed', `인벤토리 스캔 오류 ${inventoryErrors.length}건${webmakerData.errorsTruncated ? ' 이상(전송 한도에 따라 일부 오류만 표시)' : ''}: ${inventoryErrors.join(', ')}`) : ''}${table([
{ l: '사이트', f: (x) => `<span class="mono">${esc(x.id)}</span><br><b>${esc(x.title || '제목 정보 없음')}</b>` },
{ 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('<br>') || '—' },
{ 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 `<div class="cards"><div class="card"><h2>연결·역할</h2>${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', `<span class="mono">${esc(s.agentId || '미연결')}</span> ${esc(s.agentVersion || '')}`], ['시계 차이', s.clockSkewMs == null ? '—' : `${s.clockSkewMs}ms`], ['승인', s.approvedAt ? fmt(s.approvedAt) : badge('waiting', '승인 대기')], ['신규 배치', s.acceptsNew ? '허용' : '중지'], ['유지보수·격리', esc(s.maintenanceNote || s.isolatedReason || '—')]])}</div>
<div class="card"><h2>용량</h2>${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)}` : '해당 없음']])}</div>
<div class="card"><h2>모듈(목표 vs 실제)</h2>${table([{ l: '모듈', k: 'name' }, { l: '목표', k: 'target' }, { l: '실제', f: (m) => m.drift ? `<span class="err">${esc(m.actual || '없음')}</span>` : esc(m.actual || '—') }, { l: '건강', f: (m) => healthBadge(m.health) }, { l: '마지막 확인', f: (m) => fmt(m.lastCheckAt) }, { l: '오류', f: (m) => `<span class="err">${esc(m.lastError || '')}</span>` }], Object.entries(s.modules || {}).map(([name, m]) => ({ name, ...m })), { empty: '배치된 모듈이 없습니다' })}</div></div>
${webmakerInventory}<h2>대기열·반복 실패</h2>${agentJobsTable(agentJobs)}<h2>이 서버의 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { 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 `<svg viewBox="0 0 ${w} ${h}" class="chart" role="img" aria-label="${esc(label)} 추이 ${rows.length}개 표본"><text x="4" y="12" class="lbl">${esc(label)}</text><line x1="30" y1="${h - 20}" x2="${w - 10}" y2="${h - 20}" class="axis"/><line x1="30" y1="20" x2="30" y2="${h - 20}" class="axis"/><text x="2" y="24" class="lbl">${key === 'net' ? bytes(max) : '100%'}</text><polyline points="${pts}" fill="none" class="line"/><text x="30" y="${h - 4}" class="lbl">${fmt(rows[0].at)}</text><text x="${w - 150}" y="${h - 4}" class="lbl">${fmt(rows[rows.length - 1].at)}</text></svg>`;
}
/** 릴리스 화면(§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) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> ${t.actual_version ? `<code>${esc(t.actual_version)}</code>` : '<span class="muted">버전 없음</span>'}${t.target_version && t.actual_version !== t.target_version ? ` <span class="err">(목표 ${esc(t.target_version)})</span>` : ''}${t.active === 0 ? ' <span class="badge neutral">비활성</span>' : ''} ${healthBadge(t.health)}`).join('<br>') : '<span class="muted">배치 없음</span>'; };
const cards = modules.map((m) => { const g = guides[m] || { label: m, description: '', requires: [], provides: [], install: '' }; const v = latest[m]; return `<div class="card rel-card" data-module="${esc(m)}"><h3>${esc(g.label)} <span class="muted small">(${esc(m)})</span> ${v ? badge('ok', `최신 ${v}`) : badge('warn', '승인된 릴리스 없음')}</h3><p class="small">${esc(g.description)}</p>
<dl class="kv small"><dt>필요한 것</dt><dd>${(g.requires || []).map(esc).join(', ') || '—'}</dd><dt>제공</dt><dd>${(g.provides || []).map(esc).join(', ') || '—'}</dd><dt>서버 역할</dt><dd>${(g.roles || []).map(esc).join('·')}</dd><dt>설치 방법</dt><dd>${esc(g.install)}</dd><dt>현재 배치</dt><dd>${placed(m)}</dd></dl>
<div class="actions">${v ? `<button class="btn primary small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(v)}">${esc(g.label)} ${esc(v)} 배포(서버 선택)</button>` : ''}${g.singleton ? ` <button class="btn small" data-act="portal-relocate">포털 이전</button>` : ''}</div></div>`; }).join('');
const history = table([
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 버전') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { 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 || ''}`) + `<br><span class="muted">${fmt(r.approved_at)}</span>` : badge('waiting', '승인 대기') + ` <button class="btn" data-act="release-approve" data-v="${esc(r.version)}">승인</button>` },
{ 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}` : '<span class="muted">—</span>'}`; }).join(' · ') },
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">항목 없음</span>' },
{ l: '이 버전 배포', f: (r) => r.status === 'approved' ? modules.filter((m) => r.artifacts && (r.artifacts[m] || (guides[m] && /Agent 안의/.test(guides[m].description)))).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' });
// [0.4.1] 부트스트랩 완료 뒤에는 기능 단위 서버 배포 대기 목록을 보인다("부트스트랩 미완료" 문구는 진행 중일 때만)
const pendingFeat = (data.deployment && data.deployment.pending) || [];
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영) · 부트스트랩 완료' : ' · <span class="err">부트스트랩 진행 중(HAL 서버 Agent 등록·승인 전)</span>'}${pendingFeat.length ? ` · <b>기능 단위 서버 배포 대기: ${pendingFeat.map((m) => esc(moduleLabel(m))).join(', ')}</b>` : running.manual_install_locked ? ' · 기능 모듈 모두 배치됨' : ''} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
<h2>최신 릴리스 배포(모듈별)</h2><div class="cards rel-cards">${cards}</div>
<div class="actions"><button class="btn primary" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>
<h2>상호 연결(어떤 모듈이 있어야 무엇이 동작하는가)</h2><ul class="small">${(data.links || []).map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
<h2>역할별 서버 프로필</h2>${table([{ l: '프로필', f: (p) => `<b>${esc(p.label)}</b> <span class="muted small">(${esc(p.id)})</span>` }, { l: '역할', k: 'role' }, { l: '배포 모듈(순서)', f: (p) => p.modules.map(esc).join(' → ') }, { l: '설명', f: (p) => esc(p.description) + (p.singleton ? ' <span class="badge warn">하나만</span>' : '') }, { l: '슬롯', f: (p) => esc(Object.entries(p.slots || {}).map(([k, v]) => `${k} ${v}`).join(', ') || '—') }], data.profiles || [], { empty: '프로필 없음' })}
<details class="rel-history"><summary><b>과거 버전 보기</b> — 등록된 릴리스 ${(data.releases || []).length}개(승인 ${approved.length}). 이전 버전으로 돌아가려면 아래 배치 상태의 "되돌리기" 또는 그 버전의 배포 단추를 쓰세요.</summary>${history}</details>
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> <span class="muted small mono">${esc(t.server_id)}</span>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : 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) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}`;
}
/** [0.3.17] 작업 진행 창(dock): 진행 중 작업과 최근 히스토리. 두 목록을 모두 그리고 탭은 hidden 으로 전환한다. */
export function jobsDockHtml({ active = [], history = [], tab = 'active' } = {}) {
const row = (j) => `<div class="dock-job" data-job="${esc(j.id)}"><a href="/ops/jobs/${esc(j.id)}"><b>${esc(j.label || j.kind)}</b></a> <span class="muted small">${esc(j.kind)}</span> ${jobBadge(j.status)}${j.currentStep ? ` <span class="small">${esc(j.currentStep.title)}</span>` : ''}${j.progress != null && !['done', 'failed', 'cancelled'].includes(j.status) ? ` <span class="dock-bar"><span class="dock-bar-in" style="width:${Math.max(0, Math.min(100, Number(j.progress) || 0))}%"></span></span> <span class="small">${Math.max(0, Math.min(100, Number(j.progress) || 0))}%</span>` : ''}${j.error ? ` <span class="err small">${esc(j.error)}</span>` : ''} <span class="muted small">${esc(ago(j.updatedAt))}</span></div>`;
return `<div class="dock-list" data-tab="active"${tab !== 'active' ? ' hidden' : ''}>${active.length ? active.map(row).join('') : '<div class="muted small">진행 중인 작업이 없습니다.</div>'}</div><div class="dock-list" data-tab="history"${tab !== 'history' ? ' hidden' : ''}>${history.length ? history.map(row).join('') : '<div class="muted small">최근 작업 기록이 없습니다.</div>'}</div>`;
}
/** [0.3.17] 대상 서버 선택 목록: 라디오(단일) 또는 체크(여러 개). 준비(ready)되지 않은 서버는 고를 수 없다. eligible 이 있으면 그 서버만 미리 체크. */
export function serverChoiceHtml(servers, { name = 'serverId', multi = false, eligible = null } = {}) {
if (!servers || !servers.length) return '<div class="aw-state">선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.</div>';
return `<div class="choice-list">${servers.map((s) => { const ok = s.status === 'ready'; const checked = multi && ok && (!eligible || eligible.includes(s.id)); return `<label class="choice${ok ? '' : ' off'}"><input type="${multi ? 'checkbox' : 'radio'}" name="${esc(name)}" value="${esc(s.id)}"${ok ? '' : ' disabled'}${checked ? ' checked' : ''}> <b>${esc(s.name)}</b> <span class="muted small">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(SERVER_LABEL[s.status] || s.status)}${s.agentVersion ? ' · Agent ' + esc(s.agentVersion) : ' · Agent 없음'}${s.modules && Object.keys(s.modules).length ? ' · ' + Object.entries(s.modules).map(([m, x]) => `${esc(m)} ${esc(x.actual || '—')}`).join(', ') : ''}</span></label>`; }).join('')}</div>`;
}
// ---------------- [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:<id>'. 비밀값은 값을 보이지 않고 설정됨/없음과 새 값 입력만. 전체 범위에는 서버별 항목이 없다. */
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 ? `<span class="muted small">전체 값 ${esc(showVal(effective.values[k.key]))}</span>` : '';
const tags = `${k.restart ? ' <span class="badge neutral">재시작 필요</span>' : ''}`; const id = `set-${esc(k.key.replace(/[^a-zA-Z0-9_-]/g, '_'))}`;
let input;
if (k.type === 'secret') { const on = !!secrets[k.key]; input = `<input id="${id}" type="password" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="secret" autocomplete="new-password" placeholder="새 값 입력(비우면 그대로)"> <span class="${on ? 'ok' : 'muted'}">${on ? '● 설정됨' : '○ 없음'}</span>${on ? ` <button type="button" class="btn small" data-act="settings-delete" data-key="${esc(k.key)}">삭제</button>` : ''}`; }
else if (k.type === 'bool') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="bool"><option value="">(지정 안 함)</option><option value="true"${v === true ? ' selected' : ''}>켬</option><option value="false"${v === false ? ' selected' : ''}>끔</option></select>`;
else if (k.type === 'enum') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="enum"><option value="">(지정 안 함)</option>${(k.options || []).map((o) => `<option value="${esc(o)}"${v === o ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
else input = `<input id="${id}" type="${['int', 'number'].includes(k.type) ? 'number' : 'text'}"${k.type === 'number' ? ' step="any"' : ''}${k.min !== undefined ? ` min="${k.min}"` : ''}${k.max !== undefined ? ` max="${k.max}"` : ''} name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="${esc(k.type)}" value="${has ? esc(showVal(v)) : ''}"${k.hint ? ` placeholder="${esc(k.hint)}"` : ''}>`;
return `<div class="set-field"><label for="${id}">${esc(k.label)} <span class="mono muted small">${esc(k.key)}</span>${tags}</label><div>${input} ${inh}</div></div>`;
};
const groups = (cat.groups || []).map((g) => { const keys = g.keys.filter((k) => isServer || k.scope !== 'server'); if (!keys.length) return ''; return `<fieldset class="set-group"><legend>${esc(g.label)}</legend>${keys.map(field).join('')}</fieldset>`; }).join('');
return `<form id="settings-form" class="set-form" data-module="${esc(cat.module)}" data-scope="${esc(scope)}" autocomplete="off">${groups}<div class="actions"><button type="button" class="btn primary" data-act="settings-save">저장(${isServer ? '이 서버 덮어쓰기' : '전체'})</button> <span class="muted small">비운 칸은 지정을 지웁니다(${isServer ? '전체 값을 따름' : '모듈 기본값을 따름'}). 비밀값은 새 값을 넣을 때만 바뀝니다. 저장만으로는 서버에 반영되지 않습니다 — 아래 "서버에 적용"을 누르세요.</span></div></form>`;
}
/** 서버별 적용 상태 표 */
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) => `<a href="/ops/settings?module=${esc(module)}&scope=server:${esc(r.serverId)}"><b>${esc(r.serverName || r.serverId)}</b></a><br><span class="muted small">${esc(r.serverStatus || '')}</span>` }, { l: '상태', f: st }, { l: '적용 시각', f: (r) => fmt(r.appliedAt) }, { l: '적용된 설정', f: (r) => `<span class="mono small">${esc(String(r.appliedHash || '—').slice(0, 19))}</span>` }, { l: '오류', f: (r) => r.error ? `<span class="err small">${esc(r.error)}</span>` : '—' }, { l: '', f: (r) => `<button class="btn small" data-act="settings-apply" data-m="${esc(module)}" data-s="${esc(r.serverId)}">이 서버에 적용</button>` }], rows || [], { empty: '이 모듈이 배치된 서버가 없습니다 — 배포 릴리스에서 먼저 배포하세요' })}<div class="actions"><button class="btn primary" data-act="settings-apply" data-m="${esc(module)}">서버에 적용(선택)</button></div>`;
}
/** 통합 설정 관제 화면 본문 */
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 = `<div class="tabs">${modules.map((m) => `<a href="/ops/settings?module=${esc(m)}" class="${m === module ? 'on' : ''}">${esc((catalog[m] || {}).label || m)}${overview[m] && overview[m].stale ? ` <span class="badge warn">${overview[m].stale}</span>` : ''}</a>`).join('')}</div>`;
const scopeSel = `<div class="row"><label>범위 <select id="settings-scope" data-module="${esc(module)}"><option value="global"${scope === 'global' ? ' selected' : ''}>전체(모든 서버 기본값)</option>${placements.map((p) => `<option value="server:${esc(p.id)}"${scope === 'server:' + p.id ? ' selected' : ''}>서버 ${esc(p.name)}(덮어쓰기)</option>`).join('')}</select></label></div>`;
return `${tabs}<p class="muted">${esc(cat.description || '')}</p>${scopeSel}${settingsFormHtml(cat, { scope, values: stored.values || {}, secrets: stored.secrets || {}, effective })}<h2>서버별 적용 상태</h2><div id="settings-state">${settingsStateHtml(ov.servers, { module })}</div>`;
}
export function incidentsView(incidents, notifications = []) {
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { 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)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
<div class="actions"><button class="btn primary" data-act="incident-new">사건 등록</button></div>
<h2>운영자 알림</h2>${table([{ l: '분류', k: 'category' }, { l: '내용', f: (n) => `<b>${esc(n.title)}</b>${n.count > 1 ? ` <span class="muted">×${n.count}</span>` : ''}<br>${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) => `<b>${esc(a.action)}</b>${a.immutable ? ' <span class="muted">(삭제 불가)</span>' : ''}` }, { l: '대상', f: (a) => `${esc(a.subject_type || '')} <span class="mono">${esc(a.subject_id || '')}</span>` }, { l: '결과', f: (a) => a.outcome === 'ok' ? badge('ok', '성공') : badge('failed', a.outcome) }, { l: '상세', f: (a) => a.detail ? `<details><summary>보기</summary><pre class="pre">${esc(JSON.stringify(a.detail, null, 1))}</pre></details>` : '' }], 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 `<div class="card emergency"><div class="row"><b>${esc(s.id)}</b> ${badge(s.status === 'closed' ? 'done' : 'warn', L[s.status] || s.status)} <span class="muted">사건 ${esc(s.incident_key)} · 선언 ${fmt(s.declared_at)} (${esc(s.declared_by)})</span></div>
<ol class="flow">${steps.map((st, i) => `<li class="${i < idx ? 'done' : i === idx ? 'now' : ''}">${esc(L[st])}</li>`).join('')}</ol>
${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('<br>') : '없음 (3단계 모두 필요)'], ['자격', s.credential_issued_at ? `발급 ${fmt(s.credential_issued_at)} · 만료 ${fmt(s.credential_expires_at)} · ${s.credential_revoked_at ? '폐기 ' + fmt(s.credential_revoked_at) : '<span class="err">유효</span>'}` : '미발급(한 번만 표시되며 다시 볼 수 없음)'], ['수행 기록', (s.actions || []).length ? (s.actions || []).map((a) => `${fmt(a.at)} ${esc(a.action)} ${esc(a.target || '')} → ${a.ok ? '성공' : `<span class="err">거부/실패: ${esc(a.error || '')}</span>`}`).join('<br>') : '없음'], ['결과 확인', s.verify ? `HAL 건강 ${s.verify.halHealth ? '정상' : '<span class="err">비정상</span>'} ${fmt(s.verify.at)}` : '—'], ['상태 조정', s.reconcile ? `차이 ${(s.reconcile.drift || []).length}건 · 응답 없는 서버 ${(s.reconcile.offlineServers || []).length}대` : '—']])}
<div class="actions">${s.status === 'declared' || s.status === 'approving' ? `<button class="btn primary" data-act="em-approve" data-id="${esc(s.id)}" data-step="${approvals.length + 1}">${approvals.length + 1}단계 승인</button>` : ''}${s.status === 'approved' ? `<button class="btn primary" data-act="em-credential" data-id="${esc(s.id)}">자격 발급(1회 표시)</button>` : ''}${['credential_issued', 'performing'].includes(s.status) ? `<button class="btn" data-act="em-perform" data-id="${esc(s.id)}">작업 수행</button> <button class="btn" data-act="em-verify" data-id="${esc(s.id)}">결과 확인·자격 폐기</button>` : ''}${s.status === 'verified' ? `<button class="btn" data-act="em-reconcile" data-id="${esc(s.id)}">HAL 상태 조정</button>` : ''}${s.status !== 'closed' ? `<button class="btn danger" data-act="em-close" data-id="${esc(s.id)}">사건 종료</button>` : ''}</div></div>`; };
return `<p class="muted">HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. 허용 대상: ${(data.scopes || []).map(esc).join(', ')} · 허용 작업: ${Object.keys(data.actions || {}).map(esc).join(', ')} — 일반 고객 서비스 배포는 금지(거부됨).</p><div class="actions"><button class="btn danger" data-act="em-declare">긴급 복구 선언</button></div>${(data.sessions || []).length ? data.sessions.map(sess).join('') : statePanel('empty', '긴급 복구 세션이 없습니다')}`;
}
/** 재해복구(§34) */
export function drView(data) {
const r = data.readiness || {}; const cfg = r.config || {};
return `<div class="cards"><div class="card"><h2>준비 상태</h2>${r.ready ? badge('ok', r.label) : badge('warn', r.label)}${kv([['목표 복구 시간(RTO)', cfg.rto_minutes ? `${cfg.rto_minutes}분` : '<span class="err">미설정</span>'], ['허용 데이터 시점(RPO)', cfg.rpo_minutes ? `${cfg.rpo_minutes}분` : '<span class="err">미설정</span>'], ['복구 사본 주기·보존', cfg.snapshot_interval_minutes ? `${cfg.snapshot_interval_minutes}분 · ${cfg.retention_count}개` : '<span class="err">미설정</span>'], ['외부 보관', 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 ?? '—'})` : '<span class="err">실제 복구 훈련 전 — 정식 운영 준비 완료로 표시하지 않음</span>'], ['다음 훈련', r.lastDrill && r.lastDrill.nextDueAt ? fmt(r.lastDrill.nextDueAt) : '예정 없음'], ['운영 중 HAL', data.hal && data.hal.bootstrap ? `<span class="mono">${esc(data.hal.bootstrap.live_instance_id || '')}</span> ${badge('ok', 'HAL 하나만 운영 상태')}` : '—']])}<div class="actions"><button class="btn" data-act="dr-config">설정 변경</button> <button class="btn" data-act="dr-snapshot">지금 복구 사본 만들기</button> <button class="btn" data-act="dr-drill">복구 훈련 시작</button></div></div></div>
<h2>복구 사본</h2>${table([{ l: '사본', f: (s) => `<span class="mono">${esc(s.id)}</span><br>${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 ? `<button class="btn small danger" data-act="dr-restore" data-id="${esc(s.id)}">복구 요청</button> <button class="btn small" data-act="dr-drill" data-id="${esc(s.id)}">이 사본으로 훈련</button>` : '' }], data.snapshots || [], { empty: '복구 사본이 없습니다' })}
<h2>복구 요청(별도 승인 + 기존 제어 위치 중지 확인)</h2>${table([{ l: '요청', f: (x) => `<span class="mono">${esc(x.id)}</span><br>사본 ${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) : '<span class="err">미확인</span>' }, { l: '확인·차이', f: (x) => x.checks_json ? `<pre class="pre">${esc(x.checks_json)}</pre>` : '—' }, { l: '', f: (x) => x.status === 'requested' ? `<button class="btn small" data-act="dr-approve" data-id="${esc(x.id)}">승인</button>` : x.status === 'approved' ? `<button class="btn small danger" data-act="dr-start" data-id="${esc(x.id)}">복구 시작</button>` : x.status === 'running' ? `<button class="btn small" data-act="dr-finish" data-id="${esc(x.id)}">실제 확인 입력·완료</button>` : '' }], data.restores || [], { empty: '복구 요청이 없습니다' })}
<h2>복구 훈련</h2>${table([{ l: '훈련', f: (x) => `<span class="mono">${esc(x.id)}</span>` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` <button class="btn small" data-act="dr-drill-finish" data-id="${esc(x.id)}">결과 입력</button>` : 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 ? `<pre class="pre">${esc(x.findings_json)}</pre>` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`;
}
export function accountsView(data) {
return `<div class="cards"><div class="card"><h2>내 계정</h2>${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` <span class="muted">${esc(data.me.securityHint || '')}</span>`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '만료(10분 지남)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}<div class="actions"><button class="btn" data-act="reauth">재인증</button> <button class="btn" data-act="password">비밀번호 변경</button> ${data.me.mfaEnabled ? `<button class="btn" data-act="mfa-disable">MFA 해제</button>` : `<button class="btn primary" data-act="mfa-begin">MFA 등록</button>`}</div></div>
<div class="card"><h2>로그인 세션</h2>${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' <b>(현재)</b>' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `<button class="btn small" data-act="session-revoke" data-id="${esc(s.id)}">종료</button>` }], data.sessions || [])}<div class="actions"><button class="btn" data-act="session-revoke-all">다른 모든 세션 종료</button></div></div></div>
<h2>접근권(허용 네트워크 밖 기기)</h2>${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 ? '' : `<button class="btn small" data-act="grant-revoke" data-id="${esc(g.id)}">회수</button>` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}<div class="actions"><button class="btn" data-act="grant-issue">접근권 발급(URL 1회 표시)</button></div>
<h2>운영자 계정</h2>${table([{ l: '아이디', f: (a) => `<b>${esc(a.login)}</b> ${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 || [])}<div class="actions"><button class="btn" data-act="account-new">운영자 추가</button></div>`;
}
/** 운영 현황(§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) => `<div class="card comp"><b>${esc(COMP_LABEL[c.name] || c.name)}</b> <span class="muted small">${COMP_LABEL[c.name] && COMP_LABEL[c.name] !== c.name ? esc(c.name) : ''}</span> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
const jobs = o.jobs || {}; const srv = o.servers || {};
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음'], o.settings ? ['설정 적용 필요', o.settings.stale ? `<a class="err" href="/ops/settings">${o.settings.stale}건</a>` : '없음'] : null])}<a class="btn" href="/ops/servers">서버와 용량</a> <a class="btn" href="/ops/settings">통합 설정</a></div>
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
<h2>장애·유지보수</h2>${(o.incidents || []).length ? table([{ l: '사건', f: (i) => `<a href="/ops/incidents">${esc(i.title)}</a>` }, { l: '심각도', k: 'severity' }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음') }, { l: '시작', f: (i) => fmt(i.started_at) }], o.incidents) : badge('ok', '진행 중인 사건 없음')}
<h2>처리 대기 승인(포털)</h2><div id="pending-approvals">${statePanel('loading', '포털에서 승인 대기 항목을 불러오는 중')}</div><p class="muted">HAL 자체 상태 ${fmt(o.generatedAt)} 기준. 오래된 정보는 정상으로 표시하지 않습니다.</p>`;
}
/** 처리 대기 승인 요약(포털 중계 결과) */
export function pendingApprovals(p) { const row = (label, n, href) => `<li><a href="${href}">${esc(label)}</a> <b>${n}</b>건</li>`; return `<ul class="pending">${row('주문 승인', p.orders, '/ops/orders')}${row('복원 승인', p.restores, '/ops/backup')}${row('서버 이전 승인', p.relocations, '/ops/relocation')}${row('소유권 이전 심사', p.transfers, '/ops/relocation?tab=transfer')}</ul>${p.partial ? statePanel('partial', `일부 항목을 받지 못했습니다: ${p.partial.join(', ')}`) : ''}`; }
// ---------------- 포털 중계 화면(§26.2·§27·§30)
export function customersSearch(d) { return `<form class="row" data-form="customer-search"><input name="q" placeholder="이메일·이름·그룹명·id" value="${esc(d.q || '')}"><button class="btn">검색</button></form><h2>사용자 그룹</h2>${table([{ l: '그룹', f: (g) => `<a href="/ops/customers/${esc(g.id)}"><b>${esc(g.name)}</b></a> <span class="muted">${g.kind === 'personal' ? '개인' : '팀'}</span>` }, { 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: '검색 결과가 없습니다' })}<h2>사용자</h2>${table([{ l: '이메일', f: (u) => `${esc(u.email)}<br><span class="muted">${esc(u.displayName)}</span>` }, { 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 `<div class="cards"><div class="card"><h2>${esc(g.name)} <span class="muted">(${g.kind === 'personal' ? '개인' : '팀'})</span></h2>${kv([['id', `<span class="mono">${esc(g.id)}</span>`], ['연락처', `${esc(g.contactEmail || '—')} ${esc(g.contactPhone || '')}`], ['상태', badge(g.status === 'active' ? 'ok' : 'warn', g.status)], ['조회 기준', fmt(c.generatedAt)]])}<div class="actions"><button class="btn" data-act="cust-change" data-g="${esc(g.id)}">고객 대신 변경</button> <button class="btn" data-act="support-issue" data-g="${esc(g.id)}">일시 접근 발급</button></div></div>
<div class="card"><h2>활성 상품·권리</h2>${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: '활성 상품이 없습니다' })}</div></div>
<h2>구성원</h2>${table([{ l: '이메일', f: (m) => `${esc(m.email)}<br><span class="muted">${esc(m.displayName)} · ${esc(m.alias || '')}</span>` }, { l: '역할', k: 'role' }, { l: '상태', f: (m) => m.status === 'suspended' ? badge('warn', `정지 ${fmt(m.suspendedAt)}`) : badge('ok', m.status) }, { l: '확인·MFA', f: (m) => `${m.verified ? '이메일 확인' : '<span class="err">미확인</span>'}${m.locked ? ' · 잠김' : ''}` }, { l: '마지막 로그인', f: (m) => fmt(m.lastLoginAt) }], c.members || [])}
<h2>사이트</h2>${table([{ l: '사이트', f: (s) => `<a href="/ops/sites/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.primary_domain || '대표 도메인 없음')}</span>` }, { 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: '사이트가 없습니다' })}
<div class="cards"><div class="card"><h2>메일</h2>${table([{ l: '서비스', f: (m) => `<b>${esc(m.name)}</b><br><span class="muted">${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'}</span>` }, { 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: '메일 서비스 없음' })}</div>
<div class="card"><h2>도메인</h2>${table([{ l: '도메인', f: (d) => `<b>${esc(d.name)}</b>${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: '도메인 없음' })}</div>
<div class="card"><h2>백업</h2>${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: '백업 옵션 없음' })}</div>
<div class="card"><h2>사용량·제한</h2>${table([{ l: '자원', k: 'label' }, { l: '구매', k: 'purchased' }, { l: '배정', k: 'allocated' }, { l: '예약', k: 'reserved' }, { l: '사용', k: 'used' }, { l: '미배정', k: 'unallocated' }], c.usage || [])}${(c.limits || []).length ? `<p class="err">제한 적용: ${c.limits.map((l) => `${esc(l.resource)} ${l.level}%`).join(', ')}</p>` : ''}</div></div>
<h2>주문·결제</h2>${table([{ l: '주문', f: (o) => `<b>${esc(o.order_no)}</b> <span class="muted">${esc(o.kind)}</span>` }, { 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: '주문 없음' })}
<h2>정지·보존</h2>${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}`]])}
<h2>고객 화면 오류·실패 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '오류', f: (j) => `<span class="err">${esc(j.error || j.userMessage || '')}</span>` }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], c.failedJobs || [], { empty: '실패한 작업 없음' })}${auditTable(c.errors || [])}
<h2>일시 접근 기록</h2>${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' ? `<button class="btn small" data-act="support-revoke" data-id="${esc(a.id)}">회수</button>` : '' }], c.supportAccess || [], { empty: '일시 접근 기록 없음' })}
<h2>운영자 대신 변경 기록</h2>${table([{ l: '시각', f: (x) => fmt(x.created_at) }, { l: '항목', f: (x) => `${esc(x.field)} <span class="mono muted">${esc(x.subject_id)}</span>` }, { 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) => `<b>${esc(o.order_no)}</b><br><span class="muted">${esc(o.group && o.group.name)} · ${esc(o.product && o.product.name)} v${esc(o.product && o.product.version)}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.statusLabel ? `<br><span class="muted">${esc(o.statusLabel)}</span>` : '') }, { l: '대상·금액', f: (o) => `${esc(o.target_type || '그룹')} ${esc(o.target_id || '')}<br><b>${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)}</b> <span class="muted">(세금 ${Number(o.tax).toLocaleString('ko-KR')})</span>` }, { l: '권리 변화', f: (o) => (o.entitlementDelta || []).map((e) => `${esc(e.label || e.resource)} +${e.amount}${e.target ? ' → ' + esc(e.target) : ''}`).join('<br>') || '—' }, { l: '현재 사용량', f: (o) => (o.currentUsage || []).filter((u) => u.purchased || u.used).map((u) => `${esc(u.label)} ${u.used}/${u.purchased}`).join('<br>') || '—' }, { l: '기한·입금', f: (o) => `${fmt(o.due_at)}<br>${o.paid_at ? '입금 ' + fmt(o.paid_at) + ' ' + esc(o.paid_ref || '') : '<span class="err">입금 미확인</span>'}` }, { l: '', f: (o) => o.status === 'awaiting_payment' ? `<button class="btn small" data-act="order-paid" data-id="${esc(o.id)}">입금 확인</button> <button class="btn small danger" data-act="order-reject" data-id="${esc(o.id)}">거절</button>` : o.status === 'awaiting_approval' ? `<button class="btn small primary" data-act="order-approve" data-id="${esc(o.id)}">승인</button> <button class="btn small danger" data-act="order-reject" data-id="${esc(o.id)}">거절</button>` : o.status === 'approved' ? `<button class="btn small" data-act="order-refund" data-id="${esc(o.id)}" data-g="${esc(o.group_id)}">환불·크레딧</button>` : '' }], d.orders || [], { empty: '주문이 없습니다' }); }
export function productsView(d) { return `${table([{ l: '상품', f: (p) => `<b>${esc(p.name)}</b> <span class="muted">${esc(p.product_id)} v${p.version}</span>` }, { 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 ? `<span class="err">${p.deferred.map((x) => esc(x.key || x)).join(', ')}</span>` : '없음' }, { l: '', f: (p) => `<button class="btn small" data-act="product-new-version" data-id="${esc(p.id)}" data-p="${esc(p.product_id)}">새 버전 만들기</button> <button class="btn small" data-act="product-publish" data-id="${esc(p.id)}" data-pub="${p.published ? 0 : 1}">${p.published ? '비공개' : '공개'}</button>` }], d.products || [], { empty: '상품이 없습니다 — "검증 Catalog 넣기"로 시작' })}<p class="muted">이미 판매된 조건은 바꾸지 않고 새 버전을 만듭니다(§27.2). 기존 고객은 자기 버전을 유지합니다.</p><div class="actions"><button class="btn" data-act="catalog-seed">검증 Catalog 넣기</button></div>`; }
export function sitesView(d) { return table([{ l: '사이트', f: (s) => `<a href="/ops/sites/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.primaryDomain || s.primary_domain || '—')} · 그룹 ${esc(s.groupId || '')}</span>` }, { 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 ? `<span class="err">${esc(s.actionNeeded)}</span>` : '—' }], d.sites || [], { empty: '사이트가 없습니다' }); }
export function siteDetailView(d) { const s = d.site || {}; return `<div class="cards"><div class="card"><h2>${esc(s.name)}</h2>${kv([['그룹', `<a href="/ops/customers/${esc(s.group_id)}">${esc(s.group_id)}</a>`], ['상태', 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)]])}<div class="actions"><button class="btn" data-act="site-refresh" data-id="${esc(s.id)}">상태 다시 확인</button></div></div>
<div class="card"><h2>외부 Agent</h2>${d.externalAgent ? kv([['상태', esc(d.externalAgent.status)], ['마지막 확인', fmt(d.externalAgent.lastSeenAt)], ['버전', esc(d.externalAgent.version || '')]]) : '<span class="muted">관리형 또는 미연결</span>'}</div></div>
<h2>배포 이력</h2>${table([{ l: '배포', f: (x) => `<span class="mono">${esc(x.id)}</span><br>${esc(x.version_label || x.version_ref || '')}` }, { l: '상태', f: (x) => jobBadge(x.status) + (x.verify_state ? ` <span class="muted">${esc(x.verify_state)}</span>` : '') }, { l: '시각', f: (x) => `${fmt(x.created_at)} → ${fmt(x.ended_at)}` }], d.deployments || [], { empty: '배포 이력 없음' })}
<h2>도메인</h2>${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: '도메인 없음' })}
<h2>작업</h2>${(d.jobs || []).map((j) => jobBlock(j, { actions: false })).join('') || statePanel('empty', '작업 없음')}<h2>감사 기록</h2>${auditTable(d.audit || [])}`; }
export function mailView(d) { return `<h2>메일 서버</h2>${table([{ l: '서버', f: (s) => `<b>${esc(s.name || s.id)}</b><br><span class="muted">${esc(s.baseUrl || s.base_url || '')}</span>` }, { l: '연결', f: (s) => s.ok === false || s.error ? badge('failed', '연결 실패') + ` <span class="err">${esc(s.error || '')}</span>` : 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) => `<button class="btn small" data-act="mail-rates" data-id="${esc(s.id)}">발송 제한·제출량</button>` }], d.servers || [], { empty: '등록된 메일 서버가 없습니다' })}
<h2>메일 서비스</h2>${table([{ l: '서비스', f: (m) => `<b>${esc(m.name)}</b><br><span class="muted">${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'} · 그룹 ${esc(m.group_id)}</span>` }, { 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('<br>') : '—' }, { l: '보존', f: (m) => m.retention_until ? `${fmt(m.retention_until)} · 삭제 ${fmt(m.delete_after)}` : '—' }, { l: '', f: (m) => `<button class="btn small" data-act="mail-test" data-id="${esc(m.id)}">외부 송수신 시험</button> <button class="btn small" data-act="mail-security" data-id="${esc(m.id)}">DNS 보안 확인</button> <button class="btn small" data-act="mail-operating" data-id="${esc(m.id)}">운영 상태</button> <button class="btn small danger" data-act="mail-cancel" data-id="${esc(m.id)}">정지·해지</button>` }], d.services || [], { empty: '메일 서비스가 없습니다' })}<p class="muted">메일 원문은 운영 화면에서 열람하지 않습니다. 진단 조회는 접근 사유를 기록합니다(§30.2).</p>`; }
export function domainsView(d, src) { return `<h2>도메인·인증서(§30.1)</h2>${table([{ l: '도메인', f: (x) => `<b>${esc(x.name)}</b>${x.is_primary ? ' (대표)' : ''}<br><span class="muted">${esc(x.purpose)} · 그룹 ${esc(x.group_id)} · 사이트 ${esc(x.site_id || '—')}</span>` }, { 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 ? `<br><span class="${Date.parse(x.cert_expires_at) - Date.now() < 14 * 86400000 ? 'err' : 'muted'}">만료 ${fmt(x.cert_expires_at)}</span>` : '') }, { l: '메일 보안', f: (x) => esc(x.mail_dns_state || '—') }, { l: '마지막 외부 확인', f: (x) => fmt(x.last_checked_at) }, { l: '반복 실패', f: (x) => x.failures ? `<span class="err">${x.failures}회</span>` : '—' }], (d.domains || []), { empty: '도메인이 없습니다' })}${d.duplicates && d.duplicates.length ? `<p class="err">여러 고객에게 연결된 도메인(활성화 차단): ${d.duplicates.map(esc).join(', ')}</p>` : ''}<div class="actions"><button class="btn" data-act="domains-sweep">전체 다시 확인</button></div>
<h2>Source/Gitea(§30.5)</h2>${table([{ l: '사이트', f: (o) => `<b>${esc(o.site_name || o.site_id)}</b> <span class="mono muted">${esc(o.site_id)}</span>` }, { 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) => `<span class="mono">${esc(String(o.last_deployed_revision || '').slice(0, 10))}</span>` }], (src && src.options) || [], { empty: 'Source 옵션이 없습니다' })}`; }
export function backupView(d) { return `<h2>복원 승인(§30.3)</h2>${table([{ l: '요청', f: (r) => `<span class="mono">${esc(r.id)}</span><br><b>${esc(r.site_name)}</b> ${esc(r.primary_domain || '')}<br><span class="muted">${esc(r.group_name)} · 백업 ${fmt(r.backup_created_at || r.backupAt)}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">${esc(r.approval_state)}</span>` : '') }, { 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' ? `<button class="btn small primary" data-act="restore-approve" data-id="${esc(r.id)}">승인(최종 확인)</button> <button class="btn small danger" data-act="restore-reject" data-id="${esc(r.id)}">거절</button>` : r.status === 'failed' ? `<button class="btn small" data-act="restore-retry" data-id="${esc(r.id)}">재시도</button>` : '' }], d.restores || [], { empty: '복원 요청이 없습니다' })}
<h2>백업 옵션</h2>${table([{ l: '사이트', f: (o) => `<b>${esc(o.site_name || o.site_id)}</b> <span class="muted">${esc(o.group_name || '')}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.paused_reason ? ` <span class="err">${esc(o.paused_reason)}</span>` : '') }, { l: '최근 성공·실패', f: (o) => `${fmt(o.last_success_at)}<br>${o.consecutive_failures ? `<span class="err">연속 실패 ${o.consecutive_failures}</span>` : '실패 없음'}` }, { l: '다음 예정', f: (o) => fmt(o.next_run_at) }, { l: '만료·삭제', f: (o) => `${fmt(o.ends_at)} · ${fmt(o.delete_after)}` }, { l: '', f: (o) => `<button class="btn small" data-act="backup-retry" data-id="${esc(o.id)}">재시도</button> <button class="btn small" data-act="backup-override" data-id="${esc(o.id)}">같은 서버 허용</button>` }], d.options || [], { empty: '활성 백업 옵션이 없습니다' })}
<h2>백업 목록</h2>${table([{ l: '백업', f: (b) => `<span class="mono">${esc(b.id)}</span><br>${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)} <span class="muted">${esc(b.location || '')}</span>` }], d.backups || [], { empty: '백업이 없습니다' })}`; }
export function relocationView(d, tr) { return `<h2>서버 이전(§30.4)</h2>${table([{ l: '대상', f: (r) => `<b>${esc(r.subjectName || r.subject_id)}</b> <span class="muted">${esc(r.subject_type)} · ${esc(r.group_name || '')}</span><br>${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}<br><span class="muted">${esc(r.reason || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">승인 ${esc(r.approval_state)}</span>` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `<br><span class="muted">${esc(r.job.waitReason)}</span>` : ''}` : '—' }, { 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' ? `<button class="btn small primary" data-act="reloc-approve" data-id="${esc(r.id)}">승인</button> <button class="btn small danger" data-act="reloc-reject" data-id="${esc(r.id)}">거절</button>` : ['failed', 'paused'].includes(r.status) ? `<button class="btn small" data-act="reloc-retry" data-id="${esc(r.id)}">재시도</button> <button class="btn small" data-act="reloc-resume" data-id="${esc(r.id)}">이어서</button> <button class="btn small danger" data-act="reloc-revert" data-id="${esc(r.id)}">되돌리기</button>` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}<div class="actions"><button class="btn" data-act="reloc-new">플랫폼 사유 이전 만들기</button></div>
<h2>소유권 이전 심사(§22)</h2>${table([{ l: '요청', f: (t) => `<span class="mono">${esc(t.id)}</span><br>${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) ? `<button class="btn small primary" data-act="transfer-approve" data-id="${esc(t.id)}">승인</button> <button class="btn small danger" data-act="transfer-reject" data-id="${esc(t.id)}">거절</button>` : t.status === 'disputed' ? `<button class="btn small" data-act="transfer-resolve" data-id="${esc(t.id)}">분쟁 해결</button>` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; }
/** [0.4.1] 모듈 표시 이름(배너·릴리스 화면의 "기능 단위 서버 배포 대기" 목록). 브라우저에서도 쓰므로 hal/modules 를 import 하지 않고 여기 둔다. */
export const MODULE_LABELS = { hal: 'HAL', agent: 'Node Agent', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye', observability: 'HawkEye' };
export function moduleLabel(m) { return MODULE_LABELS[m] || String(m); }