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