2026-10-26 12:15:48 +00:00
/**
* 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).
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
* - [0.3.0] 본문 오른쪽에 AI 채팅룸 패널(#ai-panel, hal/ui/public/ai.js)이 붙는다 — 통합 탭 고정 + 연결별 탭. 좁은 화면에서는 본문 아래로 내려간다(ops.css). 메뉴 'AI 연동'(/ops/ai)에서 연결을 관리한다.
2026-10-28 16:50:12 +00:00
* - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs).
2026-10-26 12:15:48 +00:00
*/
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' ;
2026-10-27 05:25:05 +00:00
import * as mailsvc from './mailsvc.mjs' ;
2026-10-26 12:15:48 +00:00
import { MODULES , DEPLOY_ORDER } from './modules/index.mjs' ;
HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
import { releasesData } from './app.mjs' ;
2026-10-26 12:15:48 +00:00
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' , '운영 작업' ],
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
[ 'incidents' , '/ops/incidents' , '장애와 알림' ], [ 'audit' , '/ops/audit' , '감사 기록' ], [ 'recovery' , '/ops/recovery' , '플랫폼 복구' ], [ 'ai' , '/ops/ai' , 'AI 연동' ], [ 'account' , '/ops/account' , '운영자 계정' ],
2026-10-26 12:15:48 +00:00
];
export function registerUi ( app , d ) {
/** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */
function shell ( ctx , { title , nav , body , page , id = '' , scripts = [] }) {
if ( ! ctx . operator ) return ctx . html ( loginPage ( ctx , d ));
2026-10-28 16:50:12 +00:00
const grant = ctx . opsVia === 'grant' ? ops . grantValid ( d , ctx . cookies [ ops . GRANT_COOKIE ], ctx . headers [ 'user-agent' ]) : null ;
2026-10-26 12:15:48 +00:00
const health = core . selfHealth ( d ); const emOpen = d . store . get ( 'SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'' ). n ;
const banner = [];
2026-10-28 16:50:12 +00:00
// [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다.
2026-10-26 12:15:48 +00:00
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 14:31:59 +00:00
// [실제 환경 결함 수정 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>` );
2026-10-26 12:15:48 +00:00
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>
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
<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>
2026-10-26 12:15:48 +00:00
<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>
HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
<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>
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
<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>
2026-10-26 12:15:48 +00:00
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
<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>` );
2026-10-26 12:15:48 +00:00
}
// ---------------- 로그인·최초 설정
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' );
2026-10-27 05:25:05 +00:00
// 메일 서비스: 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' );
2026-10-26 12:15:48 +00:00
relayPage ( '/ops/backup' , 'backup' , '백업과 복원' , 'backup' ); relayPage ( '/ops/relocation' , 'relocation' , '서버 이전 · 소유권 이전' , 'relocation' );
// ---------------- 서버(§28)
HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
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>` }));
2026-10-26 12:15:48 +00:00
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)
HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
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 제외
2026-10-26 12:15:48 +00:00
// ---------------- 작업(§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>` }));
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
// ---------------- 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 ) }));
2026-10-26 12:15:48 +00:00
// ---------------- 운영자 계정(§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 () }));
}
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
/** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */
function aiPage ( d ) {
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
return `<p class="muted">AI 모델을 HAL 에 연결합니다(기본 공급자: Claude · ChatGPT · Gemini · OpenRouter, 그 밖의 OpenAI 호환 API 서비스는 "기타"). OAuth 를 고르면 저장 직후 로그인 창이 자동으로 열리고, 로컬 CLI 는 로그인이 안 되어 있으면 텍스트 셸이 열립니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.</p>
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
<div id="ai-conns"> ${ R . statePanel ( 'loading' , '연결 목록을 불러오는 중' ) } </div>
2026-10-29 01:11:32 +00:00
<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>
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
<h2>연결 추가</h2><form id="ai-conn-form" class="card" autocomplete="off"><input type="hidden" name="id">
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
<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>
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
<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>
2026-10-29 01:11:32 +00:00
<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>
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
<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>
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
<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>
2026-10-29 02:49:46 +00:00
<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>
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
<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>
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
<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>` ;
}
2026-10-26 12:15:48 +00:00
/** 로그인·최초 설정 화면(고객 로그인과 분리, §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 ))] }; };
HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
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 : '배치 없음' }); });
2026-10-26 12:15:48 +00:00
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 ; }
2026-10-27 05:25:05 +00:00
/** 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>` ;
}