2026-10-26 12:15:48 +00:00
/**
* 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 ) } · ${ 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 = [] } = {}) {
2026-10-27 08:56:32 +00:00
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: '사이트 디렉터리가 없습니다' })}` : '' ;
2026-10-26 12:15:48 +00:00
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>
2026-10-27 08:56:32 +00:00
$ { 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)}`;
2026-10-26 12:15:48 +00:00
}
/** 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 ) {
2026-10-28 16:50:12 +00:00
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>
2026-10-26 12:15:48 +00:00
<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: '소유권 이전 요청이 없습니다' })}`; }