158 lines
37 KiB
JavaScript
158 lines
37 KiB
JavaScript
/**
|
|
* hal/ui.mjs — 운영자 콘솔 화면(§25~§34) 라우트. hal/app.mjs 가 registerUi(app, d) 로 부른다.
|
|
*
|
|
* 구조
|
|
* - 모든 화면은 /ops/* 아래에 있고 app.mjs 의 운영자 미들웨어(네트워크 관문 → 운영자 세션)를 먼저 거친다. 허용 네트워크·접근권이 없으면 로그인 화면도 열리지 않는다(403).
|
|
* - 운영자 세션이 없으면(고객 세션 쿠키가 있어도) 로그인 화면만 보인다. 고객 세션으로는 어떤 운영자 화면도 열 수 없다.
|
|
* - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는
|
|
* 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음).
|
|
* - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css).
|
|
* - [0.3.0] 본문 오른쪽에 AI 채팅룸 패널(#ai-panel, hal/ui/public/ai.js)이 붙는다 — 통합 탭 고정 + 연결별 탭. 좁은 화면에서는 본문 아래로 내려간다(ops.css). 메뉴 'AI 연동'(/ops/ai)에서 연결을 관리한다.
|
|
* - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs).
|
|
*/
|
|
import { esc } from '../lib/http.mjs';
|
|
import * as session from '../lib/session.mjs';
|
|
import { describe } from '../lib/jobs.mjs';
|
|
import { listAudit } from '../lib/audit.mjs';
|
|
import { listNotifications } from '../lib/notify.mjs';
|
|
import { redact } from '../lib/redact.mjs';
|
|
import { nowIso } from '../lib/db.mjs';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import * as core from './core.mjs';
|
|
import * as ops from './ops.mjs';
|
|
import * as emergency from './emergency.mjs';
|
|
import * as dr from './dr.mjs';
|
|
import * as mailsvc from './mailsvc.mjs';
|
|
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
|
import { releasesData } from './app.mjs';
|
|
import * as R from './ui/render.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
/** 메뉴(§25) */
|
|
export const MENU = [
|
|
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
|
|
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
|
|
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'],
|
|
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'],
|
|
];
|
|
|
|
export function registerUi(app, d) {
|
|
/** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */
|
|
function shell(ctx, { title, nav, body, page, id = '', scripts = [] }) {
|
|
if (!ctx.operator) return ctx.html(loginPage(ctx, d));
|
|
const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null;
|
|
const health = core.selfHealth(d); const emOpen = d.store.get('SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'').n;
|
|
const banner = [];
|
|
// [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다.
|
|
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
|
|
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
|
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
|
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
|
|
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
|
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
|
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
|
|
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="ai-toggle" aria-pressed="true" aria-controls="ai-panel" title="AI 채팅룸 열기/닫기">AI</button><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
|
<div class="shell"><aside class="side" id="side"><nav aria-label="운영자 메뉴">${MENU.map(([k, href, label]) => `<a class="nav${nav === k ? ' on' : ''}" href="${href}">${esc(label)}</a>`).join('')}</nav></aside>
|
|
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div>
|
|
<section id="ops-dock" class="ops-dock" aria-label="운영 작업 진행 상황"><header class="ops-dock-head"><button class="ai-tab on" data-act="dock-tab" data-tab="active">진행 중 <span id="ops-dock-count" class="badge neutral">0</span></button><button class="ai-tab" data-act="dock-tab" data-tab="history">히스토리</button><span class="muted small">배포·복원 등 운영 작업의 현재 진행 상황과 최근 기록 — 5초마다 바뀐 부분만 갱신</span><button class="btn small" data-act="dock-toggle" aria-expanded="true" title="접기/펼치기">▾</button></header><div id="ops-dock-body" class="ops-dock-body">${R.statePanel('loading', '작업 상황을 불러오는 중')}</div></section></main>
|
|
<aside id="ai-panel" class="ai-panel" aria-label="AI 채팅룸" data-operator="${esc(ctx.operator.login)}"><div class="ai-resize" title="너비 조절"></div><div class="ai-head"><div class="ai-tabs" role="tablist" id="ai-tabs"><span class="muted small">AI 채팅룸 불러오는 중</span></div></div><div class="ai-status" id="ai-status"></div><div class="ai-log" id="ai-log" aria-live="polite"></div><form class="ai-compose" id="ai-compose"><textarea id="ai-input" rows="2" placeholder="지시를 입력하세요. /help 로 명령 목록(/status /order …), 통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다" aria-label="AI 지시"></textarea><div class="ai-compose-row"><label class="small"><input type="checkbox" id="ai-ctx" checked> 현재 화면 문맥 포함</label><button class="btn small" type="button" data-act="ai-reset" title="이 방의 기록을 비웁니다">새 대화</button><button class="btn primary small" type="submit">보내기</button></div></form></aside></div>
|
|
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
|
|
<script type="module" src="/ops/ui/ops.js"></script><script type="module" src="/ops/ui/ai.js"></script>${scripts.map((s) => `<script type="module" src="${esc(s)}"></script>`).join('')}</body></html>`);
|
|
}
|
|
// ---------------- 로그인·최초 설정
|
|
app.get('/ops/login', (ctx) => ctx.operator ? ctx.redirect('/ops/') : ctx.html(loginPage(ctx, d)));
|
|
app.get('/ops/setup', (ctx) => ctx.html(loginPage(ctx, d, { setup: true })));
|
|
// ---------------- 운영 현황(§26.1)
|
|
app.get('/ops/', (ctx) => shell(ctx, { title: '운영 현황', nav: 'overview', page: 'overview', body: R.overviewView(overviewLocal(d)) }));
|
|
// ---------------- 포털 중계 화면(브라우저가 /ops/api/portal/* 로 채운다)
|
|
const relayPage = (path, nav, title, page) => app.get(path, (ctx) => shell(ctx, { title, nav, page, id: ctx.params.id || ctx.params.groupId || '', body: R.statePanel('loading', '포털에서 불러오는 중') }));
|
|
relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer');
|
|
relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products');
|
|
relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site');
|
|
// 메일 서비스: HAL 관리형(legacy 가져오기, HAL 자체 데이터)을 먼저 서버에서 그리고, 포털 메일 서비스는 브라우저가 중계로 채운다(ops.js 는 #portal-mail 만 바꾼다).
|
|
app.get('/ops/mail', (ctx) => shell(ctx, { title: '메일 서비스', nav: 'mail', page: 'mail', body: `<h2>HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)</h2>${mailServicesView(mailsvc.readback(d))}<h2>포털 메일 서비스</h2><div id="portal-mail">${R.statePanel('loading', '포털에서 불러오는 중')}</div>` }));
|
|
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: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button> <button class="btn" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="portal-relocate">포털 이전</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
|
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 = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
|
|
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
|
|
// ---------------- 릴리스(§32.1)
|
|
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ ...releasesData(d), running: core.bootstrapState(d) }, changelog()) })); // [0.3.17] 모듈별 최신 릴리스·안내 중심, HAL 제외
|
|
// ---------------- 작업(§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 = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
|
|
return shell(ctx, { title: '운영 작업', nav: 'jobs', page: 'jobs', body: filters + R.table([{ l: '작업 번호', f: (j) => `<a href="/ops/jobs/${esc(j.id)}" class="mono">${esc(j.id)}</a><br><b>${esc(j.label)}</b>` }, { l: '종류', k: 'kind' }, { l: '고객·대상', f: (j) => `${esc(j.groupId || '—')}<br><span class="muted">${esc((j.subject && (j.subject.target || j.subject.module || j.subject.payload?.siteId)) || '')}</span>` }, { 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 ? `<br><span class="muted">${esc(j.waitReason)}</span>` : ''}` }, { l: '갱신', f: (j) => R.fmt(j.updatedAt) }, { l: '오류', f: (j) => `<span class="err">${esc(j.error || '')}</span>` }], 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))}<h3>Agent 작업</h3>${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 ? `<h3>결과</h3><pre class="pre">${esc(JSON.stringify(redact(j.result), null, 1))}</pre>` : ''}<h3>기록</h3><pre class="pre">${esc((j.log || []).map((l) => `${l.at} ${l.line}`).join('\n') || '없음')}</pre>` }); });
|
|
// ---------------- 사건·알림(§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 })) + `<p class="muted">공개 상태 페이지: <a href="/status" target="_blank" rel="noopener">/status</a> — 개인정보·내부 위치 없음, 고객 영향 없는 내부 경고는 올리지 않음, 복구 완료는 실제 외부 확인 뒤 게시(§31.3).</p>` }));
|
|
// ---------------- 감사(§24.2·§25)
|
|
app.get('/ops/audit', (ctx) => shell(ctx, { title: '감사 기록', nav: 'audit', page: 'audit', body: `<form class="row" method="get" action="/ops/audit"><input name="action" placeholder="행동(예: ops., release., emergency.)" value="${esc(ctx.query.action || '')}"><input name="subjectId" placeholder="대상 id" value="${esc(ctx.query.subjectId || '')}"><input type="date" name="since" value="${esc(ctx.query.since || '')}"><button class="btn">조회</button></form>` + 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: `<div class="tabs"><a href="#emergency" class="on" data-tab="emergency">긴급 복구(§33)</a><a href="#dr" data-tab="dr">재해복구(§34)</a></div><section id="emergency">${R.emergencyView({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS })}</section><section id="dr" hidden>${R.drView({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50'), hal: core.selfHealth(d) })}</section>` }));
|
|
// ---------------- AI 연동(0.3.0, 개발 지시 v0.3.0): 모델 연결 관리 화면. 표·동작은 ai.js 가 /ops/api/ai/* 로 채운다. 비밀값은 서버로만 가고 화면에는 힌트만 돌아온다.
|
|
app.get('/ops/ai', (ctx) => shell(ctx, { title: 'AI 연동', nav: 'ai', page: 'ai', body: aiPage(d) }));
|
|
// ---------------- 운영자 계정(§25·§3.3)
|
|
app.get('/ops/account', (ctx) => shell(ctx, { title: '운영자 계정', nav: 'account', page: 'account', body: R.accountsView({ me: ctx.operator, recentlyAuthed: session.recentlyAuthed(ctx.session), via: ctx.opsVia, sessions: session.listSessions(d.store, ctx.operator.id, 'operator').map((s) => ({ ...s, current: s.id === ctx.session.id })), grants: d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 100'), accounts: d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount) }) }));
|
|
// [ops UI 추가] 서버 화면용 읽기 전용: 릴리스 변경 이력(VERSION_HISTORY.md 절)
|
|
app.get('/ops/api/changelog', () => ({ ok: true, changelog: changelog() }));
|
|
}
|
|
/** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */
|
|
function aiPage(d) {
|
|
return `<p class="muted">AI 모델을 HAL 에 연결합니다(기본 공급자: Claude · ChatGPT · Gemini · OpenRouter, 그 밖의 OpenAI 호환 API 서비스는 "기타"). OAuth 를 고르면 저장 직후 로그인 창이 자동으로 열리고, 로컬 CLI 는 로그인이 안 되어 있으면 텍스트 셸이 열립니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.</p>
|
|
<div id="ai-conns">${R.statePanel('loading', '연결 목록을 불러오는 중')}</div>
|
|
<h2>서버에서 찾은 CLI(로그인 세션)</h2><p class="muted small">HAL 서버(실행 계정)에 설치된 Claude Code·Codex CLI 를 자동 탐색합니다(서비스 PATH 밖의 <code>~/.local/bin</code> 등 포함). 로그인은 HAL 이 대신할 수 없으므로, "로그인 필요"면 서버에서 그 계정으로 <code>claude login</code> / <code>codex login</code> 을 실행한 뒤 "다시 탐색"하세요.</p><div id="ai-clis">${R.statePanel('loading', 'CLI 를 탐색하는 중')}</div>
|
|
<h2>연결 추가</h2><form id="ai-conn-form" class="card" autocomplete="off"><input type="hidden" name="id">
|
|
<div class="row"><label>이름<input name="name" required maxlength="60" placeholder="예: 네오"></label><label>공급자<select name="provider"><option value="anthropic">Claude(Anthropic)</option><option value="openai">ChatGPT(OpenAI)</option><option value="gemini">Gemini(Google)</option><option value="openrouter">OpenRouter</option><option value="other">기타 API 서비스(OpenAI 호환)</option><option value="local-cli">로컬 CLI 로그인 세션(Claude Code·Codex)</option></select></label><label>인증 방식<select name="authKind"><option value="api_key">API 키</option><option value="oauth">OAuth(로그인 창 자동)</option><option value="login">로그인(로컬 CLI 세션)</option></select></label></div>
|
|
<div id="ai-guide" class="alert info"><b>사전 준비</b> — 공급자·인증 방식을 고르면 필요한 준비 사항이 여기에 나옵니다.</div>
|
|
<div class="row"><label>기본 모델(비우면 공급자 기본값)<input name="model" placeholder="예: claude-opus-5-5 / gpt-5 / gemini-2.5-pro" maxlength="120"></label><label>API 기본 주소(기타 서비스는 필수, 그 밖은 선택)<input name="baseUrl" placeholder="https://…/v1"></label></div>
|
|
<fieldset data-auth="api_key"><legend>API 키</legend><label>API 키(저장 뒤 다시 표시하지 않음)<input name="apiKey" type="password" autocomplete="new-password"></label></fieldset>
|
|
<fieldset data-auth="oauth" hidden><legend>OAuth(authorization code + PKCE)</legend><div class="row"><label>authorize URL<input name="oauthAuthorizeUrl" placeholder="https://…/authorize"></label><label>token URL<input name="oauthTokenUrl" placeholder="https://…/oauth/token"></label></div><div class="row"><label>client id<input name="oauthClientId"></label><label>scope(공백 구분)<input name="oauthScopes"></label><label>client secret(선택, 저장 뒤 미표시)<input name="oauthClientSecret" type="password" autocomplete="new-password"></label></div><p class="muted small">저장 뒤 표의 "OAuth 로그인"을 누르면 공급자 로그인 화면으로 이동하고, 돌아오면 토큰이 Vault 에 저장됩니다. 콜백 주소: <code>${esc(d.config.publicUrl)}/ops/ai/oauth/callback</code></p></fieldset>
|
|
<fieldset data-auth="login" hidden><legend>로그인(로컬 CLI 세션)</legend><div class="row"><label>CLI 종류<select name="cliTool"><option value="claude">Claude Code(claude)</option><option value="codex">OpenAI Codex(codex)</option></select></label><label>CLI 실행 파일 절대 경로(비우면 서버에서 자동 탐색)<input name="cliBin" placeholder="예: /home/master/.local/bin/codex"></label></div><p class="muted small">HAL 서버에 설치되어 이미 로그인된 CLI 세션을 그대로 씁니다(HAL 은 세션 파일·자격을 읽지 않음). 이 방식은 대화·분석 전용이며 HAL 도구 호출은 지원하지 않습니다. 모델을 비우면 CLI 기본 모델을 씁니다.</p></fieldset>
|
|
<fieldset><legend>도구 정책(기본: 전부 — 읽기는 즉시, 쓰기·고위험은 승인)</legend><label>프리셋<select name="toolsPreset"><option value="all">전부(기본값)</option><option value="read_write">읽기 + 쓰기(고위험 제외)</option><option value="read_only">읽기만</option><option value="custom">직접 선택</option><option value="none">도구 없음(대화만)</option></select></label><div id="ai-tool-policy" class="small" hidden></div><p class="muted small">HAL 운영 도구는 연결 즉시 쓸 수 있게 미리 설정되어 있습니다. 인증·승인이 필요한 도구(쓰기·고위험)는 채팅룸에 승인 요청으로 올라오고, 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다.</p></fieldset>
|
|
<fieldset><legend>고유 정보</legend><div class="row"><label>역할·업무<input name="role" maxlength="2000" placeholder="예: 릴리스 검토·배포 감시"></label><label>특성<input name="traits" maxlength="2000" placeholder="예: 꼼꼼함, 근거 우선"></label></div><label>추가 지시<textarea name="instructions" rows="2" maxlength="2000" placeholder="이 모델에게만 주는 지시"></textarea></label></fieldset>
|
|
<div class="actions"><button class="btn primary" type="submit" id="ai-conn-submit">연결 추가</button><button class="btn" type="button" data-act="ai-conn-cancel" hidden>수정 취소</button></div><p class="err" id="ai-conn-msg" role="alert"></p></form>
|
|
<h2>텍스트 셸(CLI 로그인·대화형)</h2><p class="muted small">서버 CLI 의 로그인·대화형 화면을 여기서 직접 다룹니다. claude 의 실제 로그인 명령은 <code>claude auth login</code>(화살표·Enter 로 방식을 고르는 TUI)이고, 이미 설치·로그인된 경우 <code>claude</code> 대화형 세션이 "이 폴더를 신뢰합니까?" 같은 질문을 하므로 <b>단말 화면을 클릭해 키를 직접 누르거나</b> 키 단추(↑↓ Enter Esc Ctrl+C y n)로 답합니다. 허용 명령(login·대화형·상태·로그아웃·버전)만 가짜 단말(PTY)로 돌리며 작업 폴더는 HAL 실행 계정 HOME, 입력은 저장하지 않습니다. 시작에는 최근 10분 재인증이 필요합니다. <b>복사</b>: 셸에 나온 인증 주소는 아래 "감지된 주소" 목록에서 복사·열기 단추로(줄 끝에서 끊긴 긴 주소도 이어 붙임), 단말 글을 드래그 선택하면 Ctrl+C 가 복사가 되고(Ctrl+Shift+C 는 항상 복사), "전체 출력 복사"·"텍스트로 보기"도 있습니다.</p><div class="actions"><button class="btn primary" data-act="ai-shell-open" data-tool="claude" data-command="login">claude auth login</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="interactive">claude 대화형(신뢰 설정)</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="status">claude 상태</button><button class="btn primary" data-act="ai-shell-open" data-tool="codex" data-command="login">codex login</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="interactive">codex 대화형</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="status">codex 상태</button><button class="btn danger" data-act="ai-shell-kill-all">열린 셸 모두 종료</button></div><div id="ai-shells" class="small">${R.statePanel('loading', '셸 세션을 불러오는 중')}</div>
|
|
<h2>MCP 설정(외부 클라이언트용)</h2><p class="muted small">HAL 도구를 MCP 서버로 쓰는 설정입니다. 로컬 CLI 연결에는 자동으로 붙으며, 운영자의 Claude Code·Codex 에 직접 넣을 수도 있습니다. 쓰기·고위험 도구 호출은 통합 방에 승인 요청으로 올라오고 <code>hal_action_status</code> 로 결과를 봅니다.</p><div id="ai-mcp">${R.statePanel('loading', 'MCP 설정을 불러오는 중')}</div>
|
|
<h2>AI 가 쓰는 HAL 도구</h2><p class="muted small">읽기 도구는 즉시 실행됩니다. 쓰기·고위험 도구는 채팅룸에 "제안"으로 멈추고 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다. 임의 셸 명령 도구는 없습니다. MCP 클라이언트용: <code>node hal/index.mjs mcp</code>(읽기 전용).</p><div id="ai-tools">${R.statePanel('loading', '도구 목록을 불러오는 중')}</div>`;
|
|
}
|
|
/** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */
|
|
function loginPage(ctx, d, { setup = false } = {}) {
|
|
const needSetup = !core.ensureOpsAccountExists(d); const via = ctx.opsVia === 'grant' ? '기기 귀속 접근권' : '허용 네트워크';
|
|
const body = needSetup || setup ? `<h1>HAL 최초 설정</h1><p class="muted">bootstrap-hal.sh 가 한 번만 출력한 설정 토큰으로 첫 운영자 계정을 만듭니다. 토큰은 사용 후 폐기되며 다시 쓸 수 없습니다.</p><form id="setup-form"><label>설정 토큰<input name="setupToken" required autocomplete="off"></label><label>아이디<input name="login" required pattern="[a-z0-9][a-z0-9._-]{2,31}"></label><label>표시 이름<input name="displayName"></label><label>비밀번호(10자 이상)<input name="password" type="password" required minlength="10"></label><button class="btn primary">첫 운영자 만들기</button><p class="err" id="msg"></p></form>` : `<h1>운영자 로그인</h1><p class="muted">고객 계정으로는 로그인할 수 없습니다. 접속 경로: ${esc(via)}.</p><form id="login-form"><label>아이디<input name="login" required autocomplete="username"></label><label>비밀번호<input name="password" type="password" required autocomplete="current-password"></label><label id="totp-row" hidden>인증 코드(또는 복구 코드)<input name="totp" autocomplete="one-time-code"></label><button class="btn primary">로그인</button><p class="err" id="msg" role="alert"></p></form>`;
|
|
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"></head><body class="login"><main class="login-box" data-page="${needSetup || setup ? 'setup' : 'login'}">${body}<p class="muted small">Ariweb HAL v${esc(d.version)} · 반복 실패 시 15분 잠금 · 세션 24시간</p></main><script type="module" src="/ops/ui/ops.js"></script></body></html>`;
|
|
}
|
|
/** 운영 현황 데이터(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', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
|
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
|
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
|
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
|
}
|
|
/** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */
|
|
export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; }
|
|
|
|
/** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */
|
|
function mailServicesView(rb) {
|
|
const warn = []; if (rb.duplicates.length) warn.push(`<div class="alert critical">⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}</div>`); if (rb.secretLeaks) warn.push(`<div class="alert critical">⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인</div>`);
|
|
const t = R.table([
|
|
{ l: '도메인', f: (s) => `<b>${esc(s.domain)}</b><br><span class="muted">${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}</span>` },
|
|
{ l: '공개 주소', f: (s) => s.webUrl ? `<a href="${esc(s.webUrl)}" rel="noopener" target="_blank">${esc(s.publicHost)}</a>` : '—' },
|
|
{ l: '서버·인스턴스', f: (s) => `<a href="/ops/servers/${esc(s.serverId)}">${esc(s.serverName || s.serverId)}</a><br><span class="muted mono">${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}</span>` },
|
|
{ l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} <span class="muted">(AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})</span>${s.instance.observedVersion ? `<br><span class="muted">Agent 관측 ${esc(s.instance.observedVersion)}</span>` : ''}` },
|
|
{ l: '계정·메일함·메일', f: (s) => s.aggregates ? `${esc(String(s.aggregates.accounts ?? '—'))} · ${esc(String(s.aggregates.mailboxes ?? '—'))} · ${esc(String(s.aggregates.messages ?? '—'))}` : '—' },
|
|
{ l: '건강·동기화', f: (s) => `${R.healthBadge(s.health || 'unknown')}<br><span class="muted">${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}</span>` },
|
|
{ l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}<br><span class="muted">${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}</span>` },
|
|
{ l: '비밀', f: (s) => `<span class="muted">참조 ${(s.secretRefs || []).length}건(값 없음)</span>` },
|
|
], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' });
|
|
return warn.join('') + t + `<p class="muted">총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).</p>`;
|
|
}
|