Files
AriWeb-Suite/hal/ui/render.mjs
T

179 lines
62 KiB
JavaScript
Raw 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)} · ${esc(s.location || '위치 미입력')}</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) */
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}` : '<span class="muted">배치 없음</span>'}`; }).join(' · ');
return `<p class="muted">배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — <b>HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2)</b>. HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'}</p>
${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: '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] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">VERSION_HISTORY 항목 없음</span>' },
{ l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).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 를 만들고 등록하세요' })}
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}" class="mono">${esc(t.server_id)}</a>` }, { 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) }, { 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: '배치된 모듈이 없습니다' })}
<div class="actions"><button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></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', '재인증 필요(고위험 작업)')], ['접속 경로', 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 = (c) => `<div class="card comp"><b>${esc(c.name)}</b> ${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>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</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: '소유권 이전 요청이 없습니다' })}`; }