From 005e0944e47306634d44311d9ab03f7cf4b31515 Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 12:15:48 +0000 Subject: [PATCH] =?UTF-8?q?HAL=20=EC=9A=B4=EC=98=81=EC=9E=90=20=EC=BD=98?= =?UTF-8?q?=EC=86=94=20=ED=99=94=EB=A9=B4(=EC=9A=B4=EC=98=81=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=C2=B7=EA=B3=A0=EA=B0=9D=C2=B7=EC=A3=BC=EB=AC=B8=C2=B7?= =?UTF-8?q?=EC=83=81=ED=92=88=C2=B7=EC=82=AC=EC=9D=B4=ED=8A=B8=C2=B7?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=C2=B7=EB=8F=84=EB=A9=94=EC=9D=B8=C2=B7?= =?UTF-8?q?=EC=84=9C=EB=B2=84=C2=B7=EB=B0=B1=EC=97=85=C2=B7=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=C2=B7=EB=A6=B4=EB=A6=AC=EC=8A=A4=C2=B7=EC=9E=91?= =?UTF-8?q?=EC=97=85=C2=B7=EC=9E=A5=EC=95=A0=C2=B7=EA=B0=90=EC=82=AC=C2=B7?= =?UTF-8?q?=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EB=B3=B5=EA=B5=AC)=EA=B3=BC=20?= =?UTF-8?q?=ED=8F=AC=ED=84=B8=20=EA=B3=A0=EA=B0=9D=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=C2=B7=EB=8C=80=EC=8B=A0=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=C2=B7=EC=9D=BC=EC=8B=9C=20=EC=A0=91=EA=B7=BC=20(=C2=A725~?= =?UTF-8?q?=C2=A734)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- hal/app.mjs | 2 + hal/ui.mjs | 111 ++++++++++++++++++++++++++ hal/ui/public/ops.css | 27 +++++++ hal/ui/public/ops.js | 144 +++++++++++++++++++++++++++++++++ hal/ui/public/render.mjs | 1 + hal/ui/render.mjs | 167 +++++++++++++++++++++++++++++++++++++++ lang/ko/ops.json | 9 +++ portal/ops/api.mjs | 101 +++++++++++++++++++++++ portal/ops/model.mjs | 17 ++++ test/hal-ui.test.mjs | 98 +++++++++++++++++++++++ 10 files changed, 677 insertions(+) create mode 100644 hal/ui.mjs create mode 100644 hal/ui/public/ops.css create mode 100644 hal/ui/public/ops.js create mode 120000 hal/ui/public/render.mjs create mode 100644 hal/ui/render.mjs create mode 100644 lang/ko/ops.json create mode 100644 portal/ops/api.mjs create mode 100644 portal/ops/model.mjs create mode 100644 test/hal-ui.test.mjs diff --git a/hal/app.mjs b/hal/app.mjs index a1b5aa3..bf185e1 100644 --- a/hal/app.mjs +++ b/hal/app.mjs @@ -25,6 +25,7 @@ import * as ops from './ops.mjs'; import * as emergency from './emergency.mjs'; import * as dr from './dr.mjs'; import { MODULES, DEPLOY_ORDER } from './modules/index.mjs'; +import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34) const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; }; @@ -162,6 +163,7 @@ export function buildApp(d) { app.post('/ops/api/dr/drills/:id/finish', wrap(async (ctx) => ({ ok: true, drill: dr.finishDrill(d, ctx.params.id, { by: ctx.operator.id, ...(await ctx.body()) }) }))); // 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다 app.route('*', '/ops/api/portal/*', wrap(async (ctx) => { const secret = d.vault.get('service:hal'); if (!secret) throw new HttpError(503, 'portal_not_linked', '포털 연동 키가 없습니다'); if (!['GET', 'HEAD'].includes(ctx.method)) requireRecent(ctx); const rest = ctx.path.replace(/^\/ops\/api\/portal/, '') + (ctx.url.search || ''); const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : { ...(await ctx.body()), _operator: { id: ctx.operator.id, login: ctx.operator.login } }; const { serviceFetch } = d.serviceauth; const r = await serviceFetch(d.config.portalUrl, 'hal', secret, ctx.method, '/internal/ops' + rest, body, { timeoutMs: 20000, fetchImpl: d.fetch || fetch }).catch((e) => { throw new HttpError(503, 'portal_unreachable', '포털에 연결할 수 없습니다 — 정보 확인 지연'); }); ctx.status = r.status; return r.json || { ok: false, error: 'portal_bad_response' }; })); + registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다) app.get('/ops', (ctx) => ctx.redirect('/ops/')); app.get('/ops/', (ctx) => { const f = join(uiDir, 'index.html'); return ctx.html(existsSync(f) ? readFileSync(f, 'utf8') : `Ariweb HAL

Ariweb HAL 운영자 콘솔

화면(UI)은 아직 설치되지 않았습니다. API: /ops/api/me

`); }); return app; } diff --git a/hal/ui.mjs b/hal/ui.mjs new file mode 100644 index 0000000..a6efe80 --- /dev/null +++ b/hal/ui.mjs @@ -0,0 +1,111 @@ +/** + * 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). + */ +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 { 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', '플랫폼 복구'], ['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 recent = session.recentlyAuthed(ctx.session); 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 = []; + if (!recent) banner.push(`! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증`); + if (grant) banner.push(`
ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)
`); + if (!health.ok) banner.push(`
⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요
`); + if (emOpen) banner.push(`⚠ 긴급 복구 세션 ${emOpen}건 진행 중`); + if (health.bootstrap && !health.bootstrap.completed_at) banner.push(`
! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.hal_running_version || '확인 전')}, 첫 Agent ${health.bootstrap.first_agent_id ? '등록됨' : '미등록'}) — 포털 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'); + relayPage('/ops/mail', 'mail', '메일 서비스', 'mail'); 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.selfHealth(d).bootstrap, 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: `
${R.emergencyView({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS })}
` })); + // ---------------- 운영자 계정(§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() })); +} +/** 로그인·최초 설정 화면(고객 로그인과 분리, §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; } diff --git a/hal/ui/public/ops.css b/hal/ui/public/ops.css new file mode 100644 index 0000000..d3bdb64 --- /dev/null +++ b/hal/ui/public/ops.css @@ -0,0 +1,27 @@ +/* hal/ui/public/ops.css — 운영자 콘솔 스타일. 상태는 글자+기호로 구분(색은 보조). 760px 이하에서 메뉴가 접히고 표가 카드로 바뀐다(360px 조회·승인·중지·재시도 가능). */ +:root{--bg:#f3f5f8;--fg:#1b2430;--mut:#5b6672;--line:#d9dee6;--pri:#1f4fb0;--ok:#1a7f4b;--warn:#b26a00;--bad:#b3261e;--card:#fff;--rad:10px;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-size:14px;line-height:1.5}a{color:var(--pri)} +.skip{position:absolute;left:-999px}.skip:focus{left:8px;top:8px;background:#fff;padding:6px;z-index:99} +.top{display:flex;align-items:center;gap:12px;padding:8px 14px;background:#101826;color:#fff;position:sticky;top:0;z-index:20}.top a{color:#fff}.brand{font-weight:700;text-decoration:none}.ver{font-size:12px;opacity:.9}.menu-btn{display:none;font-size:20px;background:none;border:0;color:#fff} +.top-right{margin-left:auto;display:flex;gap:12px;align-items:center}.top-right .muted{color:#c7d0dc} +.shell{display:flex;min-height:calc(100vh - 48px)}.side{width:210px;background:#fff;border-right:1px solid var(--line);padding:10px 8px}.side .nav{display:block;padding:8px 10px;border-radius:8px;color:var(--fg);text-decoration:none}.side .nav.on{background:#e6edfb;color:var(--pri);font-weight:600} +.main{flex:1;padding:14px 18px;max-width:1400px;min-width:0}h1{font-size:20px;margin:4px 0 10px}h2{font-size:16px;margin:18px 0 8px}h3{font-size:15px;margin:14px 0 6px} +.alerts{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}.alert{display:block;padding:9px 12px;border-radius:8px;border:1px solid;text-decoration:none;color:var(--fg)}.alert.warning{background:#fff7e6;border-color:#f0c36d}.alert.critical{background:#fdecea;border-color:#f3a09a}.alert.info{background:#e8effc;border-color:#b9cdf2} +.card{background:var(--card);border:1px solid var(--line);border-radius:var(--rad);padding:14px;margin-bottom:12px;min-width:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}.card h2{margin-top:0}.comp{display:flex;flex-direction:column;gap:4px} +.kv{display:grid;grid-template-columns:max-content 1fr;gap:5px 12px;margin:8px 0}.kv dt{color:var(--mut)}.kv dd{margin:0;word-break:break-word} +.btn{display:inline-block;padding:7px 12px;border:1px solid var(--line);background:#fff;border-radius:8px;cursor:pointer;font-size:13px;color:var(--fg);text-decoration:none}.btn.primary{background:var(--pri);color:#fff;border-color:var(--pri)}.btn.danger{color:var(--bad);border-color:var(--bad)}.btn.small{padding:4px 8px;font-size:12px}.btn.on{background:#e6edfb;border-color:var(--pri)}.btn:disabled{opacity:.5;cursor:not-allowed}.actions{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0} +.badge{display:inline-block;padding:1px 8px;border-radius:12px;font-size:12px;border:1px solid var(--line);background:#fff;white-space:nowrap}.badge.ok,.badge.done{border-color:var(--ok);color:var(--ok)}.badge.failed{border-color:var(--bad);color:var(--bad)}.badge.warn,.badge.stale{border-color:var(--warn);color:var(--warn)}.badge.running,.badge.waiting,.badge.info{border-color:var(--pri);color:var(--pri)} +table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:var(--rad);overflow:hidden;margin-bottom:10px}th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;font-size:13px}th{background:#f7f9fc;font-weight:600}tr:last-child td{border-bottom:0} +.aw-state{padding:20px;text-align:center;color:var(--mut);background:#fff;border:1px dashed var(--line);border-radius:var(--rad);margin:8px 0}.aw-state.failed,.aw-state.forbidden{border-color:var(--bad);color:var(--bad)}.aw-state.stale,.aw-state.partial{border-color:var(--warn);color:var(--warn)} +.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0}input,select,textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:8px;width:100%;max-width:420px}label{display:block;font-weight:600;margin:8px 0 2px}.row label{margin:0;font-weight:400} +.steps{list-style:none;padding:0;margin:6px 0}.steps li{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line)}.steps .s{width:20px;text-align:center}.steps .done .s{color:var(--ok)}.steps .running .s{color:var(--pri)}.steps .failed .s{color:var(--bad)}.steps .waiting .s,.steps .needs_user .s{color:var(--warn)} +.flow{display:flex;flex-wrap:wrap;gap:4px;padding:0;list-style:none;counter-reset:f}.flow li{padding:3px 8px;border:1px solid var(--line);border-radius:8px;font-size:12px;color:var(--mut)}.flow li.done{border-color:var(--ok);color:var(--ok)}.flow li.now{border-color:var(--pri);color:var(--pri);font-weight:600} +.pre{background:#f7f9fc;border:1px solid var(--line);border-radius:8px;padding:8px;white-space:pre-wrap;word-break:break-word;font-size:12px;max-height:320px;overflow:auto}.muted{color:var(--mut)}.err{color:var(--bad)}.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px}.small{font-size:12px} +.chart{width:100%;height:auto;background:#fff;border:1px solid var(--line);border-radius:8px}.chart .line{stroke:var(--pri);stroke-width:1.5}.chart .axis{stroke:var(--line)}.chart .lbl{font-size:11px;fill:var(--mut)} +.toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:#101826;color:#fff;padding:10px 16px;border-radius:8px;display:none;z-index:50;max-width:92vw}.toast.show{display:block} +.modal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:40;padding:14px}.modal[hidden]{display:none}.modal .box{background:#fff;border-radius:12px;padding:18px;max-width:600px;width:100%;max-height:92vh;overflow:auto}.modal .box h2{margin-top:0}.once{background:#fff7e6;border:1px dashed var(--warn);padding:10px;border-radius:8px;word-break:break-all} +.tabs{display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:10px}.tabs a{padding:8px 12px;text-decoration:none;color:var(--fg);border-bottom:2px solid transparent}.tabs a.on{border-bottom-color:var(--pri);color:var(--pri);font-weight:600} +.pending{list-style:none;padding:0;display:flex;gap:14px;flex-wrap:wrap}.pending li{background:#fff;border:1px solid var(--line);border-radius:8px;padding:8px 12px} +body.login{display:grid;place-items:center;min-height:100vh}.login-box{background:#fff;border:1px solid var(--line);border-radius:12px;padding:24px;width:min(420px,94vw)} +@media (max-width:760px){.menu-btn{display:block}.side{position:fixed;left:-230px;top:48px;bottom:0;transition:left .2s;z-index:30;overflow:auto}.side.open{left:0}.main{padding:10px 12px}.top-right .muted{display:none} + table.r{display:block;border:0;background:none}table.r thead{display:none}table.r tr{display:block;background:#fff;border:1px solid var(--line);border-radius:var(--rad);margin-bottom:8px;padding:6px 10px}table.r td{display:flex;justify-content:space-between;gap:8px;border:0;padding:5px 0;flex-wrap:wrap}table.r td::before{content:attr(data-l);color:var(--mut);font-size:12px;min-width:70px}.kv{grid-template-columns:1fr}} diff --git a/hal/ui/public/ops.js b/hal/ui/public/ops.js new file mode 100644 index 0000000..5d542b9 --- /dev/null +++ b/hal/ui/public/ops.js @@ -0,0 +1,144 @@ +/* hal/ui/public/ops.js — 운영자 콘솔 브라우저 스크립트. + * - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 비밀번호 창 → 재시도, 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내 + * - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다 + * - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다. + */ +import * as R from './render.mjs'; +const $ = (s, el = document) => el.querySelector(s); +const csrf = () => ($('meta[name=csrf]') || {}).content || ''; +async function api(method, path, body) { + const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' }); + let j = null; try { j = await r.json(); } catch (_) {} + if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; } + return j; +} +/** 재인증이 필요하면 비밀번호를 받아 재인증한 뒤 한 번 더 시도 */ +async function call(method, path, body) { try { return await api(method, path, body); } catch (e) { if (e.code === 'reauth_required') { if (await reauth()) return api(method, path, body); } throw e; } } +const portal = (method, path, body) => call(method, '/ops/api/portal' + path, body); +let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); } +function modal(html) { const m = $('#modal'); m.innerHTML = ``; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; } +/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */ +function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) { + return new Promise((resolve) => { + const m = modal(`

${R.esc(title)}

${note ? `

${note}

` : ''}
${fields.map((f) => f.type === 'select' ? `` : f.type === 'checkbox' ? `` : f.type === 'textarea' ? `` : ``).join('') + (fields.some((f) => f.hint) ? `

${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}

` : '')}
`); + $('#askc', m.el).onclick = () => { m.close(); resolve(null); }; + $('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : fd.get(f.name); m.close(); resolve(out); }; + }); +} +/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */ +function showOnce(title, value, note = '') { const m = modal(`

${R.esc(title)}

이 값은 지금 한 번만 표시되며 다시 볼 수 없습니다. 안전한 곳에 바로 옮기세요.

${note ? `

${R.esc(note)}

` : ''}
${R.esc(value)}
`); $('#cp', m.el).onclick = () => { navigator.clipboard?.writeText(value); toast('복사했습니다'); }; $('#ok', m.el).onclick = m.close; } +/** 고위험 작업 3단계 확인(§25): 영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부 */ +function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌리기 가능 여부를 확인하세요' }) { + return new Promise((resolve) => { + let step = 1; const m = modal(''); + const render = () => { m.el.innerHTML = `

${R.esc(title)} — ${step}/3단계

${step === 1 ? `

1단계 영향 범위 확인

    ${impacts.map((i) => `
  • ${R.esc(i)}
  • `).join('')}
` : step === 2 ? `

2단계 대상 식별정보 재확인 — 아래 식별정보를 정확히 입력하세요: ${identifiers.map(R.esc).join(', ')}

` : `

3단계 최종 실행

${R.esc(revertible)}

`}
`; + $('#cancel', m.el).onclick = () => { m.close(); resolve(null); }; + $('#next', m.el).onclick = () => { if (step === 1 && !$('#c1', m.el).checked) return toast('영향 범위를 확인해야 합니다'); if (step === 2) { const got = $('#c2', m.el).value.split(',').map((s) => s.trim()).filter(Boolean).sort(); if (JSON.stringify(got) !== JSON.stringify(identifiers.slice().sort())) return toast('대상 식별정보가 다릅니다'); } if (step === 3 && !$('#c3', m.el).checked) return toast('최종 실행을 확인해야 합니다'); if (step < 3) { step++; render(); } else { m.close(); resolve({ confirmImpact: true, confirmIdentifiers: identifiers, confirmExecute: true }); } }; }; + render(); + }); +} +async function reauth() { const v = await ask('재인증(최근 10분)', [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: '재인증' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } } +const reload = () => location.reload(); +async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message || String(e)); } } +// ---------------- 페이지별 데이터 로드(포털 중계 포함) +const body = () => $('#body'); +async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } } +const PAGES = { + overview: async () => { try { const o = await api('GET', '/ops/api/overview'); body().innerHTML = R.overviewView(o); } catch (e) { body().innerHTML = R.relayError(e); return; } + const el = $('#pending-approvals'); if (!el) return; const partial = []; const count = async (p, pick) => { try { const r = await relay(p); return pick(r); } catch (e) { partial.push(p); return 0; } }; + const [orders, restores, relocations, transfers] = await Promise.all([count('/commerce/orders?status=awaiting_approval', (r) => (r.orders || []).length), count('/backup/restores', (r) => (r.restores || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/relocation', (r) => (r.relocations || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/transfer', (r) => (r.transfers || []).filter((x) => ['reviewing', 'accepted'].includes(x.status)).length)]); + el.innerHTML = partial.length === 4 ? R.relayError({ code: 'portal_unreachable' }) : R.pendingApprovals({ orders, restores, relocations, transfers, partial: partial.length ? partial : null }); }, + customers: async () => { const q = new URLSearchParams(location.search).get('q') || ''; try { body().innerHTML = R.customersSearch(await relay('/customers?q=' + encodeURIComponent(q))); } catch (e) { body().innerHTML = R.relayError(e); } }, + customer: async (id) => { try { body().innerHTML = R.customerView(await relay('/customers/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } }, + orders: async () => { try { body().innerHTML = R.ordersView(await relay('/commerce/orders')); } catch (e) { body().innerHTML = R.relayError(e); } }, + products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } }, + sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } }, + site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } }, + mail: async () => { try { body().innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { body().innerHTML = R.relayError(e); } }, + domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } }, + backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } }, + relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } }, + servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); b.innerHTML = (a ? a.outerHTML : '') + R.serversTable(r.servers) + '

마지막 갱신 ' + R.fmt(new Date().toISOString()) + '

'; } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } }, + server: async (id) => { const draw = async () => { const key = $('#metric-key')?.value || 'cpu'; const days = Number($('#metric-days')?.value || 30); try { const r = await api('GET', `/ops/api/servers/${id}/metrics?since=${encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString())}&limit=5000`); $('#metrics').innerHTML = R.metricsSvg(r.samples, key); } catch (e) { $('#metrics').innerHTML = R.relayError(e); } }; $('#metric-key')?.addEventListener('change', draw); $('#metric-days')?.addEventListener('change', draw); await draw(); }, + job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb) jb.outerHTML = R.jobBlock(j); } } catch (_) {} }, + recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); }, + login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; }, + setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; }, +}; +// ---------------- 동작(data-act) +const ACT = { + 'toggle-nav': () => $('#side')?.classList.toggle('open'), logout: async () => { await api('POST', '/ops/api/logout'); location.href = '/ops/login'; }, reauth: () => reauth(), + job: async (b) => { await call('POST', `/ops/api/jobs/${b.id}/${b.op}`); toast('요청했습니다'); reload(); }, + 'release-approve': async (b) => { if (!(await ask('릴리스 승인', [{ name: 'ok', label: `릴리스 ${b.v} 를 승인합니다(검사 실패 릴리스는 거부됨)`, type: 'checkbox' }]))) return; await call('POST', `/ops/api/releases/${b.v}/approve`); toast('승인했습니다'); reload(); }, + 'module-deploy': async (b) => { const sv = await api('GET', '/ops/api/servers'); const v = await ask(`${b.m} ${b.v} 배포`, [{ name: 'serverIds', label: '대상 서버 id(쉼표, 비우면 해당 역할 전체)', hint: sv.servers.map((s) => `${s.id}=${s.name}(${s.role},${s.status})`).join(' ') }], { note: 'HAL 이 먼저 배포되어 있어야 하며(§32.2), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다.' }); if (!v) return; const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds ? v.serverIds.split(',').map((s) => s.trim()).filter(Boolean) : [], nonce: Date.now() }); location.href = '/ops/jobs/' + r.job.id; }, + 'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; }, + 'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; }, + 'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; }, + 'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; }, + 'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); }, + 'server-approve': async (b) => { await call('POST', `/ops/api/servers/${b.id}/approve`); toast('승인했습니다 — 배치 가능'); reload(); }, + 'server-maint-start': async (b) => { const v = await ask('유지보수 시작', [{ name: 'note', label: '유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)', required: true }], { note: '신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).' }); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { note: v.note }); reload(); }, + 'server-maint-end': async (b) => { const v = await ask('유지보수 종료', [{ name: 'acceptsNew', label: '신규 배치 재개', type: 'checkbox', value: true }]); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { end: true, acceptsNew: v.acceptsNew }); reload(); }, + 'server-accepts': async (b) => { await call('PATCH', `/ops/api/servers/${b.id}`, { accepts_new: Number(b.v) }); reload(); }, + 'server-isolate': async (b) => { const v = await ask('서버 격리', [{ name: 'reason', label: '격리 이유', required: true }], { danger: true }); if (!v) return; await call('PATCH', `/ops/api/servers/${b.id}`, { status: 'isolated', isolated_reason: v.reason }); reload(); }, + 'server-delete': async (b) => { const c = await threeStep({ title: '서버 삭제', impacts: [`서버 ${b.id} 와 그 Agent 를 해지합니다`, `배치된 서비스 ${b.hosted}개가 있으면 먼저 이전해야 합니다`, '지표·작업 기록은 남습니다'], identifiers: [b.id], revertible: '삭제 후 복구할 수 없습니다(다시 등록해야 함)' }); if (!c) return; await call('POST', `/ops/api/servers/${b.id}/delete`, c); location.href = '/ops/servers'; }, + 'incident-new': async () => { const v = await ask('사건 등록', [{ name: 'key', label: '사건 키(같은 원인은 같은 키로 묶임)', required: true }, { name: 'title', label: '제목', required: true }, { name: 'severity', label: '심각도', type: 'select', options: ['info', 'warning', 'critical'] }, { name: 'component', label: '구성요소' }, { name: 'customerImpact', label: '고객 영향(없으면 비움 — 내부 경고는 공개하지 않음)' }, { name: 'public', label: '공개 상태 페이지에 표시', type: 'checkbox' }, { name: 'publicNote', label: '고객 공지 문구' }]); if (!v) return; await call('POST', '/ops/api/incidents', v); reload(); }, + 'incident-note': async (b) => { const v = await ask('대응 메모', [{ name: 'note', label: '메모', type: 'textarea', required: true }, { name: 'assignee', label: '담당자' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); }, + 'incident-public': async (b) => { const v = await ask('고객 공지', [{ name: 'publicNote', label: '공개 문구(개인정보·내부 위치 없이)', type: 'textarea', required: true }, { name: 'public', label: '공개', type: 'checkbox', value: true }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); }, + 'incident-resolve': async (b) => { const v = await ask('사건 종료', [{ name: 'verifiedUserPath', label: '실제 사용자 경로(외부 접속·메일 왕복 등)로 복구를 확인했습니다', type: 'checkbox' }, { name: 'note', label: '복구 확인 내용', type: 'textarea' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, { ...v, resolve: true }); reload(); }, + 'em-declare': async () => { const v = await ask('긴급 복구 선언', [{ name: 'incidentKey', label: '사건 번호(키)', required: true }, { name: 'reason', label: '복구 이유', type: 'textarea', required: true }, { name: 'scope', label: '허용 대상(쉼표: hal,hal_data,vault,cert_base,artifact_store,first_agent)', required: true }], { danger: true, note: 'HAL 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. HAL 이 정상이면 거부됩니다.' }); if (!v) return; await call('POST', '/ops/api/emergency', { ...v, scope: v.scope.split(',').map((s) => s.trim()).filter(Boolean) }); reload(); }, + 'em-approve': async (b) => { const step = Number(b.step); const fields = step === 1 ? [{ name: 'impactText', label: '1단계 — 영향 범위를 적으세요', type: 'textarea', required: true }] : step === 2 ? [{ name: 'identifiers', label: '2단계 — 변경되지 않는 대상 식별정보(쉼표)', required: true }] : [{ name: 'finalConfirm', label: '3단계 — 최종 실행을 확인합니다', type: 'checkbox' }, { name: 'revertible', label: '되돌리기 가능', type: 'checkbox' }]; const v = await ask(`승인 ${step}단계`, fields, { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/approve`, { step, ...v, identifiers: v.identifiers ? v.identifiers.split(',').map((s) => s.trim()) : undefined }); reload(); }, + 'em-credential': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/credential`); showOnce('긴급 자격(짧은 유효 시간)', r.credential, `만료 ${R.fmt(r.expiresAt)} · 정상화 후 폐기됩니다`); setTimeout(reload, 500); }, + 'em-perform': async (b) => { const v = await ask('긴급 작업 수행', [{ name: 'credential', label: '긴급 자격', type: 'password', required: true }, { name: 'action', label: '작업', type: 'select', options: ['hal.restart', 'hal.restore_snapshot', 'vault.verify', 'cert.reissue_internal', 'artifact.restore', 'agent.reconnect'] }, { name: 'target', label: '대상(선택)' }], { danger: true, note: '허용 범위 밖 작업·만료 자격·일반 배포는 거부됩니다.' }); if (!v) return; try { const r = await call('POST', `/ops/api/emergency/${b.id}/perform`, v); toast('수행 결과: ' + JSON.stringify(r.result).slice(0, 200)); } catch (e) { toast('거부: ' + e.message); } reload(); }, + 'em-verify': async (b) => { const v = await ask('실제 결과 확인·자격 폐기', [{ name: 'note', label: '실제 확인 내용(운영자 로그인·HAL 제어·HTTPS·고객 서비스 외부 확인)', type: 'textarea', required: true }]); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/verify`, { verify: { note: v.note } }); reload(); }, + 'em-reconcile': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/reconcile`); toast(`차이 ${(r.result.drift || []).length}건`); reload(); }, + 'em-close': async (b) => { const v = await ask('사건 종료', [{ name: 'note', label: '종료 메모', type: 'textarea' }], { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/close`, v); reload(); }, + 'dr-config': async () => { const v = await ask('재해복구 설정', [{ name: 'rto_minutes', label: '복구 목표 시간 RTO(분)', type: 'number', required: true }, { name: 'rpo_minutes', label: '허용 데이터 시점 RPO(분)', type: 'number', required: true }, { name: 'snapshot_interval_minutes', label: '복구 사본 생성 주기(분)', type: 'number', required: true }, { name: 'retention_count', label: '보존 개수', type: 'number', required: true }, { name: 'external_store', label: '외부 보관 위치' }]); if (!v) return; await call('PUT', '/ops/api/dr/config', Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === 'external_store' ? x : Number(x)]))); reload(); }, + 'dr-snapshot': async () => { await call('POST', '/ops/api/dr/snapshots'); toast('복구 사본을 만들었습니다'); reload(); }, + 'dr-drill': async (b) => { await call('POST', '/ops/api/dr/drills', { snapshotId: b.id || null }); toast('훈련을 시작했습니다 — 실제 복구 뒤 결과를 입력하세요'); reload(); }, + 'dr-drill-finish': async (b) => { const v = await ask('훈련 결과', [{ name: 'result', label: '결과', type: 'select', options: ['success', 'failed'] }, { name: 'measuredRtoMinutes', label: '측정 RTO(분)', type: 'number', required: true }, { name: 'measuredRpoMinutes', label: '측정 RPO(분)', type: 'number', required: true }, { name: 'findings', label: '실패·조치 항목(줄바꿈)', type: 'textarea' }, { name: 'steps', label: '수행 단계(쉼표)', value: '운영자 로그인,HAL 제어,HTTPS,고객 서비스 외부 확인' }]); if (!v) return; await call('POST', `/ops/api/dr/drills/${b.id}/finish`, { result: v.result, measuredRtoMinutes: Number(v.measuredRtoMinutes), measuredRpoMinutes: Number(v.measuredRpoMinutes), findings: v.findings.split('\n').filter(Boolean), steps: v.steps.split(',').map((s) => ({ id: s.trim(), ok: v.result === 'success' })) }); reload(); }, + 'dr-restore': async (b) => { const v = await ask('복구 요청', [{ name: 'note', label: '이유', type: 'textarea', required: true }], { danger: true, note: '복구 시작에는 별도 승인과 기존 제어 위치 중지 확인이 필요합니다. 복구 후 HAL 하나만 운영 상태여야 합니다(§34).' }); if (!v) return; await call('POST', '/ops/api/dr/restores', { snapshotId: b.id, note: v.note }); reload(); }, + 'dr-approve': async (b) => { await call('POST', `/ops/api/dr/restores/${b.id}/approve`); reload(); }, + 'dr-start': async (b) => { const v = await ask('복구 시작', [{ name: 'oldControlStopped', label: '기존 제어 위치(이전 HAL)를 중지했음을 확인합니다', type: 'checkbox' }, { name: 'confirmText', label: '확인 문구 입력: 복구 시작', required: true }], { danger: true }); if (!v) return; const r = await call('POST', `/ops/api/dr/restores/${b.id}/start`, v); toast(r.message || '복구를 시작했습니다'); reload(); }, + 'dr-finish': async (b) => { const v = await ask('복구 완료 — 실제 확인 입력', [{ name: 'login', label: '실제 운영자 로그인 확인', type: 'checkbox' }, { name: 'control', label: 'HAL 제어 확인', type: 'checkbox' }, { name: 'https', label: 'HTTPS 확인', type: 'checkbox' }, { name: 'customer', label: '고객 서비스 한 개 이상 외부 확인', type: 'checkbox' }]); if (!v) return; await call('POST', `/ops/api/dr/restores/${b.id}/finish`, { checks: v }); reload(); }, + password: async () => { const v = await ask('비밀번호 변경', [{ name: 'current', label: '현재 비밀번호', type: 'password', required: true }, { name: 'next', label: '새 비밀번호(10자 이상)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/password', v); toast('바꿨습니다(다른 세션 종료)'); reload(); }, + 'mfa-begin': async () => { const r = await call('POST', '/ops/api/mfa/begin'); const v = await ask('MFA 등록', [{ name: 'code', label: `인증 앱에 추가(otpauth): ${r.otpauth} — 수동 키: ${r.secret}. 앱의 6자리 코드를 입력하세요`, required: true }]); if (!v) return; const c = await call('POST', '/ops/api/mfa/confirm', { code: v.code }); showOnce('복구 코드(1회만 표시)', c.recoveryCodes.join('\n'), '각 코드는 한 번만 쓸 수 있습니다.'); setTimeout(reload, 300); }, + 'mfa-disable': async () => { const v = await ask('MFA 해제', [{ name: 'code', label: '인증 코드', required: true }], { danger: true }); if (!v) return; await call('POST', '/ops/api/mfa/disable', v); reload(); }, + 'session-revoke': async (b) => { await call('POST', '/ops/api/sessions/revoke', { id: b.id }); reload(); }, 'session-revoke-all': async () => { await call('POST', '/ops/api/sessions/revoke', { all: true }); reload(); }, + 'grant-issue': async () => { const v = await ask('접근권 발급', [{ name: 'accountId', label: '운영자 id(비우면 나)' }, { name: 'hours', label: '유효 시간(1~72시간)', type: 'number', value: 8 }, { name: 'reason', label: '사유', required: true }]); if (!v) return; const r = await call('POST', '/ops/api/access-grants', { accountId: v.accountId || undefined, hours: Number(v.hours), reason: v.reason }); showOnce('접근권 URL(1회)', r.grant.url, `허용 네트워크 밖 기기에서 이 URL 을 열면 그 기기에 묶입니다. 만료 ${R.fmt(r.grant.expiresAt)}`); }, + 'grant-revoke': async (b) => { await call('POST', `/ops/api/access-grants/${b.id}/revoke`); reload(); }, + 'account-new': async () => { const v = await ask('운영자 추가', [{ name: 'login', label: '아이디', required: true }, { name: 'displayName', label: '표시 이름' }, { name: 'password', label: '초기 비밀번호(첫 로그인 뒤 변경 강제)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/accounts', v); reload(); }, + // ---- 포털 중계 동작(§26.2·§27·§30) + 'cust-change': async (b) => { const v = await ask('고객 대신 변경', [{ name: 'field', label: '항목', type: 'select', options: ['group.name', 'group.contact_email', 'group.contact_phone', 'site.name', 'site.maintenance', 'site.search_visible'] }, { name: 'subjectId', label: '사이트 id(사이트 항목일 때)' }, { name: 'value', label: '변경 후 값(참/거짓 항목은 1/0)', required: true }, { name: 'reason', label: '사유', required: true }, { name: 'approver', label: '승인자' }]); if (!v) return; const r = await portal('POST', `/customers/${b.g}/change`, { ...v, value: /\.(maintenance|search_visible)$/.test(v.field) ? v.value === '1' : v.value }); toast(`변경: ${JSON.stringify(r.change.before)} → ${JSON.stringify(r.change.after)}`); reload(); }, + 'support-issue': async (b) => { const v = await ask('고객 지원용 일시 접근', [{ name: 'targetType', label: '대상 종류', type: 'select', options: ['group', 'site', 'mail_service'] }, { name: 'targetId', label: '대상 id' }, { name: 'reason', label: '이유', required: true }, { name: 'hours', label: '만료(시간, 최대 24)', type: 'number', value: 4 }, { name: 'scope', label: '허용 범위(쉼표: view,change)', value: 'view' }, { name: 'approver', label: '승인자' }]); if (!v) return; await portal('POST', '/support-access', { ...v, groupId: b.g, hours: Number(v.hours), scope: v.scope.split(',').map((s) => s.trim()) }); reload(); }, + 'support-revoke': async (b) => { await portal('POST', `/support-access/${b.id}/revoke`, {}); reload(); }, + 'order-paid': async (b) => { const v = await ask('입금 확인', [{ name: 'ref', label: '입금 확인 참조(입금자·시각 등)', required: true }]); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/paid`, v); reload(); }, + 'order-approve': async (b) => { const o = await portal('GET', `/commerce/orders/${b.id}`); const od = o.order || {}; const v = await ask('주문 승인', [{ name: 'ok', label: `대상 ${od.group?.name || od.group_id} · 금액 ${od.total} ${od.currency} · 권리 변화 ${(od.entitlementDelta || []).map((e) => e.resource + '+' + e.amount).join(', ')} — 확인했습니다`, type: 'checkbox' }]); if (!v || !v.ok) return; const r = await portal('POST', `/commerce/orders/${b.id}/approve`, {}); toast(r.already ? '이미 승인된 주문입니다(권리 중복 생성 없음)' : `승인 — 권리 ${r.granted}건 활성화`); reload(); }, + 'order-reject': async (b) => { const v = await ask('주문 거절', [{ name: 'reason', label: '사유(고객에게 표시)', required: true }], { danger: true }); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/reject`, v); reload(); }, + 'order-refund': async (b) => { const v = await ask('환불·크레딧', [{ name: 'kind', label: '종류', type: 'select', options: ['refund', 'credit', 'adjust'] }, { name: 'amount', label: '금액', type: 'number', required: true }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', '/commerce/credits', { groupId: b.g, orderId: b.id, kind: v.kind, amount: Number(v.amount), reason: v.reason }); reload(); }, + 'product-new-version': async (b) => { const v = await ask('새 상품 버전', [{ name: 'patch', label: '변경 내용(JSON, 예 {"price": 12000})', type: 'textarea', required: true }], { note: '판매된 버전은 바꾸지 않고 새 버전을 만듭니다. 기존 고객은 자기 버전을 유지합니다.' }); if (!v) return; let patch; try { patch = JSON.parse(v.patch); } catch (_) { return toast('JSON 형식이 아닙니다'); } await portal('POST', '/commerce/products', { productId: b.p, baseVersionId: b.id, patch }); reload(); }, + 'product-publish': async (b) => { await portal('POST', `/commerce/products/${b.id}/publish`, { published: b.pub === '1' }); reload(); }, + 'catalog-seed': async () => { await portal('POST', '/commerce/catalog/seed', {}); reload(); }, + 'site-refresh': async (b) => { await portal('POST', `/sites/${b.id}/refresh`, {}); reload(); }, + 'mail-rates': async (b) => { const v = await ask('발송 제한·제출량 조회', [{ name: 'domain', label: '도메인(선택)' }, { name: 'reason', label: '접근 사유(기록됨)', required: true }]); if (!v) return; const r = await portal('GET', `/mail/servers/${b.id}/rates?domain=${encodeURIComponent(v.domain || '')}`); modal(`

발송 제한·제출량

${R.esc(JSON.stringify(r, null, 1))}
`); }, + 'mail-test': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/test`, {}); toast('외부 송수신 시험을 시작했습니다' + (r.job ? ' — 작업 ' + r.job.id : '')); }, + 'mail-security': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/security`, {}); modal(`

DNS 보안 확인

${R.esc(JSON.stringify(r, null, 1))}
`); }, + 'mail-operating': async (b) => { const v = await ask('운영 상태(이전 중 두 서버 동시 처리 방지)', [{ name: 'operating', label: '상태', type: 'select', options: ['active', 'migrating_out', 'migrating_in', 'frozen'] }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', `/mail/services/${b.id}/operating`, v); reload(); }, + 'mail-cancel': async (b) => { const v = await ask('메일 서비스 정지·해지', [{ name: 'reason', label: '사유', required: true }, { name: 'ok', label: '송수신이 즉시 중지되고 30일 읽기·내보내기 뒤 삭제됩니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/mail/services/${b.id}/cancel`, { reason: v.reason }); reload(); }, + 'domains-sweep': async () => { await portal('POST', '/domains/sweep', {}); await portal('POST', '/source/sweep', {}).catch(() => {}); reload(); }, + 'backup-retry': async (b) => { await portal('POST', `/backup/options/${b.id}/retry`, {}); reload(); }, 'backup-override': async (b) => { const v = await ask('같은 서버 저장 허용(예외)', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/options/${b.id}/override-same-server`, v); reload(); }, + 'restore-approve': async (b) => { const d = await portal('GET', `/backup/restores/${b.id}`); const r = d.restore || d; const fc = d.finalConfirmation || {}; const v = await ask('복원 승인 — 최종 확인', [{ name: 'ok', label: `고객 그룹 ${fc.groupName || r.group_name} · 사이트 ${fc.siteName || r.site_name} · 도메인 ${fc.primaryDomain || r.primary_domain || '—'} · 백업 시각 ${R.fmt(fc.backupAt || r.backup_created_at)} — 위 내용을 최종 확인합니다`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/backup/restores/${b.id}/approve`, { confirm: fc }); reload(); }, + 'restore-reject': async (b) => { const v = await ask('복원 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/restores/${b.id}/reject`, v); reload(); }, 'restore-retry': async (b) => { await portal('POST', `/backup/restores/${b.id}/retry`, {}); reload(); }, + 'reloc-approve': async (b) => { const d = await portal('GET', `/relocation/${b.id}`); const fc = d.finalConfirmation || { subjectId: d.relocation?.subject_id, toServerId: d.relocation?.to_server_id }; const v = await ask('이전 승인 — 최종 확인', [{ name: 'scheduledAt', label: '작업 시간(ISO, 비우면 즉시)' }, { name: 'ok', label: `대상 ${fc.subjectName || fc.subjectId} → 서버 ${fc.toServerId} · 전환 직전 조건이 달라지면 자동 중지됩니다 — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/approve`, { scheduledAt: v.scheduledAt || null, confirm: fc }); reload(); }, + 'reloc-reject': async (b) => { const v = await ask('이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/relocation/${b.id}/reject`, v); reload(); }, + 'reloc-retry': async (b) => { await portal('POST', `/relocation/${b.id}/retry`, {}); reload(); }, 'reloc-resume': async (b) => { await portal('POST', `/relocation/${b.id}/resume`, {}); reload(); }, 'reloc-revert': async (b) => { const v = await ask('이전 되돌리기', [{ name: 'ok', label: '새 위치를 운영 위치로 확정하지 않고 이전 위치로 돌립니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/revert`, {}); reload(); }, + 'reloc-new': async () => { const v = await ask('플랫폼 사유 이전', [{ name: 'subjectType', label: '대상 종류', type: 'select', options: ['site', 'mail'] }, { name: 'subjectId', label: '대상 id', required: true }, { name: 'toServerId', label: '도착 서버 id(비우면 자동 선택)' }, { name: 'reason', label: '이유(장애 회피·용량 조정 — 고객 과금 없음)', required: true }]); if (!v) return; await portal('POST', '/relocation', v); reload(); }, + 'transfer-approve': async (b) => { const d = await portal('GET', `/transfer/${b.id}`); const fc = d.finalConfirmation || {}; const v = await ask('소유권 이전 승인', [{ name: 'proof', label: '소유 증빙 확인 내용', required: true }, { name: 'domains', label: '도메인 확인 결과', required: true }, { name: 'ok', label: `${fc.fromGroupId} → ${fc.toGroupId} 전체가 한 번에 이전됩니다(일부만 이전되지 않음) — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/transfer/${b.id}/approve`, { review: { proof: v.proof, domains: v.domains }, confirm: fc }); reload(); }, + 'transfer-reject': async (b) => { const v = await ask('소유권 이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/transfer/${b.id}/reject`, v); reload(); }, + 'transfer-resolve': async (b) => { const v = await ask('분쟁 해결', [{ name: 'resolution', label: '결정', type: 'select', options: ['continue', 'cancel'] }, { name: 'note', label: '근거', required: true }]); if (!v) return; await portal('POST', `/transfer/${b.id}/resolve-dispute`, v); reload(); }, +}; +document.addEventListener('click', (e) => { const el = e.target.closest('[data-act]'); if (!el) return; e.preventDefault(); const fn = ACT[el.dataset.act]; if (fn) guarded(() => fn(el.dataset)); }); +document.addEventListener('submit', (e) => { const f = e.target.closest('[data-form=customer-search]'); if (f) { e.preventDefault(); location.href = '/ops/customers?q=' + encodeURIComponent(new FormData(f).get('q') || ''); } }); +// ---------------- 부팅 +const main = document.querySelector('[data-page]'); const page = main?.dataset.page; const id = main?.dataset.id; +if (page && PAGES[page]) { guarded(() => PAGES[page](id)); if (['overview', 'jobs', 'job', 'servers'].includes(page)) setInterval(() => { if (!document.hidden) guarded(() => PAGES[page](id)); }, 15000); } diff --git a/hal/ui/public/render.mjs b/hal/ui/public/render.mjs new file mode 120000 index 0000000..3b640be --- /dev/null +++ b/hal/ui/public/render.mjs @@ -0,0 +1 @@ +../render.mjs \ No newline at end of file diff --git a/hal/ui/render.mjs b/hal/ui/render.mjs new file mode 100644 index 0000000..ef917f3 --- /dev/null +++ b/hal/ui/render.mjs @@ -0,0 +1,167 @@ +/** + * 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 ` ${esc(label)}`; } +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 `
${esc(L[kind] || kind)}${text ? `

${esc(text)}

` : ''}${href ? `${esc(label || '이동')}` : ''}
`; } +/** 중계(포털) 호출 실패 → 정보 지연. 포털 데이터로 정상 판정하지 않는다. */ +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 `${cols.map((c) => ``).join('')}${rows.map((r) => `${cols.map((c) => ``).join('')}`).join('')}
${esc(c.l)}
${c.f ? c.f(r) : esc(r[c.k])}
`; } +export function kv(pairs) { return `
${pairs.filter((p) => p).map(([k, v]) => `
${esc(k)}
${v == null || v === '' ? '—' : v}
`).join('')}
`; } +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 `
${jobBadge(j.status)} ${esc(j.label || j.kind)} ${esc(j.id)}${j.stale ? ' ' + badge('stale', '정보 확인 지연') : ''}
+ ${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 ? ` — ${esc(j.currentStep.detail)}` : '') : '—'], ['진행률', `${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 ? ['오류 요약', `${esc(j.error)} (${esc(j.errorCode || '')})`] : null])} +
    ${(j.steps || []).map((s) => `
  • ${esc(s.title)}${s.detail ? ` — ${esc(s.detail)}` : ''}${s.error ? ` — ${esc(s.error)}` : ''}
  • `).join('')}
+ ${actions ? `
${j.canRetry ? `` : ''}${j.canResume ? `` : ''}${j.canCancel ? `` : ''}${['done', 'failed', 'needs_user'].includes(j.status) ? `` : ''}${['queued', 'awaiting_external'].includes(j.status) ? `` : ''}
` : ''}
`; +} +/** 자식(대상별) 결과: 일부 실패는 '부분 실패' */ +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 `

대상별 결과 ${overall}

${table([{ l: '대상', f: (k) => `${esc(k.label || k.id)}
${esc((k.subject && (k.subject.serverId || k.subject.target)) || '')}` }, { l: '상태', f: (k) => jobBadge(k.status) }, { l: '시도', f: (k) => `${k.attempt || 0}${k.next_run_at ? ' · 다음 ' + fmt(k.next_run_at) : ''}` }, { l: '오류', f: (k) => `${esc(k.error || '')}` }], c.children)}`; } +export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r) => `${esc(r.id)}
${esc(r.kind)}` }, { l: 'Agent', f: (r) => `${esc(r.agent_id || '')}` }, { l: '상태', f: (r) => jobBadge(r.status) }, { l: '진행', f: (r) => `${r.progress || 0}%` }, { l: '오류·조치', f: (r) => `${esc(r.error || '')} ${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) => `${esc(s.name)}
${esc(s.role)} · ${esc(s.location || '위치 미입력')}` }, + { l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}
마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}` }, + { 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 ? `
고갈 예상 ${fmt(s.capacity.exhaustEta)}` : ''}` : '정보 없음' }, + { 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' ? `
${esc(s.maintenanceNote || '유지보수')}` : s.rawStatus === 'isolated' ? `
격리: ${esc(s.isolatedReason || '')}` : '') }, + { l: '시계·인증서', f: (s) => `${s.clockSkewMs == null ? '—' : Math.abs(s.clockSkewMs) > 60000 ? `${s.clockSkewMs}ms` : `${s.clockSkewMs}ms`}
${s.facts && s.facts.certExpiresAt ? '인증서 ' + fmt(s.facts.certExpiresAt) : '인증서 정보 없음'}` }, + { l: '모듈 버전', f: (s) => Object.entries(s.modules || {}).map(([m, v]) => `${esc(m)} ${v.drift ? `${esc(v.actual || '없음')}→${esc(v.target)}` : esc(v.actual || v.target || '—')} ${healthBadge(v.health)}`).join('
') || '배치 없음' }, + ], servers, { empty: '등록된 서버가 없습니다' }); +} +export function serverDetail(s, { jobs = [], agentJobs = [] } = {}) { + return `

연결·역할

${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', `${esc(s.agentId || '미연결')} ${esc(s.agentVersion || '')}`], ['시계 차이', s.clockSkewMs == null ? '—' : `${s.clockSkewMs}ms`], ['승인', s.approvedAt ? fmt(s.approvedAt) : badge('waiting', '승인 대기')], ['신규 배치', s.acceptsNew ? '허용' : '중지'], ['유지보수·격리', esc(s.maintenanceNote || s.isolatedReason || '—')]])}
+

용량

${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)}` : '해당 없음']])}
+

모듈(목표 vs 실제)

${table([{ l: '모듈', k: 'name' }, { l: '목표', k: 'target' }, { l: '실제', f: (m) => m.drift ? `${esc(m.actual || '없음')}` : esc(m.actual || '—') }, { l: '건강', f: (m) => healthBadge(m.health) }, { l: '마지막 확인', f: (m) => fmt(m.lastCheckAt) }, { l: '오류', f: (m) => `${esc(m.lastError || '')}` }], Object.entries(s.modules || {}).map(([name, m]) => ({ name, ...m })), { empty: '배치된 모듈이 없습니다' })}
+

대기열·반복 실패

${agentJobsTable(agentJobs)}

이 서버의 작업

${table([{ l: '작업', f: (j) => `${esc(j.label)}` }, { 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 `${esc(label)}${key === 'net' ? bytes(max) : '100%'}${fmt(rows[0].at)}${fmt(rows[rows.length - 1].at)}`; +} +/** 릴리스 화면(§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}` : '배치 없음'}`; }).join(' · '); + return `

배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2). HAL 실행 버전: ${esc(running.hal_running_version || '확인 전')}${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · 부트스트랩 미완료'}

+ ${table([ + { l: '버전', f: (r) => `${esc(r.version)}${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 중') : ''}` }, { l: 'main 커밋', f: (r) => `${esc(String(r.main_commit).slice(0, 12))}` }, { 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 || ''}`) + `
${fmt(r.approved_at)}` : badge('waiting', '승인 대기') + ` ` }, + { 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] ? `
보기
${esc(changelog[r.version])}
` : 'VERSION_HISTORY 항목 없음' }, + { l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).map((m) => ``).join(' ') : '승인 후' }, + ], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })} +

모듈 배치 상태(대상별)

${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `${esc(t.server_id)}` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `${esc(t.actual_version || '없음')}` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `${esc(t.last_error || '')}` }, { l: '', f: (t) => ` ` }], targets, { empty: '배치된 모듈이 없습니다' })} +
`; +} +export function incidentsView(incidents, notifications = []) { + return `

사건(같은 원인은 하나로 묶임)

${table([{ l: '사건', f: (i) => `${esc(i.title)}
${esc(i.key)}` }, { 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)}
${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `
${esc(i.public_note || '')}` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : ` ` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })} +
+

운영자 알림

${table([{ l: '분류', k: 'category' }, { l: '내용', f: (n) => `${esc(n.title)}${n.count > 1 ? ` ×${n.count}` : ''}
${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) => `${esc(a.action)}${a.immutable ? ' (삭제 불가)' : ''}` }, { l: '대상', f: (a) => `${esc(a.subject_type || '')} ${esc(a.subject_id || '')}` }, { l: '결과', f: (a) => a.outcome === 'ok' ? badge('ok', '성공') : badge('failed', a.outcome) }, { l: '상세', f: (a) => a.detail ? `
보기
${esc(JSON.stringify(a.detail, null, 1))}
` : '' }], 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 `
${esc(s.id)} ${badge(s.status === 'closed' ? 'done' : 'warn', L[s.status] || s.status)} 사건 ${esc(s.incident_key)} · 선언 ${fmt(s.declared_at)} (${esc(s.declared_by)})
+
    ${steps.map((st, i) => `
  1. ${esc(L[st])}
  2. `).join('')}
+ ${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('
') : '없음 (3단계 모두 필요)'], ['자격', s.credential_issued_at ? `발급 ${fmt(s.credential_issued_at)} · 만료 ${fmt(s.credential_expires_at)} · ${s.credential_revoked_at ? '폐기 ' + fmt(s.credential_revoked_at) : '유효'}` : '미발급(한 번만 표시되며 다시 볼 수 없음)'], ['수행 기록', (s.actions || []).length ? (s.actions || []).map((a) => `${fmt(a.at)} ${esc(a.action)} ${esc(a.target || '')} → ${a.ok ? '성공' : `거부/실패: ${esc(a.error || '')}`}`).join('
') : '없음'], ['결과 확인', s.verify ? `HAL 건강 ${s.verify.halHealth ? '정상' : '비정상'} ${fmt(s.verify.at)}` : '—'], ['상태 조정', s.reconcile ? `차이 ${(s.reconcile.drift || []).length}건 · 응답 없는 서버 ${(s.reconcile.offlineServers || []).length}대` : '—']])} +
${s.status === 'declared' || s.status === 'approving' ? `` : ''}${s.status === 'approved' ? `` : ''}${['credential_issued', 'performing'].includes(s.status) ? ` ` : ''}${s.status === 'verified' ? `` : ''}${s.status !== 'closed' ? `` : ''}
`; }; + return `

HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. 허용 대상: ${(data.scopes || []).map(esc).join(', ')} · 허용 작업: ${Object.keys(data.actions || {}).map(esc).join(', ')} — 일반 고객 서비스 배포는 금지(거부됨).

${(data.sessions || []).length ? data.sessions.map(sess).join('') : statePanel('empty', '긴급 복구 세션이 없습니다')}`; +} +/** 재해복구(§34) */ +export function drView(data) { + const r = data.readiness || {}; const cfg = r.config || {}; + return `

준비 상태

${r.ready ? badge('ok', r.label) : badge('warn', r.label)}${kv([['목표 복구 시간(RTO)', cfg.rto_minutes ? `${cfg.rto_minutes}분` : '미설정'], ['허용 데이터 시점(RPO)', cfg.rpo_minutes ? `${cfg.rpo_minutes}분` : '미설정'], ['복구 사본 주기·보존', cfg.snapshot_interval_minutes ? `${cfg.snapshot_interval_minutes}분 · ${cfg.retention_count}개` : '미설정'], ['외부 보관', 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 ?? '—'})` : '실제 복구 훈련 전 — 정식 운영 준비 완료로 표시하지 않음'], ['다음 훈련', r.lastDrill && r.lastDrill.nextDueAt ? fmt(r.lastDrill.nextDueAt) : '예정 없음'], ['운영 중 HAL', data.hal && data.hal.bootstrap ? `${esc(data.hal.bootstrap.live_instance_id || '')} ${badge('ok', 'HAL 하나만 운영 상태')}` : '—']])}
+

복구 사본

${table([{ l: '사본', f: (s) => `${esc(s.id)}
${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 ? ` ` : '' }], data.snapshots || [], { empty: '복구 사본이 없습니다' })} +

복구 요청(별도 승인 + 기존 제어 위치 중지 확인)

${table([{ l: '요청', f: (x) => `${esc(x.id)}
사본 ${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) : '미확인' }, { l: '확인·차이', f: (x) => x.checks_json ? `
${esc(x.checks_json)}
` : '—' }, { l: '', f: (x) => x.status === 'requested' ? `` : x.status === 'approved' ? `` : x.status === 'running' ? `` : '' }], data.restores || [], { empty: '복구 요청이 없습니다' })} +

복구 훈련

${table([{ l: '훈련', f: (x) => `${esc(x.id)}` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` ` : 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 ? `
${esc(x.findings_json)}
` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`; +} +export function accountsView(data) { + return `

내 계정

${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` ${esc(data.me.securityHint || '')}`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '재인증 필요(고위험 작업)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}
${data.me.mfaEnabled ? `` : ``}
+

로그인 세션

${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' (현재)' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `` }], data.sessions || [])}
+

접근권(허용 네트워크 밖 기기)

${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 ? '' : `` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}
+

운영자 계정

${table([{ l: '아이디', f: (a) => `${esc(a.login)} ${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 || [])}
`; +} +/** 운영 현황(§26.1) */ +export function overviewView(o) { + const comp = (c) => `
${esc(c.name)} ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}
${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}
`; + const jobs = o.jobs || {}; const srv = o.servers || {}; + return `
${(o.components || []).map(comp).join('')}
+

서버

${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `${srv.offline}대` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `${srv.diskWarning}대` : '없음']])}서버와 용량
+

진행 중 작업

${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `${jobs.needsUser}` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}작업 화면
+

목표·실제 차이

${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}
+

재해복구·긴급

${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}
${(o.emergencyOpen || []).length ? `긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중` : '긴급 복구 없음'}
플랫폼 복구
+

장애·유지보수

${(o.incidents || []).length ? table([{ l: '사건', f: (i) => `${esc(i.title)}` }, { l: '심각도', k: 'severity' }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음') }, { l: '시작', f: (i) => fmt(i.started_at) }], o.incidents) : badge('ok', '진행 중인 사건 없음')} +

처리 대기 승인(포털)

${statePanel('loading', '포털에서 승인 대기 항목을 불러오는 중')}

HAL 자체 상태 ${fmt(o.generatedAt)} 기준. 오래된 정보는 정상으로 표시하지 않습니다.

`; +} +/** 처리 대기 승인 요약(포털 중계 결과) */ +export function pendingApprovals(p) { const row = (label, n, href) => `
  • ${esc(label)} ${n}건
  • `; return `${p.partial ? statePanel('partial', `일부 항목을 받지 못했습니다: ${p.partial.join(', ')}`) : ''}`; } +// ---------------- 포털 중계 화면(§26.2·§27·§30) +export function customersSearch(d) { return `

    사용자 그룹

    ${table([{ l: '그룹', f: (g) => `${esc(g.name)} ${g.kind === 'personal' ? '개인' : '팀'}` }, { 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: '검색 결과가 없습니다' })}

    사용자

    ${table([{ l: '이메일', f: (u) => `${esc(u.email)}
    ${esc(u.displayName)}` }, { 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 `

    ${esc(g.name)} (${g.kind === 'personal' ? '개인' : '팀'})

    ${kv([['id', `${esc(g.id)}`], ['연락처', `${esc(g.contactEmail || '—')} ${esc(g.contactPhone || '')}`], ['상태', badge(g.status === 'active' ? 'ok' : 'warn', g.status)], ['조회 기준', fmt(c.generatedAt)]])}
    +

    활성 상품·권리

    ${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: '활성 상품이 없습니다' })}
    +

    구성원

    ${table([{ l: '이메일', f: (m) => `${esc(m.email)}
    ${esc(m.displayName)} · ${esc(m.alias || '')}` }, { l: '역할', k: 'role' }, { l: '상태', f: (m) => m.status === 'suspended' ? badge('warn', `정지 ${fmt(m.suspendedAt)}`) : badge('ok', m.status) }, { l: '확인·MFA', f: (m) => `${m.verified ? '이메일 확인' : '미확인'}${m.locked ? ' · 잠김' : ''}` }, { l: '마지막 로그인', f: (m) => fmt(m.lastLoginAt) }], c.members || [])} +

    사이트

    ${table([{ l: '사이트', f: (s) => `${esc(s.name)}
    ${esc(s.primary_domain || '대표 도메인 없음')}` }, { 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: '사이트가 없습니다' })} +

    메일

    ${table([{ l: '서비스', f: (m) => `${esc(m.name)}
    ${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'}` }, { 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: '메일 서비스 없음' })}
    +

    도메인

    ${table([{ l: '도메인', f: (d) => `${esc(d.name)}${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: '도메인 없음' })}
    +

    백업

    ${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: '백업 옵션 없음' })}
    +

    사용량·제한

    ${table([{ l: '자원', k: 'label' }, { l: '구매', k: 'purchased' }, { l: '배정', k: 'allocated' }, { l: '예약', k: 'reserved' }, { l: '사용', k: 'used' }, { l: '미배정', k: 'unallocated' }], c.usage || [])}${(c.limits || []).length ? `

    제한 적용: ${c.limits.map((l) => `${esc(l.resource)} ${l.level}%`).join(', ')}

    ` : ''}
    +

    주문·결제

    ${table([{ l: '주문', f: (o) => `${esc(o.order_no)} ${esc(o.kind)}` }, { 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: '주문 없음' })} +

    정지·보존

    ${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}`]])} +

    고객 화면 오류·실패 작업

    ${table([{ l: '작업', f: (j) => `${esc(j.label)}` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '오류', f: (j) => `${esc(j.error || j.userMessage || '')}` }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], c.failedJobs || [], { empty: '실패한 작업 없음' })}${auditTable(c.errors || [])} +

    일시 접근 기록

    ${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' ? `` : '' }], c.supportAccess || [], { empty: '일시 접근 기록 없음' })} +

    운영자 대신 변경 기록

    ${table([{ l: '시각', f: (x) => fmt(x.created_at) }, { l: '항목', f: (x) => `${esc(x.field)} ${esc(x.subject_id)}` }, { 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) => `${esc(o.order_no)}
    ${esc(o.group && o.group.name)} · ${esc(o.product && o.product.name)} v${esc(o.product && o.product.version)}` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.statusLabel ? `
    ${esc(o.statusLabel)}` : '') }, { l: '대상·금액', f: (o) => `${esc(o.target_type || '그룹')} ${esc(o.target_id || '')}
    ${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)} (세금 ${Number(o.tax).toLocaleString('ko-KR')})` }, { l: '권리 변화', f: (o) => (o.entitlementDelta || []).map((e) => `${esc(e.label || e.resource)} +${e.amount}${e.target ? ' → ' + esc(e.target) : ''}`).join('
    ') || '—' }, { l: '현재 사용량', f: (o) => (o.currentUsage || []).filter((u) => u.purchased || u.used).map((u) => `${esc(u.label)} ${u.used}/${u.purchased}`).join('
    ') || '—' }, { l: '기한·입금', f: (o) => `${fmt(o.due_at)}
    ${o.paid_at ? '입금 ' + fmt(o.paid_at) + ' ' + esc(o.paid_ref || '') : '입금 미확인'}` }, { l: '', f: (o) => o.status === 'awaiting_payment' ? ` ` : o.status === 'awaiting_approval' ? ` ` : o.status === 'approved' ? `` : '' }], d.orders || [], { empty: '주문이 없습니다' }); } +export function productsView(d) { return `${table([{ l: '상품', f: (p) => `${esc(p.name)} ${esc(p.product_id)} v${p.version}` }, { 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 ? `${p.deferred.map((x) => esc(x.key || x)).join(', ')}` : '없음' }, { l: '', f: (p) => ` ` }], d.products || [], { empty: '상품이 없습니다 — "검증 Catalog 넣기"로 시작' })}

    이미 판매된 조건은 바꾸지 않고 새 버전을 만듭니다(§27.2). 기존 고객은 자기 버전을 유지합니다.

    `; } +export function sitesView(d) { return table([{ l: '사이트', f: (s) => `${esc(s.name)}
    ${esc(s.primaryDomain || s.primary_domain || '—')} · 그룹 ${esc(s.groupId || '')}` }, { 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 ? `${esc(s.actionNeeded)}` : '—' }], d.sites || [], { empty: '사이트가 없습니다' }); } +export function siteDetailView(d) { const s = d.site || {}; return `

    ${esc(s.name)}

    ${kv([['그룹', `${esc(s.group_id)}`], ['상태', 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)]])}
    +

    외부 Agent

    ${d.externalAgent ? kv([['상태', esc(d.externalAgent.status)], ['마지막 확인', fmt(d.externalAgent.lastSeenAt)], ['버전', esc(d.externalAgent.version || '')]]) : '관리형 또는 미연결'}
    +

    배포 이력

    ${table([{ l: '배포', f: (x) => `${esc(x.id)}
    ${esc(x.version_label || x.version_ref || '')}` }, { l: '상태', f: (x) => jobBadge(x.status) + (x.verify_state ? ` ${esc(x.verify_state)}` : '') }, { l: '시각', f: (x) => `${fmt(x.created_at)} → ${fmt(x.ended_at)}` }], d.deployments || [], { empty: '배포 이력 없음' })} +

    도메인

    ${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: '도메인 없음' })} +

    작업

    ${(d.jobs || []).map((j) => jobBlock(j, { actions: false })).join('') || statePanel('empty', '작업 없음')}

    감사 기록

    ${auditTable(d.audit || [])}`; } +export function mailView(d) { return `

    메일 서버

    ${table([{ l: '서버', f: (s) => `${esc(s.name || s.id)}
    ${esc(s.baseUrl || s.base_url || '')}` }, { l: '연결', f: (s) => s.ok === false || s.error ? badge('failed', '연결 실패') + ` ${esc(s.error || '')}` : 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) => `` }], d.servers || [], { empty: '등록된 메일 서버가 없습니다' })} +

    메일 서비스

    ${table([{ l: '서비스', f: (m) => `${esc(m.name)}
    ${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'} · 그룹 ${esc(m.group_id)}` }, { 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('
    ') : '—' }, { l: '보존', f: (m) => m.retention_until ? `${fmt(m.retention_until)} · 삭제 ${fmt(m.delete_after)}` : '—' }, { l: '', f: (m) => ` ` }], d.services || [], { empty: '메일 서비스가 없습니다' })}

    메일 원문은 운영 화면에서 열람하지 않습니다. 진단 조회는 접근 사유를 기록합니다(§30.2).

    `; } +export function domainsView(d, src) { return `

    도메인·인증서(§30.1)

    ${table([{ l: '도메인', f: (x) => `${esc(x.name)}${x.is_primary ? ' (대표)' : ''}
    ${esc(x.purpose)} · 그룹 ${esc(x.group_id)} · 사이트 ${esc(x.site_id || '—')}` }, { 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 ? `
    만료 ${fmt(x.cert_expires_at)}` : '') }, { l: '메일 보안', f: (x) => esc(x.mail_dns_state || '—') }, { l: '마지막 외부 확인', f: (x) => fmt(x.last_checked_at) }, { l: '반복 실패', f: (x) => x.failures ? `${x.failures}회` : '—' }], (d.domains || []), { empty: '도메인이 없습니다' })}${d.duplicates && d.duplicates.length ? `

    여러 고객에게 연결된 도메인(활성화 차단): ${d.duplicates.map(esc).join(', ')}

    ` : ''}
    +

    Source/Gitea(§30.5)

    ${table([{ l: '사이트', f: (o) => `${esc(o.site_name || o.site_id)} ${esc(o.site_id)}` }, { 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) => `${esc(String(o.last_deployed_revision || '').slice(0, 10))}` }], (src && src.options) || [], { empty: 'Source 옵션이 없습니다' })}`; } +export function backupView(d) { return `

    복원 승인(§30.3)

    ${table([{ l: '요청', f: (r) => `${esc(r.id)}
    ${esc(r.site_name)} ${esc(r.primary_domain || '')}
    ${esc(r.group_name)} · 백업 ${fmt(r.backup_created_at || r.backupAt)}` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `
    ${esc(r.approval_state)}` : '') }, { 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' ? ` ` : r.status === 'failed' ? `` : '' }], d.restores || [], { empty: '복원 요청이 없습니다' })} +

    백업 옵션

    ${table([{ l: '사이트', f: (o) => `${esc(o.site_name || o.site_id)} ${esc(o.group_name || '')}` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.paused_reason ? ` ${esc(o.paused_reason)}` : '') }, { l: '최근 성공·실패', f: (o) => `${fmt(o.last_success_at)}
    ${o.consecutive_failures ? `연속 실패 ${o.consecutive_failures}` : '실패 없음'}` }, { l: '다음 예정', f: (o) => fmt(o.next_run_at) }, { l: '만료·삭제', f: (o) => `${fmt(o.ends_at)} · ${fmt(o.delete_after)}` }, { l: '', f: (o) => ` ` }], d.options || [], { empty: '활성 백업 옵션이 없습니다' })} +

    백업 목록

    ${table([{ l: '백업', f: (b) => `${esc(b.id)}
    ${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)} ${esc(b.location || '')}` }], d.backups || [], { empty: '백업이 없습니다' })}`; } +export function relocationView(d, tr) { return `

    서버 이전(§30.4)

    ${table([{ l: '대상', f: (r) => `${esc(r.subjectName || r.subject_id)} ${esc(r.subject_type)} · ${esc(r.group_name || '')}
    ${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}
    ${esc(r.reason || '')}` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `
    승인 ${esc(r.approval_state)}` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `
    ${esc(r.job.waitReason)}` : ''}` : '—' }, { 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' ? ` ` : ['failed', 'paused'].includes(r.status) ? ` ` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}
    +

    소유권 이전 심사(§22)

    ${table([{ l: '요청', f: (t) => `${esc(t.id)}
    ${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) ? ` ` : t.status === 'disputed' ? `` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; } diff --git a/lang/ko/ops.json b/lang/ko/ops.json new file mode 100644 index 0000000..6bcf5c8 --- /dev/null +++ b/lang/ko/ops.json @@ -0,0 +1,9 @@ +{ + "ops": { + "title": "Ariweb HAL 운영자 콘솔", + "menu": { "overview": "운영 현황", "customers": "고객과 사용자 그룹", "orders": "주문과 결제 승인", "products": "상품과 정책", "sites": "사이트와 배포", "mail": "메일 서비스", "domains": "도메인과 인증서", "servers": "서버와 용량", "backup": "백업과 복원", "relocation": "서버 이전", "releases": "배포 릴리스", "jobs": "운영 작업", "incidents": "장애와 알림", "audit": "감사 기록", "recovery": "플랫폼 복구", "account": "운영자 계정" }, + "gate": { "denied": "허용 네트워크(VPN·사설망) 또는 기기 귀속 접근권 없이는 로그인 화면에도 접근할 수 없습니다", "grantExpires": "접근권 만료 {at}", "reauth": "고위험 작업은 최근 10분 안의 재인증이 필요합니다", "customerSession": "고객 세션으로는 운영자 화면을 열 수 없습니다" }, + "threeStep": { "impact": "1단계 — 영향 범위 확인", "identifiers": "2단계 — 변경되지 않는 대상 식별정보 재입력", "execute": "3단계 — 최종 실행과 되돌리기 가능 여부 확인" }, + "state": { "stale": "정보 지연 — 포털 또는 Agent 에서 최신 정보를 받지 못했습니다. 정상으로 판정하지 않습니다", "partial": "부분 실패 — 대상별 결과를 확인하세요" } + } +} diff --git a/portal/ops/api.mjs b/portal/ops/api.mjs new file mode 100644 index 0000000..30472bd --- /dev/null +++ b/portal/ops/api.mjs @@ -0,0 +1,101 @@ +/** + * portal/ops/api.mjs — 운영자 콘솔(HAL)이 중계해서 부르는 고객 지원 내부 API(§26.2·§25 고객 지원용 일시 접근). + * + * 모든 경로는 /internal/ops/* 이며 portal/commerce/opsauth.requireOps(서비스 키 'hal' + HMAC, 테스트는 deps.opsAuthBypass)로 보호된다. + * 운영자 식별은 HAL 중계가 본문에 넣는 _operator(id·login) 또는 X-Ops-Actor 헤더로 받는다(비밀 없음). + * + * GET /internal/ops/customers?q= 고객(사용자 이메일·이름)·사용자 그룹(이름·id) 검색 + * GET /internal/ops/customers/:groupId 그룹 전체 보기: 구성원·활성 상품(권리)·사이트·메일·도메인·백업·사용량·주문·결제·정지·보존·고객 화면 오류 + * POST /internal/ops/customers/:groupId/change 고객 대신 변경(허용 항목만): 변경 전후·사유·승인자 기록 + 고객 감사 기록 + * GET /internal/ops/support-access 일시 접근 목록(활성·만료·회수) + * POST /internal/ops/support-access 일시 접근 발급(대상·이유·만료·승인자) + * POST /internal/ops/support-access/:id/revoke + * 비밀번호·토큰·메일 원문은 어떤 응답에도 넣지 않는다(§30.2·§37). 개인정보는 운영자 역할 화면에만 보인다. + */ +import { HttpError } from '../../lib/http.mjs'; +import { requireOps } from '../commerce/opsauth.mjs'; +import { newId } from '../../lib/crypto.mjs'; +import { nowIso, toJson, parseJson } from '../../lib/db.mjs'; +import { audit, listAudit } from '../../lib/audit.mjs'; +import { redact } from '../../lib/redact.mjs'; +import * as quota from '../../lib/quota.mjs'; +import { describe } from '../../lib/jobs.mjs'; + +/** 운영자가 고객 대신 바꿀 수 있는 항목(그 밖의 항목은 거부 — 고객 스스로 또는 전용 운영 화면에서) */ +const CHANGEABLE = { + 'group.name': { table: 'groups', col: 'name', id: 'group' }, 'group.contact_email': { table: 'groups', col: 'contact_email', id: 'group' }, 'group.contact_phone': { table: 'groups', col: 'contact_phone', id: 'group' }, + 'site.maintenance': { table: 'sites', col: 'maintenance', id: 'site', bool: true }, 'site.search_visible': { table: 'sites', col: 'search_visible', id: 'site', bool: true }, 'site.name': { table: 'sites', col: 'name', id: 'site' }, +}; +const tableExists = (store, t) => !!store.get('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=?', t); +const operatorOf = (ctx, body, actor) => String((body && body._operator && body._operator.login) || actor || 'operator').slice(0, 80); + +export function register(app, deps) { + const { store } = deps; + const ops = (fn) => async (ctx) => { const { actor } = await requireOps(ctx, deps); return fn(ctx, actor); }; + /** 사용자·그룹 요약(비밀 없음) */ + const userPub = (u) => ({ id: u.id, email: u.email, displayName: u.display_name, verified: !!u.email_verified_at, status: u.status, lastLoginAt: u.last_login_at, createdAt: u.created_at, mfa: !!(parseJson(u.mfa_json, null) && parseJson(u.mfa_json, {}).enabledAt), locked: !!(u.lock_until && Date.parse(u.lock_until) > Date.now()) }); + const groupPub = (g) => ({ id: g.id, name: g.name, kind: g.kind, status: g.status, contactEmail: g.contact_email, contactPhone: g.contact_phone, ownerUserId: g.owner_user_id, members: store.get('SELECT COUNT(*) AS n FROM memberships WHERE group_id=? AND status=\'active\'', g.id).n, createdAt: g.created_at }); + + app.get('/internal/ops/customers', ops((ctx) => { + const q = String(ctx.query.q || '').trim(); const like = `%${q}%`; + const users = q ? store.all('SELECT * FROM users WHERE email LIKE ? OR display_name LIKE ? OR id=? ORDER BY created_at DESC LIMIT 50', like, like, q) : store.all('SELECT * FROM users ORDER BY created_at DESC LIMIT 50'); + const groups = q ? store.all('SELECT * FROM groups WHERE name LIKE ? OR id=? OR id IN (SELECT group_id FROM memberships WHERE user_id IN (SELECT id FROM users WHERE email LIKE ?)) ORDER BY created_at DESC LIMIT 50', like, q, like) : store.all('SELECT * FROM groups ORDER BY created_at DESC LIMIT 50'); + return { ok: true, q, users: users.map(userPub), groups: groups.map(groupPub) }; + })); + app.get('/internal/ops/customers/:groupId', ops((ctx, actor) => { + const g = store.get('SELECT * FROM groups WHERE id=?', ctx.params.groupId); if (!g) throw new HttpError(404, 'not_found', '사용자 그룹이 없습니다'); + const gid = g.id; const now = nowIso(); + const members = store.all('SELECT m.*, u.email, u.display_name, u.email_verified_at, u.last_login_at, u.lock_until, u.status AS user_status FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.group_id=? ORDER BY m.joined_at', gid).map((m) => ({ userId: m.user_id, email: m.email, displayName: m.display_name, role: m.role, alias: m.alias, status: m.status, verified: !!m.email_verified_at, lastLoginAt: m.last_login_at, suspendedAt: m.suspended_at, suspendedBy: m.suspended_by, locked: !!(m.lock_until && Date.parse(m.lock_until) > Date.now()) })); + const entitlements = store.all('SELECT * FROM quota_entitlements WHERE group_id=? ORDER BY starts_at DESC', gid).map((e) => ({ ...e, active: e.starts_at <= now && (!e.ends_at || e.ends_at > now) })); + const subscriptions = tableExists(store, 'subscriptions') ? store.all('SELECT * FROM subscriptions WHERE group_id=? ORDER BY created_at DESC', gid) : []; + const sites = store.all('SELECT id, name, lifecycle, deploy_mode, primary_domain, site_count_state, server_id, health, health_checked_at, maintenance, paused_at, cancel_at, retention_until, delete_after, deploy_verify, current_version, updated_at FROM sites WHERE group_id=? AND lifecycle<>\'deleted\' ORDER BY created_at', gid); + const mail = store.all('SELECT id, name, kind, site_id, domain, status, dns_state, send_state, receive_state, cancelled_at, retention_until, delete_after FROM mail_services WHERE group_id=? ORDER BY created_at', gid); + const domains = store.all('SELECT id, name, purpose, site_id, mail_service_id, is_primary, ownership_state, dns_state, cert_state, cert_expires_at, mail_dns_state, last_checked_at FROM domains WHERE group_id=? ORDER BY name', gid); + const backups = store.all('SELECT * FROM backup_options WHERE group_id=?', gid); + const orders = store.all('SELECT id, order_no, kind, status, total, currency, due_at, paid_at, approved_at, rejected_reason, created_at FROM orders WHERE group_id=? ORDER BY created_at DESC LIMIT 50', gid); + const usage = quota.summary(store, gid); const allocations = quota.listAllocations(store, gid); const reservations = quota.listReservations(store, gid); + const limits = tableExists(store, 'usage_limits') ? store.all('SELECT * FROM usage_limits WHERE group_id=? AND level>0', gid) : []; + const failedJobs = deps.jobs ? deps.jobs.list({ groupId: gid, status: ['failed', 'needs_user'], limit: 30 }).map(describe) : []; + const errors = listAudit(store, { groupId: gid, limit: 500 }).filter((a) => a.outcome === 'failed' || a.outcome === 'denied').slice(0, 30); + const suspensions = { members: members.filter((m) => m.status === 'suspended'), sites: sites.filter((s) => s.lifecycle === 'paused' || s.maintenance), mail: mail.filter((m) => ['paused', 'suspended', 'cancelled'].includes(m.status)) }; + const retention = { sites: sites.filter((s) => s.retention_until || s.delete_after || s.cancel_at), mail: mail.filter((m) => m.retention_until || m.delete_after), backups: backups.filter((b) => b.ends_at || b.delete_after) }; + const access = store.all('SELECT * FROM support_access WHERE group_id=? ORDER BY created_at DESC LIMIT 50', gid).map(accessPub); + const changes = store.all('SELECT * FROM ops_changes WHERE group_id=? ORDER BY created_at DESC LIMIT 50', gid).map((c) => ({ ...c, before: parseJson(c.before_json, null), after: parseJson(c.after_json, null) })); + audit(store, { actorType: 'operator', actorId: actor, groupId: gid, action: 'ops.customer.view', subjectType: 'group', subjectId: gid, detail: { reason: ctx.query.reason || '' } }); + return { ok: true, group: groupPub(g), members, entitlements, subscriptions, sites, mail, domains, backups, orders, usage, allocations, reservations, limits, failedJobs, errors, suspensions, retention, supportAccess: access, changes, changeable: Object.keys(CHANGEABLE), generatedAt: now }; + })); + app.post('/internal/ops/customers/:groupId/change', ops(async (ctx, actor) => { + const b = await ctx.body(); const op = operatorOf(ctx, b, actor); const def = CHANGEABLE[b.field]; + if (!def) throw new HttpError(400, 'field_not_changeable', `운영자가 대신 바꿀 수 없는 항목입니다: ${b.field || ''}`, { changeable: Object.keys(CHANGEABLE) }); + if (!b.reason || String(b.reason).trim().length < 2) throw new HttpError(400, 'reason_required', '변경 사유가 필요합니다'); + const g = store.get('SELECT * FROM groups WHERE id=?', ctx.params.groupId); if (!g) throw new HttpError(404, 'not_found', '사용자 그룹이 없습니다'); + const subjectId = def.id === 'group' ? g.id : String(b.subjectId || ''); const row = def.id === 'group' ? g : store.get('SELECT * FROM sites WHERE id=? AND group_id=?', subjectId, g.id); + if (!row) throw new HttpError(404, 'not_found', '대상이 없습니다(다른 그룹의 대상은 바꿀 수 없습니다)'); + const before = row[def.col]; const after = def.bool ? (b.value ? 1 : 0) : String(b.value ?? '').slice(0, 200); + if (!def.bool && !after && def.col === 'name') throw new HttpError(400, 'value_required', '이름은 비울 수 없습니다'); + store.run(`UPDATE ${def.table} SET ${def.col}=?, updated_at=? WHERE id=?`, after, nowIso(), subjectId); + const id = newId('oc_'); store.run('INSERT INTO ops_changes(id, group_id, subject_type, subject_id, field, before_json, after_json, reason, operator, approver, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, g.id, def.id, subjectId, b.field, toJson(before), toJson(after), String(b.reason).slice(0, 500), op, b.approver ? String(b.approver).slice(0, 80) : null, nowIso()); + // 고객 화면의 활동 기록에도 같은 내용이 보인다(§26.2 "고객 화면에도 반영") + audit(store, { actorType: 'operator', actorId: op, actorLabel: `운영자 ${op}`, groupId: g.id, subjectType: def.id, subjectId, action: `ops.change.${b.field}`, detail: { before, after, reason: b.reason, approver: b.approver || null }, immutable: true }); + return { ok: true, change: { id, field: b.field, subjectType: def.id, subjectId, before, after, reason: b.reason, operator: op, approver: b.approver || null } }; + })); + const accessPub = (a) => ({ ...a, scope: parseJson(a.scope_json, []), state: a.revoked_at ? 'revoked' : Date.parse(a.expires_at) < Date.now() ? 'expired' : a.approved_at ? 'active' : 'pending' }); + app.get('/internal/ops/support-access', ops((ctx) => ({ ok: true, grants: store.all('SELECT * FROM support_access ORDER BY created_at DESC LIMIT 200').map(accessPub) }))); + app.post('/internal/ops/support-access', ops(async (ctx, actor) => { + const b = await ctx.body(); const op = operatorOf(ctx, b, actor); + if (!b.groupId || !store.get('SELECT id FROM groups WHERE id=?', b.groupId)) throw new HttpError(404, 'not_found', '사용자 그룹이 없습니다'); + if (!b.reason || String(b.reason).trim().length < 2) throw new HttpError(400, 'reason_required', '접근 이유가 필요합니다'); + const hours = Math.min(24, Math.max(1, Number(b.hours || 4))); const scope = Array.isArray(b.scope) && b.scope.length ? b.scope.map(String) : ['view']; + const id = newId('sa_'); const now = nowIso(); const approver = b.approver ? String(b.approver).slice(0, 80) : op; + store.run('INSERT INTO support_access(id, group_id, target_type, target_id, reason, scope_json, requested_by, approved_by, approved_at, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, b.groupId, String(b.targetType || 'group'), b.targetId || null, String(b.reason).slice(0, 500), toJson(scope), op, approver, now, now, new Date(Date.now() + hours * 3600000).toISOString()); + audit(store, { actorType: 'operator', actorId: op, groupId: b.groupId, subjectType: 'support_access', subjectId: id, action: 'ops.support_access.issue', detail: { targetType: b.targetType || 'group', targetId: b.targetId || null, reason: b.reason, hours, scope, approver }, immutable: true }); + return { ok: true, grant: accessPub(store.get('SELECT * FROM support_access WHERE id=?', id)) }; + })); + app.post('/internal/ops/support-access/:id/revoke', ops(async (ctx, actor) => { + const b = await ctx.body(); const op = operatorOf(ctx, b, actor); const a = store.get('SELECT * FROM support_access WHERE id=?', ctx.params.id); if (!a) throw new HttpError(404, 'not_found', '접근 기록이 없습니다'); + store.run('UPDATE support_access SET revoked_at=?, revoked_by=? WHERE id=? AND revoked_at IS NULL', nowIso(), op, a.id); + audit(store, { actorType: 'operator', actorId: op, groupId: a.group_id, subjectType: 'support_access', subjectId: a.id, action: 'ops.support_access.revoke', immutable: true }); + return { ok: true, grant: accessPub(store.get('SELECT * FROM support_access WHERE id=?', a.id)) }; + })); + deps.opsSupport = { changeable: Object.keys(CHANGEABLE), redact }; +} diff --git a/portal/ops/model.mjs b/portal/ops/model.mjs new file mode 100644 index 0000000..f9678d8 --- /dev/null +++ b/portal/ops/model.mjs @@ -0,0 +1,17 @@ +/** + * portal/ops/model.mjs — 운영자 고객 지원 영역의 표. + * support_access : 고객 지원용 일시 접근(§25): 대상·이유·만료·승인 기록. 허용 범위(scope_json) 안에서만 조회·변경할 수 있음을 화면에 보인다. + * ops_changes : 운영자가 고객 대신 바꾼 값의 변경 전후·사유·승인자(§26.2). 고객 감사 기록(audit_log)에도 같은 내용이 남는다. + */ +export const MIGRATIONS = [{ id: 'ops_0001', sql: ` +CREATE TABLE IF NOT EXISTS support_access ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT, reason TEXT NOT NULL, scope_json TEXT NOT NULL, + requested_by TEXT NOT NULL, approved_by TEXT, approved_at TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT, revoked_by TEXT, last_used_at TEXT +); +CREATE INDEX IF NOT EXISTS support_access_group ON support_access(group_id, expires_at); +CREATE TABLE IF NOT EXISTS ops_changes ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, field TEXT NOT NULL, before_json TEXT, after_json TEXT, + reason TEXT NOT NULL, operator TEXT NOT NULL, approver TEXT, created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ops_changes_group ON ops_changes(group_id, created_at); +` }]; diff --git a/test/hal-ui.test.mjs b/test/hal-ui.test.mjs new file mode 100644 index 0000000..9841ce6 --- /dev/null +++ b/test/hal-ui.test.mjs @@ -0,0 +1,98 @@ +/** test/hal-ui.test.mjs — 운영자 콘솔 화면 회귀 테스트: 네트워크 관문·운영자 세션 필수(고객 쿠키 거부)·주요 화면 렌더·중계 실패 → 정보 지연·포털 고객 지원 API. */ +import { test, eq, ok, tmpDir, done } from '../lib/test.mjs'; +import { invoke } from '../lib/http.mjs'; +import { createHalDeps } from '../hal/deps.mjs'; +import { buildApp } from '../hal/app.mjs'; +import * as core from '../hal/core.mjs'; +import * as ops from '../hal/ops.mjs'; +import * as emergency from '../hal/emergency.mjs'; +import * as dr from '../hal/dr.mjs'; +import * as R from '../hal/ui/render.mjs'; +import { changelog, overviewLocal } from '../hal/ui.mjs'; +import { makeDeps, seedGroup, appFor, call } from './helpers.mjs'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +function makeHal(over = {}) { + const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true }); + const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); }, ...over }; + const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } }); + return { d, app: buildApp(d), dataDir }; +} +/** 운영자 계정 + 로그인 세션 */ +async function loginOp(h) { ops.createAccount(h.d, { login: 'ozops', password: 'password-example-1', displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: 'password-example-1' } }); ok(r.json.ok, JSON.stringify(r.json)); return { cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (method, path, body, extra = {}) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops, ...(extra.cookies || {}) }, headers: { 'x-csrf': r.json.csrf, ...(extra.headers || {}) }, ip: extra.ip }) }; } + +await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠키 → 로그인 화면, 운영자 세션 → 콘솔(메뉴 14개 + 계정)', async () => { + const h = makeHal(); + let r = await invoke(h.app, 'GET', '/ops/', { ip: '203.0.113.9' }); eq(r.status, 403); eq(r.json.error, 'access_denied'); + r = await invoke(h.app, 'GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('HAL 최초 설정'), '계정 없으면 최초 설정 화면'); + const op = await loginOp(h); + r = await invoke(h.app, 'GET', '/ops/servers', { cookies: { aw_session: 'customer-cookie-example' } }); eq(r.status, 200); ok(r.text.includes('운영자 로그인') && !r.text.includes('서버 등록'), '고객 세션 쿠키로는 로그인 화면만'); + r = await op.as('GET', '/ops/'); eq(r.status, 200); for (const m of ['운영 현황', '고객과 사용자 그룹', '주문과 결제 승인', '상품과 정책', '사이트와 배포', '메일 서비스', '도메인과 인증서', '서버와 용량', '백업과 복원', '서버 이전', '배포 릴리스', '운영 작업', '장애와 알림', '감사 기록', '플랫폼 복구']) ok(r.text.includes(m), '메뉴 ' + m); + ok(r.text.includes('부트스트랩 미완료'), '부트스트랩 상태 배너'); ok(r.text.includes('data-page="overview"')); + // 로그인 화면은 고객 로그인과 분리된 문구 + r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다')); +}); +await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => { + const h = makeHal(); const op = await loginOp(h); + const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id }); + const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok); + h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=?, metrics_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 10e9, diskTotalBytes: 100e9, sitesHosted: 3, reservedBytes: 1e9 }), JSON.stringify({ cpu: 12, memUsed: 50, memTotal: 100, netIn: 1000, netOut: 2000 }), s.id); + core.approveServer(h.d, s.id, 'ozops'); + h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', 'portal', s.id, '0.1.1', '0.1.0', 'ok', 1, new Date().toISOString(), new Date().toISOString()); + core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abcdef1234567890', builtAt: new Date().toISOString(), artifacts: { hal: { file: 'hal.tgz', sha256: 'sha256:x', size: 1 } }, checks: { testsPassed: true } }); + let r = await op.as('GET', '/ops/servers'); eq(r.status, 200); ok(r.text.includes('웹1') && r.text.includes('배치 가능') && r.text.includes('0.1.0') && r.text.includes('0.1.1'), '서버 표에 상태·모듈 차이'); + r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"')); + r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 을 먼저 배포') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기')); + // 작업: 부모 + 자식(하나 실패) → 부분 실패 표시 + h.d.jobs.register('ui.demo', { title: '데모', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { await ctx.step('a', () => { if (job.subject.fail) throw new Error('대상 실패'); return 1; }); } }); + const p = h.d.jobs.enqueue({ kind: 'ui.demo', initialStatus: 'awaiting_external', label: '부모' }).job; h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상1' }); h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상2', subject: { fail: true } }); await h.d.jobs.drain(); + r = await op.as('GET', '/ops/jobs'); ok(r.text.includes('부모') && r.text.includes('대상2')); + r = await op.as('GET', `/ops/jobs/${p.id}`); ok(r.text.includes('부분 실패') && r.text.includes('대상 실패'), '자식 실패는 부분 실패로'); + r = await op.as('GET', '/ops/jobs?status=needs_user,failed'); ok(r.text.includes('대상2') && !r.text.includes('>대상1<')); + // 사건·감사·복구·계정 + await op.as('POST', '/ops/api/incidents', { key: 'k1', title: '메일 지연', severity: 'warning', customerImpact: '수신 지연' }); + r = await op.as('GET', '/ops/incidents'); ok(r.text.includes('메일 지연') && r.text.includes('사용자 경로 확인 후')); + r = await op.as('GET', '/ops/audit?action=ops.'); ok(r.text.includes('ops.') || r.text.includes('기록이 없습니다')); + r = await op.as('GET', '/ops/recovery'); ok(r.text.includes('긴급 복구 선언') && r.text.includes('정식 운영 준비 전') && r.text.includes('기존 제어 위치 중지 확인')); + r = await op.as('GET', '/ops/account'); ok(r.text.includes('접근권') && r.text.includes('운영자 추가') && r.text.includes('비밀번호만')); + r = await op.as('GET', '/ops/'); ok(r.text.includes('목표·실제 차이') && r.text.includes('0.1.0'), '운영 현황에 버전 차이'); + const o = overviewLocal(h.d); eq(o.drift.length, 1); ok(o.components.find((c) => c.name === 'portal').status === 'ok'); +}); +await test('중계 실패 → 정보 지연(정상으로 판정하지 않음), 재인증 필요·권한 없음 표시, 포털 미연동 503', async () => { + const h = makeHal(); const op = await loginOp(h); + let r = await op.as('GET', '/ops/api/portal/commerce/orders'); eq(r.status, 503); eq(r.json.error, 'portal_not_linked'); + h.d.vault.set('service:hal', 'hal-secret-example'); r = await op.as('GET', '/ops/api/portal/commerce/orders'); eq(r.status, 503); eq(r.json.error, 'portal_unreachable'); + const html = R.relayError(r.json); ok(html.includes('정보 지연') && !html.includes('정상')); ok(R.relayError({ code: 'reauth_required' }).includes('재인증')); ok(R.relayError({ code: 'unauthenticated' }).includes('권한 없음')); + ok(R.pendingApprovals({ orders: 1, restores: 0, relocations: 2, transfers: 0, partial: ['/transfer'] }).includes('부분 실패')); + // 고객 화면 렌더러는 빈 데이터도 상태 패널로 + ok(R.customerView({ group: { id: 'g1', name: '그룹', kind: 'team' } }).includes('사이트가 없습니다')); ok(R.ordersView({ orders: [] }).includes('비어 있음')); + ok(R.serversTable([]).includes('등록된 서버가 없습니다')); ok(R.metricsSvg([]).includes('비어 있음')); ok(R.metricsSvg([{ at: '2026-10-01T00:00:00Z', cpu: 5 }, { at: '2026-10-02T00:00:00Z', cpu: 50 }], 'cpu').includes(' { + const h = makeHal(); const op = await loginOp(h); + h.d.store.run('UPDATE bootstrap_state SET hal_running_version=NULL'); // HAL 비정상 상태로 선언 허용 + let s; try { s = emergency.declare(h.d, { incidentKey: 'INC-1', reason: 'HAL 정지', scope: ['hal'], by: 'ozops', force: true }); } catch (e) { s = null; } + const r = await op.as('GET', '/ops/recovery'); ok(r.text.includes('허용 작업') && r.text.includes('일반 고객 서비스 배포는 금지')); + if (s) { ok(r.text.includes('INC-1') && r.text.includes('1단계 승인') && r.text.includes('미발급')); } + const cl = changelog(); ok(typeof cl === 'object'); + const v = R.drView({ readiness: dr.readiness(h.d), snapshots: [], restores: [{ id: 'drr_1', snapshot_id: 'x', status: 'requested' }], drills: [] }); ok(v.includes('미확인') && v.includes('승인') && v.includes('미설정')); +}); +await test('포털 고객 지원 API: 검색·그룹 보기·대신 변경(전후·사유·승인자·고객 감사)·허용 밖 항목 거부·일시 접근 발급·회수', async () => { + const deps = await makeDeps({ opsAuthBypass: true }); const who = seedGroup(deps, { email: 'cust@example.test', name: '고객사' }); + const app = await appFor(deps, ['ops', 'auditui'], who); + deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', 's_1', who.group.id, '홈페이지', 'live', new Date().toISOString(), new Date().toISOString()); + let r = await call(app, 'GET', '/internal/ops/customers?q=cust@'); eq(r.status, 200); eq(r.json.groups[0].id, who.group.id); eq(r.json.users[0].email, 'cust@example.test'); ok(!JSON.stringify(r.json).includes('pass_hash')); + r = await call(app, 'GET', `/internal/ops/customers/${who.group.id}`); eq(r.status, 200); eq(r.json.sites.length, 1); eq(r.json.members.length, 1); ok(Array.isArray(r.json.usage) && r.json.changeable.includes('site.maintenance')); + r = await call(app, 'POST', `/internal/ops/customers/${who.group.id}/change`, { field: 'site.maintenance', subjectId: 's_1', value: true, reason: '고객 요청(전화)', approver: 'hera', _operator: { login: 'ozops' } }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.change.before, 0); eq(r.json.change.after, 1); eq(r.json.change.operator, 'ozops'); + eq(deps.store.get('SELECT maintenance FROM sites WHERE id=?', 's_1').maintenance, 1); + const au = deps.store.get('SELECT * FROM audit_log WHERE action=? AND group_id=?', 'ops.change.site.maintenance', who.group.id); ok(au && au.immutable === 1 && au.detail_json.includes('고객 요청'), '고객 감사 기록'); + r = await call(app, 'POST', `/internal/ops/customers/${who.group.id}/change`, { field: 'user.password', value: 'x', reason: 'r' }); eq(r.status, 400); eq(r.json.error, 'field_not_changeable'); + r = await call(app, 'POST', `/internal/ops/customers/${who.group.id}/change`, { field: 'site.name', subjectId: 's_other', value: 'x', reason: '다른 그룹 대상 시험' }); eq(r.status, 404); + r = await call(app, 'POST', '/internal/ops/support-access', { groupId: who.group.id, targetType: 'site', targetId: 's_1', reason: '배포 실패 조사', hours: 2, scope: ['view'], _operator: { login: 'ozops' } }); eq(r.status, 200); eq(r.json.grant.state, 'active'); eq(r.json.grant.approved_by, 'ozops'); + r = await call(app, 'POST', `/internal/ops/support-access/${r.json.grant.id}/revoke`, {}); eq(r.json.grant.state, 'revoked'); + r = await call(app, 'GET', `/internal/ops/customers/${who.group.id}`); eq(r.json.supportAccess.length, 1); eq(r.json.changes.length, 1); + // 서명 없이(바이패스 끔) 부르면 401 + deps.opsAuthBypass = false; r = await call(app, 'GET', '/internal/ops/customers'); eq(r.status, 401); +}); +done();