/** * hal/ui.mjs — 운영자 콘솔 화면(§25~§34) 라우트. hal/app.mjs 가 registerUi(app, d) 로 부른다. * * 구조 * - 모든 화면은 /ops/* 아래에 있고 app.mjs 의 운영자 미들웨어(네트워크 관문 → 운영자 세션)를 먼저 거친다. 허용 네트워크·접근권이 없으면 로그인 화면도 열리지 않는다(403). * - 운영자 세션이 없으면(고객 세션 쿠키가 있어도) 로그인 화면만 보인다. 고객 세션으로는 어떤 운영자 화면도 열 수 없다. * - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는 * 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음). * - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css). * - [0.3.0] 본문 오른쪽에 AI 채팅룸 패널(#ai-panel, hal/ui/public/ai.js)이 붙는다 — 통합 탭 고정 + 연결별 탭. 좁은 화면에서는 본문 아래로 내려간다(ops.css). 메뉴 'AI 연동'(/ops/ai)에서 연결을 관리한다. * - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs). */ import { esc } from '../lib/http.mjs'; import * as session from '../lib/session.mjs'; import { describe } from '../lib/jobs.mjs'; import { listAudit } from '../lib/audit.mjs'; import { listNotifications } from '../lib/notify.mjs'; import { redact } from '../lib/redact.mjs'; import { nowIso } from '../lib/db.mjs'; import { readFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as core from './core.mjs'; import * as ops from './ops.mjs'; import * as emergency from './emergency.mjs'; import * as dr from './dr.mjs'; import * as mailsvc from './mailsvc.mjs'; import { MODULES, DEPLOY_ORDER } from './modules/index.mjs'; import * as R from './ui/render.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); /** 메뉴(§25) */ export const MENU = [ ['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'], ['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'], ['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'], ['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'], ]; export function registerUi(app, d) { /** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */ function shell(ctx, { title, nav, body, page, id = '', scripts = [] }) { if (!ctx.operator) return ctx.html(loginPage(ctx, d)); const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null; const health = core.selfHealth(d); const emOpen = d.store.get('SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'').n; const banner = []; // [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다. if (grant) banner.push(`
ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)
`); if (!health.ok) banner.push(`
⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요
`); if (emOpen) banner.push(`⚠ 긴급 복구 세션 ${emOpen}건 진행 중`); // [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함. if (health.bootstrap && !health.bootstrap.complete) banner.push(`
! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)
`); if (ctx.operator.mustChangePassword) banner.push(`! 비밀번호를 바꿔야 합니다`); return ctx.html(`${esc(title)} · Ariweb HAL 운영자 콘솔
Ariweb HALv${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}

${esc(title)}

${banner.join('')}
${body || ''}
${scripts.map((s) => ``).join('')}`); } // ---------------- 로그인·최초 설정 app.get('/ops/login', (ctx) => ctx.operator ? ctx.redirect('/ops/') : ctx.html(loginPage(ctx, d))); app.get('/ops/setup', (ctx) => ctx.html(loginPage(ctx, d, { setup: true }))); // ---------------- 운영 현황(§26.1) app.get('/ops/', (ctx) => shell(ctx, { title: '운영 현황', nav: 'overview', page: 'overview', body: R.overviewView(overviewLocal(d)) })); // ---------------- 포털 중계 화면(브라우저가 /ops/api/portal/* 로 채운다) const relayPage = (path, nav, title, page) => app.get(path, (ctx) => shell(ctx, { title, nav, page, id: ctx.params.id || ctx.params.groupId || '', body: R.statePanel('loading', '포털에서 불러오는 중') })); relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer'); relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products'); relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site'); // 메일 서비스: HAL 관리형(legacy 가져오기, HAL 자체 데이터)을 먼저 서버에서 그리고, 포털 메일 서비스는 브라우저가 중계로 채운다(ops.js 는 #portal-mail 만 바꾼다). app.get('/ops/mail', (ctx) => shell(ctx, { title: '메일 서비스', nav: 'mail', page: 'mail', body: `

HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)

${mailServicesView(mailsvc.readback(d))}

포털 메일 서비스

${R.statePanel('loading', '포털에서 불러오는 중')}
` })); relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains'); relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation'); // ---------------- 서버(§28) app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `
${R.serversTable(core.listServers(d))}

등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).

` })); app.get('/ops/servers/:id', (ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) return shell(ctx, { title: '서버', nav: 'servers', page: 'server', body: R.statePanel('failed', '서버가 없습니다') }); const ps = core.publicServer(d, s); const jobs = d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe); const agentJobs = d.store.all('SELECT id, agent_id, kind, status, progress, created_at, ended_at, error, user_message FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || ''); const actions = `
${ps.status === 'pending' ? ` ` : ''}${ps.rawStatus === 'maintenance' ? `` : ``}
`; return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}

지표(90일 이상 보존)

${R.statePanel('loading', '지표를 불러오는 중')}
` }); }); // ---------------- 릴리스(§32.1) app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.bootstrapState(d), modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) })); // ---------------- 작업(§29) app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id })); const filters = `
전체 진행·대기 확인 필요·실패 모듈 배포
`; return shell(ctx, { title: '운영 작업', nav: 'jobs', page: 'jobs', body: filters + R.table([{ l: '작업 번호', f: (j) => `${esc(j.id)}
${esc(j.label)}` }, { l: '종류', k: 'kind' }, { l: '고객·대상', f: (j) => `${esc(j.groupId || '—')}
${esc((j.subject && (j.subject.target || j.subject.module || j.subject.payload?.siteId)) || '')}` }, { l: '시작 주체', f: (j) => esc(j.actorType === 'operator' ? `운영자 ${j.createdBy || ''}` : j.actorType === 'system' ? '자동' : `사용자 ${j.createdBy || ''}`) }, { l: '상태', f: (j) => R.jobBadge(j.status) + (j.stale ? ' ' + R.badge('stale', '갱신 지연') : '') }, { l: '단계·진행', f: (j) => `${esc(j.currentStep ? j.currentStep.title : '—')} · ${j.progress || 0}%` }, { l: '재시도·대기', f: (j) => `${j.maxAttempts > 1 ? `${j.attempt}/${j.maxAttempts}` : '—'}${j.waitReason ? `
${esc(j.waitReason)}` : ''}` }, { l: '갱신', f: (j) => R.fmt(j.updatedAt) }, { l: '오류', f: (j) => `${esc(j.error || '')}` }], jobs, { empty: '작업이 없습니다' }) }); }); app.get('/ops/jobs/:id', (ctx) => { const j = d.jobs.get(ctx.params.id); if (!j) return shell(ctx, { title: '작업', nav: 'jobs', page: 'job', body: R.statePanel('failed', '작업이 없습니다') }); const v = { ...describe(j), subject: redact(j.subject), result: j.result, log: j.log, createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }; return shell(ctx, { title: `작업 · ${j.label || j.kind}`, nav: 'jobs', page: 'job', id: j.id, body: `${R.jobBlock(v)}${R.childrenBlock(d.jobs.childrenSummary(j.id))}

Agent 작업

${R.agentJobsTable(d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id))}${j.result ? `

결과

${esc(JSON.stringify(redact(j.result), null, 1))}
` : ''}

기록

${esc((j.log || []).map((l) => `${l.at} ${l.line}`).join('\n') || '없음')}
` }); }); // ---------------- 사건·알림(§31.2) app.get('/ops/incidents', (ctx) => shell(ctx, { title: '장애와 알림', nav: 'incidents', page: 'incidents', body: R.incidentsView(d.store.all('SELECT * FROM incidents ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200'), listNotifications(d.store, { groupId: null, limit: 100 })) + `

공개 상태 페이지: /status — 개인정보·내부 위치 없음, 고객 영향 없는 내부 경고는 올리지 않음, 복구 완료는 실제 외부 확인 뒤 게시(§31.3).

` })); // ---------------- 감사(§24.2·§25) app.get('/ops/audit', (ctx) => shell(ctx, { title: '감사 기록', nav: 'audit', page: 'audit', body: `
` + R.auditTable(listAudit(d.store, { action: ctx.query.action || undefined, subjectId: ctx.query.subjectId || undefined, since: ctx.query.since || undefined, limit: 300 })) })); // ---------------- 플랫폼 복구(§33·§34) app.get('/ops/recovery', (ctx) => shell(ctx, { title: '플랫폼 복구', nav: 'recovery', page: 'recovery', body: `
긴급 복구(§33)재해복구(§34)
${R.emergencyView({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS })}
` })); // ---------------- AI 연동(0.3.0, 개발 지시 v0.3.0): 모델 연결 관리 화면. 표·동작은 ai.js 가 /ops/api/ai/* 로 채운다. 비밀값은 서버로만 가고 화면에는 힌트만 돌아온다. app.get('/ops/ai', (ctx) => shell(ctx, { title: 'AI 연동', nav: 'ai', page: 'ai', body: aiPage(d) })); // ---------------- 운영자 계정(§25·§3.3) app.get('/ops/account', (ctx) => shell(ctx, { title: '운영자 계정', nav: 'account', page: 'account', body: R.accountsView({ me: ctx.operator, recentlyAuthed: session.recentlyAuthed(ctx.session), via: ctx.opsVia, sessions: session.listSessions(d.store, ctx.operator.id, 'operator').map((s) => ({ ...s, current: s.id === ctx.session.id })), grants: d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 100'), accounts: d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount) }) })); // [ops UI 추가] 서버 화면용 읽기 전용: 릴리스 변경 이력(VERSION_HISTORY.md 절) app.get('/ops/api/changelog', () => ({ ok: true, changelog: changelog() })); } /** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */ function aiPage(d) { return `

AI 모델을 HAL 에 연결합니다(기본 공급자: Claude · ChatGPT · Gemini · OpenRouter, 그 밖의 OpenAI 호환 API 서비스는 "기타"). OAuth 를 고르면 저장 직후 로그인 창이 자동으로 열리고, 로컬 CLI 는 로그인이 안 되어 있으면 텍스트 셸이 열립니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.

${R.statePanel('loading', '연결 목록을 불러오는 중')}

서버에서 찾은 CLI(로그인 세션)

HAL 서버(실행 계정)에 설치된 Claude Code·Codex CLI 를 자동 탐색합니다(서비스 PATH 밖의 ~/.local/bin 등 포함). 로그인은 HAL 이 대신할 수 없으므로, "로그인 필요"면 서버에서 그 계정으로 claude login / codex login 을 실행한 뒤 "다시 탐색"하세요.

${R.statePanel('loading', 'CLI 를 탐색하는 중')}

연결 추가

사전 준비 — 공급자·인증 방식을 고르면 필요한 준비 사항이 여기에 나옵니다.
API 키
도구 정책(기본: 전부 — 읽기는 즉시, 쓰기·고위험은 승인)

HAL 운영 도구는 연결 즉시 쓸 수 있게 미리 설정되어 있습니다. 인증·승인이 필요한 도구(쓰기·고위험)는 채팅룸에 승인 요청으로 올라오고, 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다.

고유 정보

텍스트 셸(CLI 로그인·대화형)

서버 CLI 의 로그인·대화형 화면을 여기서 직접 다룹니다. claude 의 실제 로그인 명령은 claude auth login(화살표·Enter 로 방식을 고르는 TUI)이고, 이미 설치·로그인된 경우 claude 대화형 세션이 "이 폴더를 신뢰합니까?" 같은 질문을 하므로 단말 화면을 클릭해 키를 직접 누르거나 키 단추(↑↓ Enter Esc Ctrl+C y n)로 답합니다. 허용 명령(login·대화형·상태·로그아웃·버전)만 가짜 단말(PTY)로 돌리며 작업 폴더는 HAL 실행 계정 HOME, 입력은 저장하지 않습니다. 시작에는 최근 10분 재인증이 필요합니다.

${R.statePanel('loading', '셸 세션을 불러오는 중')}

MCP 설정(외부 클라이언트용)

HAL 도구를 MCP 서버로 쓰는 설정입니다. 로컬 CLI 연결에는 자동으로 붙으며, 운영자의 Claude Code·Codex 에 직접 넣을 수도 있습니다. 쓰기·고위험 도구 호출은 통합 방에 승인 요청으로 올라오고 hal_action_status 로 결과를 봅니다.

${R.statePanel('loading', 'MCP 설정을 불러오는 중')}

AI 가 쓰는 HAL 도구

읽기 도구는 즉시 실행됩니다. 쓰기·고위험 도구는 채팅룸에 "제안"으로 멈추고 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다. 임의 셸 명령 도구는 없습니다. MCP 클라이언트용: node hal/index.mjs mcp(읽기 전용).

${R.statePanel('loading', '도구 목록을 불러오는 중')}
`; } /** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */ function loginPage(ctx, d, { setup = false } = {}) { const needSetup = !core.ensureOpsAccountExists(d); const via = ctx.opsVia === 'grant' ? '기기 귀속 접근권' : '허용 네트워크'; const body = needSetup || setup ? `

HAL 최초 설정

bootstrap-hal.sh 가 한 번만 출력한 설정 토큰으로 첫 운영자 계정을 만듭니다. 토큰은 사용 후 폐기되며 다시 쓸 수 없습니다.

` : `

운영자 로그인

고객 계정으로는 로그인할 수 없습니다. 접속 경로: ${esc(via)}.

`; return `Ariweb HAL 운영자 콘솔
${body}

Ariweb HAL v${esc(d.version)} · 반복 실패 시 15분 잠금 · 세션 24시간

`; } /** 운영 현황 데이터(hal/app.mjs overview 와 같은 규칙으로 HAL 자체 데이터만 모은다) */ export function overviewLocal(d) { const servers = core.listServers(d); const health = core.selfHealth(d); const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 }); const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra }); const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; }; const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); }); return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length }, jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) }, incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() }; } /** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */ export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; } /** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */ function mailServicesView(rb) { const warn = []; if (rb.duplicates.length) warn.push(`
⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}
`); if (rb.secretLeaks) warn.push(`
⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인
`); const t = R.table([ { l: '도메인', f: (s) => `${esc(s.domain)}
${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}` }, { l: '공개 주소', f: (s) => s.webUrl ? `${esc(s.publicHost)}` : '—' }, { l: '서버·인스턴스', f: (s) => `${esc(s.serverName || s.serverId)}
${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}` }, { l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} (AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})${s.instance.observedVersion ? `
Agent 관측 ${esc(s.instance.observedVersion)}` : ''}` }, { l: '계정·메일함·메일', f: (s) => s.aggregates ? `${esc(String(s.aggregates.accounts ?? '—'))} · ${esc(String(s.aggregates.mailboxes ?? '—'))} · ${esc(String(s.aggregates.messages ?? '—'))}` : '—' }, { l: '건강·동기화', f: (s) => `${R.healthBadge(s.health || 'unknown')}
${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}` }, { l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}
${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}` }, { l: '비밀', f: (s) => `참조 ${(s.secretRefs || []).length}건(값 없음)` }, ], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' }); return warn.join('') + t + `

총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).

`; }