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건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -37,7 +37,7 @@
|
||||
## 4. 릴리스·모듈 배포 (`hal_releases_list`, `hal_release_approve`, `hal_module_deploy`, `hal_module_rollback`)
|
||||
- 릴리스 `status` : `registered`(등록만) · `approved`(승인 — 배포 가능). 승인은 `checks.testsPassed === true`·`dirty === false`·`provenanceClean === true` 가 모두 기록된 릴리스만 가능하다(fail-closed).
|
||||
- `mainCommit` : 정본 저장소 main 커밋. `artifacts[모듈].sha256` : artifact 해시. `halDeployedAt` : HAL 자체가 그 버전으로 배포된 시각(CLI 부트스트랩 경로로 바꾸면 null 일 수 있다).
|
||||
- 배포 순서(`deployOrder`): **HAL 먼저** → agent → portal → webmaker → arimail → backup → observability. HAL 이 그 버전으로 실행 중이 아니면 다른 모듈 배포는 `hal_first` 로 거부된다.
|
||||
- 배포 순서(`deployOrder`): agent → portal → webmaker → arimail → backup → hawkeye(HawkEye, 전체 시스템 상황 점검 — 옛 이름 observability). **HAL 자체는 배포 대상이 아니다**(먼저 설치하는 도구, `hal_not_deployable`). HAL 실행 버전이 릴리스보다 오래되면 모듈 배포는 `hal_first` 로 거부된다. 역할별 통합 배포(`server.provision`: webmaker·webhosting·arimail·backup·portal 프로필)와 포털 이전(`portal.relocate`)이 있다. 배포 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 간다.
|
||||
- `module_targets.health` : `ok` · `down` · `unknown`. `lastCheckAt` 이 10분 넘게 오래되면 "정보 지연(stale)"이지 정상이 아니다.
|
||||
|
||||
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
||||
|
||||
+4
-4
@@ -19,7 +19,7 @@ import * as core from '../core.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 { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
||||
import { overviewLocal, changelog } from '../ui.mjs';
|
||||
|
||||
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
||||
@@ -43,7 +43,7 @@ export const TOOLS = [
|
||||
{ name: 'hal_servers_list', risk: 'read', description: '관리 서버 목록(상태·역할·위치·Agent·용량·지표·모듈 목표/실제 버전).', schema: S({ role: str('역할로 거르기(web·mail·backup·control)', { enum: ['web', 'mail', 'backup', 'control'] }) }), run: (d, a) => ({ servers: core.listServers(d, { role: a.role || null }) }) },
|
||||
{ name: 'hal_server_get', risk: 'read', description: '서버 하나의 상세와 최근 작업(최대 50건).', schema: S({ serverId: SERVER_ID }, ['serverId']), run: (d, a) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(jobView) }; } },
|
||||
{ name: 'hal_server_metrics', risk: 'read', description: '서버 지표 표본(cpu·메모리·디스크·inode·네트워크). 기본 최근 24시간, 최대 2000개.', schema: S({ serverId: SERVER_ID, hours: num('조회 범위(시간, 기본 24, 최대 2160)'), limit: num('최대 표본 수(기본 500, 최대 2000)') }, ['serverId']), run: (d, a) => ({ samples: core.metrics(d, a.serverId, { since: new Date(Date.now() - Math.min(2160, a.hours || 24) * 3600000).toISOString(), limit: Math.min(2000, a.limit || 500) }) }) },
|
||||
{ name: 'hal_releases_list', risk: 'read', description: '릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.', schema: S({}), run: (d) => ({ 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') }) },
|
||||
{ name: 'hal_releases_list', risk: 'read', description: '릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.', schema: S({}), run: (d) => ({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }) },
|
||||
{ name: 'hal_jobs_list', risk: 'read', description: 'HAL 작업 목록. status 는 쉼표 구분(queued,running,awaiting_external,needs_user,failed …).', schema: S({ status: str('상태 필터(쉼표 구분)'), kind: str('종류 필터(예: module.deploy)'), limit: num('최대 건수(기본 100, 최대 500)') }), run: (d, a) => ({ jobs: d.jobs.list({ status: a.status ? String(a.status).split(',').map((x) => x.trim()).filter(Boolean) : null, kind: a.kind || null, limit: Math.min(500, a.limit || 100) }).map(jobView) }) },
|
||||
{ name: 'hal_job_get', risk: 'read', description: '작업 하나의 단계·기록·결과·자식 요약·Agent 작업.', schema: S({ jobId: str('작업 id(j_…)') }, ['jobId']), run: (d, a) => { const j = d.jobs.get(a.jobId); if (!j) throw notFound('job_not_found', '작업이 없습니다'); return { job: { ...jobView(j), result: redact(j.result), log: j.log }, children: d.jobs.childrenSummary(j.id), agentJobs: 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) }; } },
|
||||
{ name: 'hal_incidents_list', risk: 'read', description: '사건 목록(열린 것 먼저, 최대 200)과 최근 알림.', schema: S({ openOnly: bool('열린 사건만') }), run: (d, a) => ({ incidents: d.store.all(`SELECT * FROM incidents ${a.openOnly ? 'WHERE resolved_at IS NULL ' : ''}ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200`).map((i) => ({ ...i, notes: parseJson(i.notes_json, []) })) }) },
|
||||
@@ -66,8 +66,8 @@ export const TOOLS = [
|
||||
// ---------------- 고위험(high): 승인 + 재인증 + 3단계 확인
|
||||
{ name: 'hal_server_isolate', risk: 'high', description: '서버를 격리(isolated)한다 — 작업 배정이 멈춘다. 되돌리려면 운영자가 상태를 바꿔야 한다.', schema: S({ serverId: SERVER_ID, reason: str('격리 사유') }, ['serverId', 'reason']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { if (!core.getServer(d, a.serverId)) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, core.updateServer(d, a.serverId, { status: 'isolated', isolated_reason: a.reason }, actor.operatorId)) }; } },
|
||||
{ name: 'hal_server_delete', risk: 'high', description: '서버를 삭제하고 Agent 를 해지한다. 서비스가 배치된 서버는 force 없이는 거부된다(먼저 이전).', schema: S({ serverId: SERVER_ID, force: bool('배치된 서비스가 있어도 삭제') }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !a.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, actor.operatorId); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); return { deleted: s.id }; } },
|
||||
{ name: 'hal_module_deploy', risk: 'high', description: '승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.', schema: S({ module: str('모듈 이름', { enum: Object.keys(MODULES) }), version: str('릴리스 버전'), serverIds: arr('대상 서버 id 목록'), nonce: str('멱등 구분값(같은 배포를 다시 넣을 때)') }, ['module', 'version', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => { const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: a.module, version: a.version, serverIds: a.serverIds || [] }, idempotencyKey: `module.deploy:${a.module}:${a.version}:${(a.serverIds || []).slice().sort().join(',')}:${a.nonce || ''}`, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} ${a.version} 배포` }); return { job: jobView(r.job), created: r.created }; } },
|
||||
{ name: 'hal_module_rollback', risk: 'high', description: '모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.', schema: S({ module: str('모듈 이름', { enum: Object.keys(MODULES) }), toVersion: str('되돌릴 버전'), serverIds: arr('대상 서버 id 목록') }, ['module', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.rollback', subject: { module: a.module, toVersion: a.toVersion || null, serverIds: a.serverIds || [] }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 되돌리기` }).job) }) },
|
||||
{ name: 'hal_module_deploy', risk: 'high', description: '승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.', schema: S({ module: str('모듈 이름(HAL 은 배포 대상 아님)', { enum: DEPLOYABLE_MODULES }), version: str('릴리스 버전'), serverIds: arr('대상 서버 id 목록'), nonce: str('멱등 구분값(같은 배포를 다시 넣을 때)') }, ['module', 'version', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => { const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: a.module, version: a.version, serverIds: a.serverIds || [] }, idempotencyKey: `module.deploy:${a.module}:${a.version}:${(a.serverIds || []).slice().sort().join(',')}:${a.nonce || ''}`, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} ${a.version} 배포` }); return { job: jobView(r.job), created: r.created }; } },
|
||||
{ name: 'hal_module_rollback', risk: 'high', description: '모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.', schema: S({ module: str('모듈 이름', { enum: DEPLOYABLE_MODULES }), toVersion: str('되돌릴 버전'), serverIds: arr('대상 서버 id 목록') }, ['module', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.rollback', subject: { module: a.module, toVersion: a.toVersion || null, serverIds: a.serverIds || [] }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 되돌리기` }).job) }) },
|
||||
{ name: 'hal_module_reconcile', risk: 'high', description: '목표 버전과 실제 버전이 다른 대상만 다시 설치하는 작업(module.reconcile)을 큐에 넣는다.', schema: S({ module: str('모듈 이름(생략 시 전체)', { enum: Object.keys(MODULES) }) }), identifiers: (a) => [a.module || 'all'], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: a.module || null }, createdBy: actor.operatorId, actorType: 'operator', label: '목표·실제 상태 맞추기' }).job) }) },
|
||||
{ name: 'hal_release_approve', risk: 'high', description: '등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).', schema: S({ version: str('릴리스 버전') }, ['version']), identifiers: (a) => [a.version], run: (d, a, actor) => ({ release: core.approveRelease(d, a.version, actor.operatorId) }) },
|
||||
{ name: 'hal_job_revert', risk: 'high', description: '끝난 작업을 되돌린다(revert 를 지원하는 종류만).', schema: S({ jobId: str('작업 id') }, ['jobId']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs.revert(a.jobId, actor.operatorId)) }) },
|
||||
|
||||
+24
-6
@@ -26,7 +26,7 @@ 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 { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, MODULE_GUIDES, MODULE_LINKS, SERVER_PROFILES, descriptor, canonicalModuleName } from './modules/index.mjs';
|
||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||
import { registerAiRoutes } from './ai/routes.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||
|
||||
@@ -127,7 +127,8 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/servers', wrap(async (ctx) => { const b = await ctx.body(); const s = core.createServer(d, { ...b, by: ctx.operator.id }); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
app.get('/ops/api/servers/:id', wrap((ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); return { ok: true, server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe), agentJobs: d.store.all('SELECT id, kind, status, progress, created_at, ended_at, error FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '') }; }));
|
||||
app.get('/ops/api/servers/:id/metrics', (ctx) => ({ ok: true, samples: core.metrics(d, ctx.params.id, { since: ctx.query.since, until: ctx.query.until, limit: Number(ctx.query.limit || 5000) }) }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); if (b.slots !== undefined) { if (b.slots && typeof b.slots === 'object') { b.slots_json = toJson(Object.fromEntries(Object.entries(b.slots).filter(([k, v]) => ['sites', 'mailServices', 'backups', 'webmaker'].includes(k) && Number.isFinite(Number(v))).map(([k, v]) => [k, Number(v)]))); } delete b.slots; } // [0.3.17] 슬롯
|
||||
return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
|
||||
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
@@ -139,9 +140,19 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; }));
|
||||
app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; }));
|
||||
// 릴리스·모듈 배포(§32)
|
||||
app.get('/ops/api/releases', () => ({ ok: true, 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') }));
|
||||
app.get('/ops/api/releases', () => releasesData(d)); // [0.3.17] 모듈별 최신 릴리스·안내·프로필 포함(HAL 제외)
|
||||
// [0.3.17] 역할별 통합 배포
|
||||
app.get('/ops/api/provision/profiles', () => ({ ok: true, profiles: Object.values(SERVER_PROFILES), guides: MODULE_GUIDES, links: MODULE_LINKS }));
|
||||
app.post('/ops/api/provision', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const prof = SERVER_PROFILES[b.profile]; if (!prof) throw new HttpError(400, 'bad_profile', `모르는 프로필: ${String(b.profile).slice(0, 30)}`); const srv = core.getServer(d, b.serverId); if (!srv) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); if (srv.status !== 'ready' || !srv.agent_id) throw new HttpError(409, 'server_not_ready', `서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`);
|
||||
const rel = core.getRelease(d, b.version); if (!rel || rel.status !== 'approved') throw new HttpError(400, 'release_not_approved', `승인된 릴리스가 아닙니다: ${b.version}`); if (prof.singleton) { const owner = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1 AND server_id<>? LIMIT 1', srv.id); if (owner) throw new HttpError(409, 'portal_exists', `포털이 이미 다른 서버(${(core.getServer(d, owner.server_id) || {}).name || owner.server_id})에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`); }
|
||||
const r = d.jobs.enqueue({ kind: 'server.provision', subject: { serverId: srv.id, profile: prof.id, version: b.version }, idempotencyKey: `provision:${srv.id}:${prof.id}:${b.version}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'server', subjectId: srv.id, label: `${prof.label} 통합 배포 ${b.version} — ${srv.name}` }); return { ok: true, job: describe(r.job), created: r.created, modules: prof.modules }; }));
|
||||
app.post('/ops/api/portal/relocate', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, [b.fromServerId, b.toServerId]); const from = core.getServer(d, b.fromServerId); const to = core.getServer(d, b.toServerId); if (!from || !to) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); const r = d.jobs.enqueue({ kind: 'portal.relocate', subject: { fromServerId: from.id, toServerId: to.id, version: b.version }, idempotencyKey: `portal.relocate:${from.id}:${to.id}:${b.version}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: 'portal', label: `포털 이전 ${from.name} → ${to.name} (${b.version})` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: ctx.params.name, version: b.version, serverIds: b.serverIds || [] }, idempotencyKey: b.idempotencyKey || `module.deploy:${ctx.params.name}:${b.version}:${(b.serverIds || []).slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const module = canonicalModuleName(ctx.params.name); descriptor(module);
|
||||
if (module === 'hal') throw new HttpError(400, 'hal_not_deployable', 'HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 먼저 설치하는 도구이며 별도 절차로 갱신합니다'); // [0.3.17]
|
||||
// [0.3.17 배포 점검] 대상을 비우면 역할에 맞는 준비된 서버 전체로 푼다. 하나도 없으면 작업을 만들지 않고 바로 알린다(옛 동작: 작업이 1초 만에 no_targets 로 실패해 원인이 보이지 않았다)
|
||||
let serverIds = Array.isArray(b.serverIds) ? b.serverIds.filter(Boolean) : []; if (!serverIds.length) { serverIds = core.defaultTargets(d, module); if (!serverIds.length) throw new HttpError(400, 'no_targets', `대상 서버가 없습니다 — ${module} 은(는) 역할 ${descriptor(module).roles.join('·')} 의 준비된(Agent 연결·승인) 서버가 필요합니다`); }
|
||||
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version: b.version, serverIds }, idempotencyKey: b.idempotencyKey || `module.deploy:${module}:${b.version}:${serverIds.slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created, targets: serverIds }; }));
|
||||
app.post('/ops/api/modules/:name/rollback', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, b.serverIds || []); const r = d.jobs.enqueue({ kind: 'module.rollback', subject: { module: ctx.params.name, toVersion: b.toVersion || null, serverIds: b.serverIds || [] }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 되돌리기` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/:name/health', wrap(async (ctx) => { const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.health', subject: { module: ctx.params.name, serverIds: b.serverIds || null }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 건강 확인` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/reconcile', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: b.module || null }, createdBy: ctx.operator.id, actorType: 'operator', label: '목표·실제 상태 맞추기' }); return { ok: true, job: describe(r.job) }; }));
|
||||
@@ -181,13 +192,20 @@ export function buildApp(d) {
|
||||
return app;
|
||||
}
|
||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
||||
export function releasesData(d) {
|
||||
const releases = core.listReleases(d); const approved = releases.filter((r) => r.status === 'approved'); const latest = {};
|
||||
for (const m of DEPLOYABLE_MODULES) { const r = approved.find((x) => MODULES[m].layout === 'agent-feature' ? true : !!x.artifacts[m]); latest[m] = r ? r.version : null; }
|
||||
const servers = Object.fromEntries(core.listServers(d).map((s) => [s.id, { name: s.name, role: s.role, status: s.status, profile: s.profile }]));
|
||||
return { ok: true, releases, running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, latest, guides: MODULE_GUIDES, links: MODULE_LINKS, profiles: Object.values(SERVER_PROFILES), servers, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') };
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
function overview(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: '배치 없음' }); });
|
||||
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: '배치 없음' }); }); // [0.3.17] observability → hawkeye
|
||||
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() };
|
||||
@@ -195,7 +213,7 @@ function overview(d) {
|
||||
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
|
||||
function publicStatus(d) {
|
||||
const o = overview(d);
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', observability: '모니터링' };
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', hawkeye: 'HawkEye(모니터링)' };
|
||||
return { ok: true, generatedAt: o.generatedAt, components: o.components.map((c) => ({ name: label[c.name] || c.name, status: c.status === 'ok' ? 'ok' : c.status === 'down' ? 'down' : 'unknown', label: c.status === 'ok' ? '정상' : c.status === 'down' ? '장애' : '확인 중' })), incidents: o.incidents.filter((i) => d.store.get('SELECT public FROM incidents WHERE id=?', i.id)?.public).map((i) => ({ severity: i.severity, title: i.title, startedAt: i.started_at, note: d.store.get('SELECT public_note FROM incidents WHERE id=?', i.id)?.public_note || '' })), maintenance: d.store.all('SELECT name, maintenance_note FROM servers WHERE status=\'maintenance\'').map((s) => ({ note: s.maintenance_note || '계획된 유지보수' })) };
|
||||
}
|
||||
function statusPage(d) { const s = publicStatus(d); return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb 서비스 상태</title><style>body{font-family:-apple-system,"Noto Sans KR",sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1f2933}li{padding:8px 0;border-bottom:1px solid #eee}.ok{color:#1a7f4b}.down{color:#b3261e}.unknown{color:#b26a00}</style></head><body><h1>Ariweb 서비스 상태</h1><p>갱신 ${esc(s.generatedAt)}</p><ul>${s.components.map((c) => `<li><b class="${c.status}">${c.status === 'ok' ? '●' : c.status === 'down' ? '✘' : '◔'} ${esc(c.label)}</b> — ${esc(c.name)}</li>`).join('')}</ul><h2>진행 중인 사건</h2>${s.incidents.length ? `<ul>${s.incidents.map((i) => `<li><b>${esc(i.title)}</b> (${esc(i.startedAt)})<br>${esc(i.note)}</li>`).join('')}</ul>` : '<p>진행 중인 사건이 없습니다.</p>'}<h2>계획된 유지보수</h2>${s.maintenance.length ? `<ul>${s.maintenance.map((m) => `<li>${esc(m.note)}</li>`).join('')}</ul>` : '<p>예정된 유지보수가 없습니다.</p>'}</body></html>`; }
|
||||
|
||||
+16
-4
@@ -11,6 +11,7 @@ import { notify } from '../lib/notify.mjs';
|
||||
import { redact, safeError } from '../lib/redact.mjs';
|
||||
import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { descriptor } from './modules/index.mjs';
|
||||
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
@@ -60,7 +61,7 @@ export function createServer(d, { name, role, location = '', by = null }) {
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.create', subjectType: 'server', subjectId: id, detail: { name, role, location } });
|
||||
return getServer(d, id);
|
||||
}
|
||||
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}) } : null; }
|
||||
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}), slots: parseJson(s.slots_json, {}) || {} } : null; }
|
||||
export function listServers(d, { role = null } = {}) { return d.store.all(`SELECT * FROM servers ${role ? 'WHERE role=?' : ''} ORDER BY created_at`, ...(role ? [role] : [])).map((s) => publicServer(d, s)); }
|
||||
/** 화면·포털용 서버 요약(비밀 없음). offline 판정은 last_seen 기준(§28.1 heartbeat). */
|
||||
export function publicServer(d, s) {
|
||||
@@ -68,14 +69,17 @@ export function publicServer(d, s) {
|
||||
const status = s.status === 'pending' ? 'pending' : offline ? 'offline' : s.status;
|
||||
const mods = d.store.all('SELECT module, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets WHERE server_id=?', s.id);
|
||||
const capacity = parseJson(s.capacity_json, {}) || {}; const metrics = parseJson(s.metrics_json, null);
|
||||
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms,
|
||||
// [0.3.17] 프로필·슬롯: 슬롯은 서버가 맡을 수 있는 수, 여유 = 슬롯 − 사용(사이트는 capacity.sitesHosted, 메일 서비스는 등록부 수). 슬롯이 없으면(null) 제한 없음.
|
||||
const slots = parseJson(s.slots_json, {}) || {}; const mailUsed = (() => { try { return d.store.get('SELECT COUNT(*) AS n FROM mail_services WHERE server_id=? AND status=\'active\'', s.id).n; } catch (_) { return 0; } })();
|
||||
const slotsFree = { sites: slots.sites != null ? Math.max(0, slots.sites - (capacity.sitesHosted || 0)) : null, mailServices: slots.mailServices != null ? Math.max(0, slots.mailServices - mailUsed) : null, backups: slots.backups ?? null, webmaker: slots.webmaker ?? null };
|
||||
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms, profile: s.profile || null, slots, slotsFree,
|
||||
health: status === 'offline' ? 'down' : mods.some((m) => m.health === 'down') ? 'partial_outage' : mods.some((m) => m.health === 'degraded') ? 'degraded' : status === 'ready' ? 'ok' : 'unknown',
|
||||
metrics, capacity: { diskFreeBytes: capacity.diskFreeBytes || 0, diskTotalBytes: capacity.diskTotalBytes || 0, memFreeBytes: capacity.memFreeBytes || 0, cpuLoad: capacity.cpuLoad || 0, sitesHosted: capacity.sitesHosted || 0, reservedBytes: capacity.reservedBytes || 0, safetyMarginBytes: capacity.safetyMarginBytes || Math.round((capacity.diskTotalBytes || 0) * 0.1), exhaustEta: capacity.exhaustEta || null },
|
||||
modules: Object.fromEntries(mods.map((m) => [m.module, { target: m.target_version, actual: m.actual_version, drift: !!(m.target_version && m.actual_version && m.target_version !== m.actual_version), health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error }])),
|
||||
maintenanceNote: s.maintenance_note, isolatedReason: s.isolated_reason, facts: parseJson(s.facts_json, {}) };
|
||||
}
|
||||
export function updateServer(d, id, patch, by = null) {
|
||||
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role'];
|
||||
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role', 'profile', 'slots_json']; // [0.3.17] 프로필·슬롯
|
||||
const keys = Object.keys(patch).filter((k) => allowed.includes(k)); if (!keys.length) return getServer(d, id);
|
||||
d.store.run(`UPDATE servers SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...keys.map((k) => patch[k]), nowIso(), id);
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.update', subjectType: 'server', subjectId: id, detail: patch });
|
||||
@@ -89,12 +93,20 @@ export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) t
|
||||
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
|
||||
*/
|
||||
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
|
||||
// [0.3.17] 슬롯: 역할별 슬롯(web→sites, mail→mailServices, backup→backups)이 정해진 서버는 여유가 있어야 한다
|
||||
const slotKey = { web: 'sites', mail: 'mailServices', backup: 'backups' }[role]; const slotOk = (s) => !slotKey || s.slotsFree[slotKey] == null || s.slotsFree[slotKey] > 0;
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && slotOk(s) && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
|
||||
let cands = listServers(d, { role }).filter(fits);
|
||||
if (!cands.length && ['web', 'backup'].includes(role)) cands = listServers(d, { role: 'control' }).filter((s) => fits(s) && (s.facts?.nginx !== false));
|
||||
cands.sort((a, b) => (b.capacity.diskFreeBytes - b.capacity.reservedBytes) - (a.capacity.diskFreeBytes - a.capacity.reservedBytes));
|
||||
return cands[0] ? { serverId: cands[0].id, name: cands[0].name } : null;
|
||||
}
|
||||
/** [0.3.17] 배포 대상 서버를 비웠을 때의 기본값: 모듈 역할에 맞고 Agent 가 있는 ready 서버 전체(agent 모듈은 역할 무관). */
|
||||
export function defaultTargets(d, module) {
|
||||
const desc = descriptor(module); return listServers(d).filter((s) => s.status === 'ready' && s.agentId && (module === 'agent' || desc.roles.includes(s.role))).map((s) => s.id);
|
||||
}
|
||||
/** [0.3.17] 버전 비교(숫자 점 구분). a >= b 이면 true. 비교 불가(없음)면 false. */
|
||||
export function versionGte(a, b) { if (!a || !b) return false; const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0); const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i] || 0; const y = pb[i] || 0; if (x !== y) return x > y; } return true; }
|
||||
/** 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다. */
|
||||
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000 }) {
|
||||
if (kind === 'managed' && serverId && !getServer(d, serverId)) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
|
||||
+43
-2
@@ -5,6 +5,7 @@
|
||||
* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
|
||||
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
|
||||
* 2) 운영자 작업(§32·§29): module.deploy / module.rollback / module.health / module.reconcile — 대상 서버마다 자식 작업을 만들고 대상별 결과를 요약한다. 일부 실패는 'partial' 이며 성공으로 보이지 않는다.
|
||||
* 3) [0.3.17] server.provision(역할별 통합 배포: 프로필의 모듈을 한 서버에 순서대로) / portal.relocate(포털 이전: 새 서버 설치 → 운영자 전환 확인 → 옛 서버 비활성). HAL 자체는 배포 대상이 아니다.
|
||||
*
|
||||
* 대상(target) 해석: 서버 id(srv_…) → 그 서버의 Agent, Agent id(ag_…) → 그 Agent(외부 Agent 포함).
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as core from './core.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES } from './modules/index.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES, SERVER_PROFILES } from './modules/index.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -115,12 +116,14 @@ export function registerKinds(engine, d) {
|
||||
const unitTemplate = (name) => { const f = name ? join(ROOT, 'deploy', 'units', name) : null; return f && existsSync(f) ? readFileSync(f, 'utf8') : null; };
|
||||
/** 배포 전제: 릴리스 승인, artifact 존재, HAL 먼저(다른 모듈은 HAL 이 그 릴리스로 실행 중이어야), 부트스트랩 완료 */
|
||||
function precheckModule(module, version) {
|
||||
if (module === 'hal') throw new JobError('hal_not_deployable', 'HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 Suite 를 설치하기 전에 먼저 설치하는 도구이며 HAL 서버에서 다시 배포하지 않습니다(별도 절차로 갱신)'); // [0.3.17]
|
||||
const rel = core.getRelease(d, version); if (!rel) throw new JobError('release_not_found', `릴리스 ${version} 이(가) 없습니다`);
|
||||
if (rel.status !== 'approved') throw new JobError('release_not_approved', `릴리스 ${version} 은(는) 승인되지 않았습니다`);
|
||||
if (ARTIFACT_MODULES.includes(module) && !core.artifactPath(d, version, module)) throw new JobError('artifact_missing', `릴리스 ${version} 의 ${module} artifact 가 없습니다`);
|
||||
const health = core.selfHealth(d); if (!health.ok) throw new JobError('hal_unhealthy', 'HAL 자체 건강 확인에 실패해 배포하지 않습니다');
|
||||
if (!core.bootstrapComplete(d)) throw new JobError('bootstrap_incomplete', '부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다');
|
||||
if (module !== 'hal' && health.bootstrap.halRunningVersion !== version) throw new JobError('hal_first', `HAL 이 아직 ${version} 으로 실행되고 있지 않습니다(현재 ${health.bootstrap.halRunningVersion}). HAL 을 먼저 배포하세요`);
|
||||
// [0.3.17] HAL 먼저 규칙: HAL 은 별도 설치되므로 "같은 버전" 이 아니라 "릴리스보다 오래되지 않음" 을 요구한다(HAL 0.3.17 이 0.3.10 모듈을 배포하는 것은 허용, HAL 0.3.9 가 0.3.10 을 배포하는 것은 거부)
|
||||
if (!core.versionGte(health.bootstrap.halRunningVersion, version)) throw new JobError('hal_first', `HAL(현재 ${health.bootstrap.halRunningVersion || '확인 전'})이 릴리스 ${version} 보다 오래되었습니다 — HAL 을 먼저 갱신하세요`);
|
||||
return rel;
|
||||
}
|
||||
engine.register('module.deploy.target', { title: '모듈 배포(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설치 지시' }, { id: 'await', title: '설치·재시작' }, { id: 'verify', title: '실제 버전·건강 확인' }],
|
||||
@@ -129,6 +132,11 @@ export function registerKinds(engine, d) {
|
||||
const art = core.artifactPath(d, version, module);
|
||||
d.store.run('INSERT INTO module_targets(module, server_id, target_version, last_job_id, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET target_version=excluded.target_version, last_job_id=excluded.last_job_id, updated_at=excluded.updated_at', module, serverId, version, job.id, nowIso());
|
||||
const facts = srv.facts || {}; const unitName = (facts.units && facts.units[module]) || desc.unit;
|
||||
if (desc.layout === 'agent-feature') { // [0.3.17] Agent 안의 기능(backup·hawkeye): 설치할 artifact 가 없다 — Agent 에 기능 확인(module.health)을 시켜 켜져 있으면 그 릴리스 버전으로 기록한다
|
||||
const dispF = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: 'module.health', title: 'Agent 기능 확인', payload: { module, descriptor: desc, unit: null } });
|
||||
const rf = await ctx.step('await', () => core.awaitAgentJob(d, ctx, dispF.agentJobId));
|
||||
return ctx.step('verify', () => { const ok = rf && rf.health?.ok !== false; d.store.run('UPDATE module_targets SET actual_version=?, health=?, active=1, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?', ok ? version : null, ok ? 'ok' : 'down', nowIso(), ok ? null : `Agent 기능 확인 실패`, nowIso(), module, serverId); if (!ok) throw new JobError('feature_unhealthy', `${srv.name}: Agent 기능 ${module} 확인에 실패했습니다`, { detail: rf }); return { actualVersion: version, health: rf.health || { ok: true }, feature: true }; });
|
||||
}
|
||||
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: action === 'rollback' ? 'module.rollback' : 'module.install', title: 'Agent 에 설치 지시', payload: { module, version, action, artifactUrl: art ? `/agent/v1/releases/${version}/artifact?module=${module}` : null, sha256: art?.sha256, size: art?.size, descriptor: { ...desc, unit: unitName }, unitTemplate: unitTemplate(desc.unitTemplate), appDir: (facts.appDirs && facts.appDirs[module]) || (desc.baseDir || `/srv/ariweb/${module}`), toVersion: job.subject.toVersion || null } });
|
||||
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
|
||||
const v = await ctx.step('verify', () => {
|
||||
@@ -162,6 +170,39 @@ export function registerKinds(engine, d) {
|
||||
engine.register('module.reconcile', { title: '목표·실제 상태 맞추기', steps: [{ id: 'diff', title: '차이 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
|
||||
async run(job, ctx) { const diff = await ctx.step('diff', () => 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)' + (job.subject.module ? ' AND module=?' : ''), ...(job.subject.module ? [job.subject.module] : []))); if (!diff.length) return { drift: [], overall: 'done' }; const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds: diff.map((x) => x.server_id), mk: (serverId) => { const x = diff.find((y) => y.server_id === serverId); return { module: x.module, version: x.target_version, serverId, action: 'install' }; } })); return { drift: diff, ...sum }; } });
|
||||
|
||||
// ---------------- [0.3.17] 역할별 통합 배포(server.provision): 프로필의 모듈을 순서대로 같은 서버에 배포하고 서버 역할·프로필·슬롯을 기록한다
|
||||
async function waitChild(ctx, id, maxMs = d.config.fanoutTimeoutMs || 12 * 3600 * 1000) { const t0 = Date.now(); while (true) { const j = engine.get(id); if (!j) throw new JobError('child_missing', '자식 작업이 없습니다'); if (['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) return j; if (Date.now() - t0 > maxMs) throw new JobError('child_timeout', '자식 작업이 제한 시간 안에 끝나지 않았습니다'); ctx.checkCancel(); await new Promise((r) => setTimeout(r, d.config.agentPollMs || 1000)); } }
|
||||
/** 포털이 다른 서버에 이미 설치되어 있으면 그 서버 id(싱글턴 검사) */
|
||||
function portalOwner(exceptServerId = null) { const r = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1' + (exceptServerId ? ' AND server_id<>?' : '') + ' ORDER BY updated_at DESC LIMIT 1', ...(exceptServerId ? [exceptServerId] : [])); return r ? r.server_id : null; }
|
||||
engine.register('server.provision', { title: '역할별 통합 배포', steps: [{ id: 'precheck', title: '서버·프로필·릴리스 확인' }, { id: 'profile', title: '역할·프로필·슬롯 기록' }, { id: 'deploy', title: '모듈 순서대로 배포' }],
|
||||
async run(job, ctx) {
|
||||
const { serverId, profile, version } = job.subject; const prof = SERVER_PROFILES[profile]; if (!prof) throw new JobError('bad_profile', `모르는 프로필: ${profile}`);
|
||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
await ctx.step('precheck', () => { if (srv.status !== 'ready' || !srv.agent_id) throw new JobError('server_not_ready', `서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`); for (const m of prof.modules) precheckModule(m, version); if (prof.singleton && portalOwner(serverId)) throw new JobError('portal_exists', `포털이 이미 다른 서버에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`); return { modules: prof.modules, role: prof.role }; });
|
||||
await ctx.step('profile', () => { core.updateServer(d, serverId, { role: prof.role, profile: prof.id, slots_json: toJson(prof.slots || {}) }, job.created_by); return { role: prof.role, profile: prof.id, slots: prof.slots }; });
|
||||
const results = await ctx.step('deploy', async () => {
|
||||
const out = []; let i = 0;
|
||||
for (const m of prof.modules) {
|
||||
const child = engine.enqueue({ kind: 'module.deploy.target', subject: { module: m, version, serverId, action: 'install' }, parentId: job.id, groupId: null, subjectType: 'server', subjectId: serverId, idempotencyKey: `${job.id}:${m}`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — ${m}` }).job;
|
||||
ctx.log(`${m} 배포 시작(${child.id})`); const r = await waitChild(ctx, child.id); out.push({ module: m, jobId: child.id, status: r.status, error: r.error || null }); ctx.progress(Math.round(100 * (++i) / prof.modules.length));
|
||||
if (r.status !== 'done') throw new JobError('provision_failed', `${m} 배포가 ${r.status} 로 끝났습니다: ${r.error || ''} — 이후 모듈은 배포하지 않았습니다`, { detail: { results: out } });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.provision', subjectType: 'server', subjectId: serverId, detail: { profile: prof.id, version, modules: prof.modules } });
|
||||
return { profile: prof.id, role: prof.role, version, modules: results };
|
||||
} });
|
||||
/** [0.3.17] 포털 이전: 새 서버에 포털 설치 → 운영자가 데이터·DNS 전환을 마치고 재개 → 옛 서버 포털을 비활성으로 */
|
||||
engine.register('portal.relocate', { title: '포털 이전', steps: [{ id: 'precheck', title: '현재 포털·대상 서버 확인' }, { id: 'install', title: '새 서버에 포털 설치' }, { id: 'handover', title: '데이터·DNS 전환(운영자 확인)' }, { id: 'switch', title: '옛 포털 비활성' }],
|
||||
async run(job, ctx) {
|
||||
const { fromServerId, toServerId, version } = job.subject; const to = core.getServer(d, toServerId); const from = core.getServer(d, fromServerId);
|
||||
await ctx.step('precheck', () => { if (!to || !from) throw new JobError('server_not_found', '서버가 없습니다'); if (to.status !== 'ready' || !to.agent_id) throw new JobError('server_not_ready', `대상 서버 ${to.name} 이(가) 준비 상태가 아닙니다`); if (portalOwner() !== fromServerId) throw new JobError('portal_not_here', `서버 ${from.name} 에 활성 포털이 없습니다`); precheckModule('portal', version); return { from: from.name, to: to.name }; });
|
||||
await ctx.step('install', async () => { const child = engine.enqueue({ kind: 'module.deploy.target', subject: { module: 'portal', version, serverId: toServerId, action: 'install' }, parentId: job.id, subjectType: 'server', subjectId: toServerId, idempotencyKey: `${job.id}:portal`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — 새 서버 설치` }).job; const r = await waitChild(ctx, child.id); if (r.status !== 'done') throw new JobError('install_failed', `새 서버 포털 설치 실패: ${r.error || r.status}`); return { jobId: child.id }; });
|
||||
const hs = (job.steps || []).find((x) => x.id === 'handover');
|
||||
await ctx.step('handover', () => { if (hs && hs.state === 'needs_user') return { confirmedBy: 'operator', at: nowIso() }; return ctx.needsUser(`새 서버(${to.name})에 포털이 설치되었습니다. 포털 데이터(DB·업로드·Vault 연동 키) 이전과 nginx/DNS(my.ariweb.net) 전환을 마친 뒤 "재개"를 누르세요 — 재개하면 옛 서버(${from.name})의 포털을 비활성으로 표시합니다.`, [{ id: 'resume', label: '전환 완료 — 재개' }]); });
|
||||
await ctx.step('switch', () => { d.store.run('UPDATE module_targets SET active=0, health=\'unknown\', last_error=?, updated_at=? WHERE module=\'portal\' AND server_id=?', `포털 이전으로 비활성(→ ${to.name})`, nowIso(), fromServerId); d.store.run('UPDATE module_targets SET active=1, updated_at=? WHERE module=\'portal\' AND server_id=?', nowIso(), toServerId); audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.portal.relocate', subjectType: 'module', subjectId: 'portal', detail: { from: fromServerId, to: toServerId, version } }); return { from: fromServerId, to: toServerId }; });
|
||||
return { from: fromServerId, to: toServerId, version };
|
||||
} });
|
||||
// ---------------- 포털 이벤트: 포털이 만든 작업(subject._portal)의 상태 변화를 outbox 에 넣는다
|
||||
engine.onChange((job) => { if (job.subject && job.subject._portal) core.queuePortalEvent(d, { halJobId: job.id, kind: job.kind, status: job.status, steps: job.steps, progress: job.progress, result: job.result, error: job.error, errorCode: job.error_code, userMessage: job.user_message, updatedAt: job.updated_at, subjectType: job.subject_type, subjectId: job.subject_id }); });
|
||||
}
|
||||
|
||||
+56
-14
@@ -1,41 +1,83 @@
|
||||
/**
|
||||
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32).
|
||||
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32) + [0.3.17] 역할별 서버 프로필·모듈 안내.
|
||||
*
|
||||
* HAL 이 어떤 모듈을 어떤 서버에 어떻게 설치·갱신·건강 확인하는지를 데이터로 둔다. Agent 는 이 기술서를 작업 payload 로 받아 그대로 수행하므로
|
||||
* Agent 바이너리는 모듈별 지식을 갖지 않는다(새 모듈을 더할 때 HAL 만 바꾸면 된다).
|
||||
*
|
||||
* 기술서 모양
|
||||
* name 모듈 이름(릴리스 artifact 이름과 같다: <name>-<version>.tar.gz)
|
||||
* label 화면 표시 이름
|
||||
* roles 설치할 수 있는 서버 역할
|
||||
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다
|
||||
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다 / 'agent-feature'(Agent 안의 기능, artifact 없음)
|
||||
* unit systemd 유닛 이름(도입 모듈은 서버에 이미 있는 이름을 쓸 수 있어 server.facts.units 로 덮어쓴다)
|
||||
* unitTemplate deploy/units/<file>.tmpl — 없으면 기존 유닛을 그대로 두고 WorkingDirectory 만 current 심링크가 가리키게 한다
|
||||
* health { url, expect } 내부 건강 확인(127.0.0.1)
|
||||
* version 실제 실행 버전을 읽는 방법: { file:'package.json' } (current/package.json 의 version) 또는 { http:'/api/health', field:'version' }
|
||||
* preStop/postStart Agent 가 실행할 추가 절차 이름(agent/jobs/module.mjs 가 아는 것만)
|
||||
* dataDirs 절대 건드리지 않는 데이터 경로(검증·백업 대상 안내용)
|
||||
*
|
||||
* [0.3.17] 이름 변경: observability → hawkeye(HawkEye, 전체 시스템 상황 점검). 옛 이름은 descriptor() 별칭으로 받고 DB 행은 hal_0008_hawkeye 마이그레이션이 바꾼다.
|
||||
* [0.3.17] HAL 은 배포 대상이 아니다: HAL 은 Suite 전체를 설치하기 전에 먼저 설치하는 도구이며 HAL 서버에서 HAL 을 다시 배포하지 않는다(이중화·분산 미구성). DEPLOY_ORDER·DEPLOYABLE_MODULES 에서 뺀다.
|
||||
*/
|
||||
export const MODULES = {
|
||||
hal: { name: 'hal', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
|
||||
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true },
|
||||
portal: { name: 'portal', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
|
||||
hal: { name: 'hal', label: 'HAL', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
|
||||
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true, deployable: false },
|
||||
portal: { name: 'portal', label: '고객 포털', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
|
||||
health: { url: 'http://127.0.0.1:8910/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8910/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/portal/data'] },
|
||||
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
agent: { name: 'agent', label: 'Node Agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true },
|
||||
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.8.0 — 0.2.0 에서 직접 편집(시각 편집기) 포함; 0.1.1 은 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
|
||||
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
|
||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.8.0 은 upstreamVersion).
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
webmaker: { name: 'webmaker', label: 'Webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] },
|
||||
// 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다.
|
||||
// 실행 계정이 Agent 와 다르면(ariweb.net 호스트의 arihakdang) Agent 가 deploy/module-as-user.sh 로 그 계정이 파일 작업을 하게 한다(실제 환경 결함 수정). 마이그레이션은 유닛의 EnvironmentFile(DB_FILE)을 같은 계정으로 읽어 실행한다.
|
||||
arimail: { name: 'arimail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
|
||||
arimail: { name: 'arimail', label: 'AriMail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
|
||||
health: { url: 'http://127.0.0.1:8790/api/health', expect: 'ok' }, version: { file: 'package.json' }, preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], dataDirs: ['<appDir>/data'], copyPaths: ['server', 'web', 'lang', 'migrations', 'package.json', 'haraka'] },
|
||||
backup: { name: 'backup', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
|
||||
observability: { name: 'observability', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [] },
|
||||
backup: { name: 'backup', label: '백업', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
|
||||
hawkeye: { name: 'hawkeye', label: 'HawkEye', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [], legacyNames: ['observability'] },
|
||||
};
|
||||
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다) */
|
||||
/** 옛 이름 → 새 이름(요청·설정·DB 에 남은 observability 를 받아 준다) */
|
||||
export const LEGACY_MODULE_NAMES = { observability: 'hawkeye' };
|
||||
export const canonicalModuleName = (name) => LEGACY_MODULE_NAMES[name] || name;
|
||||
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다). HAL 도 artifact 는 있지만 배포 대상은 아니다. */
|
||||
export const ARTIFACT_MODULES = Object.values(MODULES).filter((m) => m.layout === 'releases').map((m) => m.name);
|
||||
/** HAL 이 가장 먼저이고, 그다음 agent → portal → 도입 모듈 순으로 배포한다(§32.2) */
|
||||
export const DEPLOY_ORDER = ['hal', 'agent', 'portal', 'webmaker', 'arimail', 'backup', 'observability'];
|
||||
export function descriptor(name) { const d = MODULES[name]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
|
||||
/** [0.3.17] 배포 순서(HAL 제외): agent 가 가장 먼저(다른 모듈 설치를 수행하는 주체) → portal → 도입 모듈 → 백업 → HawkEye */
|
||||
export const DEPLOY_ORDER = ['agent', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'];
|
||||
/** [0.3.17] 릴리스 화면·배포 API 가 다루는 모듈(HAL 제외) */
|
||||
export const DEPLOYABLE_MODULES = DEPLOY_ORDER.slice();
|
||||
export function descriptor(name) { const d = MODULES[canonicalModuleName(name)]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
|
||||
|
||||
/**
|
||||
* [0.3.17] 모듈 안내(릴리스 화면·AI 스킬): 무엇을 하는지, 어떤 역할 서버에, 무엇이 먼저 있어야 하는지(requires), 무엇을 제공하는지(provides), 설치 방법.
|
||||
*/
|
||||
export const MODULE_GUIDES = {
|
||||
agent: { label: 'Node Agent', description: '각 관리 서버에서 HAL 의 작업(설치·갱신·건강 확인·백업·지표)을 수행하는 상주 프로세스. 다른 모든 모듈은 Agent 를 통해 배포된다.', roles: MODULES.agent.roles, requires: ['hal'], provides: ['module.install', 'heartbeat', 'metrics', 'backup'], install: '서버 등록 → 일회용 등록 정보로 Agent 설치(bootstrap) → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', singleton: false },
|
||||
portal: { label: '고객 포털', description: '고객 가입·상품·사이트·메일·도메인·백업·결제를 하나로 잇는 사용자 접근 화면과 API. HAL 과 서비스 키로 연결되어 작업을 HAL 에 맡긴다.', roles: MODULES.portal.roles, requires: ['agent', 'hal 연동 키(link-portal)'], provides: ['고객 화면 my.ariweb.net', '/internal/ops 운영 중계'], install: '포털 서버 프로필로 통합 배포하거나 이 화면에서 portal 배포. 포털은 **하나만** 운영한다 — 이미 있으면 설치 대신 "포털 이전"을 쓴다(새 서버 설치 → 데이터·DNS 전환 확인 → 옛 서버 비활성).', singleton: true },
|
||||
webmaker: { label: 'Webmaker', description: 'AI 사이트 제작 도구(도입 모듈). 제작 화면·직접 편집·배포 요청을 제공하고, 사용자 사이트는 웹호스팅 서버로 배포된다.', roles: MODULES.webmaker.roles, requires: ['agent', 'portal(SSO·배포 연동)', 'webhosting 서버 1대 이상(배포 대상)'], provides: ['사이트 제작·편집', '배포 요청'], install: 'Webmaker 서버 프로필(이 서버에서는 Webmaker 만 동작)로 통합 배포하거나 web 역할 서버에 배포.', singleton: false },
|
||||
arimail: { label: 'AriMail', description: '메일 서비스(도입 모듈): 웹메일 접속, POP·IMAP·SMTP, 도메인별 메일함. HAL 관리형 메일 서비스 등록부와 연결된다.', roles: MODULES.arimail.roles, requires: ['agent', 'mail 역할 서버', 'DNS(MX·SPF·DKIM)'], provides: ['webmail', 'POP/IMAP/SMTP'], install: 'AriMail 서버 프로필로 통합 배포하거나 mail 역할 서버에 배포. 기존 AriMail 인스턴스는 mail-import 로 가져온다.', singleton: false },
|
||||
backup: { label: '백업', description: 'Agent 안의 백업 기능: 사이트·메일 백업 생성·저장·검증·복원. 별도 artifact 없이 Agent 와 함께 설치된다.', roles: MODULES.backup.roles, requires: ['agent'], provides: ['backup.create/verify/restore'], install: '백업 서버 프로필로 통합 배포(backup 역할). 운영 서버와 분리된 저장 서버를 권장.', singleton: false },
|
||||
hawkeye: { label: 'HawkEye', description: '전체 시스템 상황 점검(옛 이름 observability): 서버 지표·시계 차이·디스크 고갈·모듈 건강을 heartbeat 로 모아 운영 현황과 사건으로 보인다. Agent 안의 기능.', roles: MODULES.hawkeye.roles, requires: ['agent'], provides: ['지표 수집', '건강·사건 감지'], install: '모든 프로필에 포함되어 Agent 와 함께 설치된다. 별도 artifact 없음.', singleton: false },
|
||||
};
|
||||
/** [0.3.17] 모듈 사이의 연결(릴리스 화면 "상호 연결" 설명) */
|
||||
export const MODULE_LINKS = [
|
||||
'HAL ↔ Agent: 모든 서버 작업(설치·건강·백업·지표)은 Agent 가 HAL 에서 받아 수행한다. Agent 없이는 어떤 모듈도 배포되지 않는다.',
|
||||
'HAL ↔ 포털: 서비스 키(link-portal)로 연결. 포털의 사이트 배포·백업·이전 요청은 HAL 작업으로 실행된다. 포털이 없으면 고객 화면이 없다.',
|
||||
'포털 ↔ Webmaker: SSO·제작 화면 열기·배포 요청. Webmaker 가 만든 사이트는 웹호스팅 서버(web 역할, Agent 설치)로 배포된다.',
|
||||
'포털 ↔ AriMail / HAL 메일 서비스 등록부: 메일 서비스·메일함 생성은 포털이 요청하고 AriMail(mail 역할)이 제공한다.',
|
||||
'백업(Agent 기능) ↔ 포털: 백업 옵션·복원 요청은 포털이, 생성·저장·검증은 Agent 백업 기능이 한다.',
|
||||
'HawkEye(Agent 기능) → HAL: 서버 지표·모듈 건강을 heartbeat 로 올려 운영 현황·사건·공개 상태 페이지에 반영한다.',
|
||||
];
|
||||
/**
|
||||
* [0.3.17] 역할별 서버 프로필(통합 배포). 새 서버를 추가할 때 역할을 고르면 필요한 모듈을 한 번에 순서대로 배포하고 서버의 역할·슬롯을 기록한다.
|
||||
* slots: 그 서버가 맡을 수 있는 수(배치 때 HAL 이 본다). 포털은 singleton(하나만).
|
||||
*/
|
||||
export const SERVER_PROFILES = {
|
||||
webmaker: { id: 'webmaker', label: 'Webmaker 서버', role: 'web', modules: ['agent', 'webmaker', 'hawkeye'], description: '이 서버에서는 Webmaker 만 동작합니다(제작 화면·직접 편집).', slots: { webmaker: 1 } },
|
||||
webhosting: { id: 'webhosting', label: '웹호스팅 서버', role: 'web', modules: ['agent', 'hawkeye'], description: '사용자의 웹 서비스를 제공합니다. Webmaker 의 배포 대상 서버가 됩니다(사이트 슬롯).', slots: { sites: 50 } },
|
||||
arimail: { id: 'arimail', label: 'AriMail 서버', role: 'mail', modules: ['agent', 'arimail', 'hawkeye'], description: '사용자에게 메일 서비스(웹메일·POP·IMAP·SMTP)를 제공합니다.', slots: { mailServices: 20 } },
|
||||
backup: { id: 'backup', label: '백업 서버', role: 'backup', modules: ['agent', 'backup', 'hawkeye'], description: '사용자 서비스의 백업을 저장·검증합니다. 운영 서버와 분리를 권장합니다.', slots: { backups: 500 } },
|
||||
portal: { id: 'portal', label: '포털 서버', role: 'control', modules: ['agent', 'portal', 'hawkeye'], description: '사용자 접근을 위한 포털. 하나만 운영하며, 이미 있으면 설치 대신 이전(relocate)합니다.', slots: {}, singleton: true },
|
||||
};
|
||||
export const profileOf = (id) => SERVER_PROFILES[id] || null;
|
||||
|
||||
@@ -127,4 +127,12 @@ CREATE TABLE IF NOT EXISTS ai_oauth_states (state_hash TEXT PRIMARY KEY, connect
|
||||
` },
|
||||
// 0.3.2: 연결별 도구 정책(tools_json: {preset, enabled}) · 제안 출처(source: chat | mcp)
|
||||
{ id: 'hal_0007_ai_tools_policy', sql: `ALTER TABLE ai_connections ADD COLUMN tools_json TEXT; ALTER TABLE ai_actions ADD COLUMN source TEXT NOT NULL DEFAULT 'chat';` },
|
||||
// 0.3.17: observability → hawkeye(HawkEye) 이름 변경(배치 상태·목표 버전 행), 서버 프로필(역할별 통합 배포)·슬롯
|
||||
{ id: 'hal_0008_hawkeye', sql: `
|
||||
UPDATE module_targets SET module='hawkeye' WHERE module='observability' AND NOT EXISTS (SELECT 1 FROM module_targets t2 WHERE t2.module='hawkeye' AND t2.server_id=module_targets.server_id);
|
||||
DELETE FROM module_targets WHERE module='observability';
|
||||
INSERT OR IGNORE INTO modules(name, target_version, updated_at) SELECT 'hawkeye', target_version, updated_at FROM modules WHERE name='observability';
|
||||
DELETE FROM modules WHERE name='observability';
|
||||
` },
|
||||
{ id: 'hal_0009_server_profile', sql: `ALTER TABLE servers ADD COLUMN profile TEXT; ALTER TABLE servers ADD COLUMN slots_json TEXT;` },
|
||||
];
|
||||
|
||||
+6
-4
@@ -26,6 +26,7 @@ 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)), '..');
|
||||
@@ -54,7 +55,8 @@ export function registerUi(app, d) {
|
||||
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></main>
|
||||
<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>`);
|
||||
@@ -74,12 +76,12 @@ export function registerUi(app, d) {
|
||||
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></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
||||
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({ releases: core.listReleases(d), running: core.bootstrapState(d), modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) }));
|
||||
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>`;
|
||||
@@ -130,7 +132,7 @@ export function overviewLocal(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: '배치 없음' }); });
|
||||
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() };
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@
|
||||
import * as A from './ai-render.mjs';
|
||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||
import { Term, renderHtml, keyToBytes, extractUrls, plainText, copyDecision } from './term.mjs';
|
||||
import { patchHtml } from './dom-patch.mjs'; // [0.3.17] 대화 기록은 바뀐 메시지만 갱신
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||
async function api(method, path, body) {
|
||||
@@ -61,8 +62,8 @@ async function loadMessages({ full = false } = {}) {
|
||||
if (full) { msgs = r.messages; } else if (r.messages.length) { msgs = msgs.concat(r.messages); }
|
||||
lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0;
|
||||
const log = $('#ai-log'); const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||
log.innerHTML = msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`;
|
||||
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; $('#ai-status').innerHTML = A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : '');
|
||||
patchHtml(log, msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`);
|
||||
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; patchHtml($('#ai-status'), A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : ''));
|
||||
if (atBottom || full) log.scrollTop = log.scrollHeight;
|
||||
schedule(r.pending ? 1000 : 4000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* hal/ui/public/dom-patch.mjs — 화면 부분 갱신(0.3.17, 개발 지시 v0.3.17 §3). 주기 갱신이 화면 전체를 innerHTML 로 바꾸면 번쩍이고 선택·포커스·스크롤이 깨진다.
|
||||
* 새 HTML 을 최상위 노드 단위로 나누어 바뀐 노드만 바꾼다. 순수 함수(splitTopLevel·diffChildren)는 Node 테스트가 검사하고, patchHtml 만 DOM 을 쓴다.
|
||||
*/
|
||||
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
||||
/** HTML 문자열을 최상위 노드(요소·글) 문자열 배열로 나눈다. 깊이를 세어 요소 경계를 찾는다(스크립트·주석은 쓰지 않는 화면용). */
|
||||
export function splitTopLevel(html) {
|
||||
const s = String(html || ''); const out = []; let i = 0; let depth = 0; let start = 0; let textStart = 0;
|
||||
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g;
|
||||
while (i < s.length) {
|
||||
tagRe.lastIndex = i; const m = tagRe.exec(s); if (!m) break;
|
||||
if (depth === 0 && m.index > textStart) { const t = s.slice(textStart, m.index); if (t.trim()) out.push(t); }
|
||||
const closing = m[0][1] === '/'; const name = m[1].toLowerCase(); const selfClose = m[2] === '/' || VOID.has(name);
|
||||
if (!closing) { if (depth === 0) start = m.index; if (!selfClose) depth++; if (depth === 0 && selfClose) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
||||
else { depth = Math.max(0, depth - 1); if (depth === 0) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
||||
i = m.index + m[0].length;
|
||||
}
|
||||
if (depth === 0 && textStart < s.length) { const t = s.slice(textStart); if (t.trim()) out.push(t); } else if (depth > 0) out.push(s.slice(start));
|
||||
return out;
|
||||
}
|
||||
/** 옛 노드 문자열 배열 → 새 배열로 바꾸는 최소 작업 목록: replace(오름차순) → append(오름차순) → remove(내림차순). */
|
||||
export function diffChildren(oldList, newList) {
|
||||
const ops = []; const n = Math.min(oldList.length, newList.length);
|
||||
for (let i = 0; i < n; i++) if (oldList[i] !== newList[i]) ops.push({ i, op: 'replace', html: newList[i] });
|
||||
for (let i = n; i < newList.length; i++) ops.push({ i, op: 'append', html: newList[i] });
|
||||
for (let i = oldList.length - 1; i >= n; i--) ops.push({ i, op: 'remove' });
|
||||
return ops;
|
||||
}
|
||||
const nodeHtml = (n) => (n.nodeType === 1 ? n.outerHTML : n.textContent);
|
||||
/** 컨테이너의 자식을 새 HTML 로 맞춘다(바뀐 노드만). 같은 HTML 이면 아무것도 하지 않는다. 브라우저 전용. */
|
||||
export function patchHtml(el, html) {
|
||||
if (!el) return false; if (el.__lastHtml === html) return false;
|
||||
const tpl = document.createElement('template'); tpl.innerHTML = html; const fresh = [...tpl.content.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
||||
const cur = [...el.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
||||
const ops = diffChildren(cur.map(nodeHtml), fresh.map(nodeHtml));
|
||||
for (const o of ops) { if (o.op === 'replace') el.replaceChild(fresh[o.i].cloneNode(true), cur[o.i]); else if (o.op === 'append') el.appendChild(fresh[o.i].cloneNode(true)); else if (o.op === 'remove') cur[o.i].remove(); }
|
||||
el.__lastHtml = html; return ops.length > 0;
|
||||
}
|
||||
@@ -46,3 +46,8 @@ body.login{display:grid;place-items:center;min-height:100vh}.login-box{backgroun
|
||||
.ai-term{background:#101826;color:#e6edf3;font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.25;padding:8px;border-radius:8px;min-height:240px;max-height:55vh;overflow:auto;white-space:pre;outline:none;margin:0}.ai-term:focus{box-shadow:0 0 0 2px var(--pri)}.term-cur{background:#e6edf3;color:#101826}.ai-keys{gap:4px}.ai-model-pick{width:auto;max-width:220px;padding:3px 6px;font-size:12px}
|
||||
/* 0.3.10 셸 복사·주소 */
|
||||
.ai-term{user-select:text;cursor:text}.ai-urls{list-style:none;padding:0;margin:4px 0}.ai-urls li{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:3px 0}.ai-url{word-break:break-all;background:#f7f9fc;border:1px solid var(--line);border-radius:6px;padding:2px 6px;font-size:12px}.ai-shell-tools{margin:6px 0}.ai-shell-textview{width:100%;max-width:none;font-family:ui-monospace,Menlo,monospace;font-size:12px;margin-top:6px}
|
||||
/* 0.3.17 작업 진행 창(dock)·서버 선택 목록·릴리스 카드 */
|
||||
.ops-dock{margin-top:18px;border:1px solid var(--line);border-radius:var(--rad);background:#fff}.ops-dock-head{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.ops-dock-head .muted{margin-left:auto}.ops-dock-body{padding:8px 10px;max-height:260px;overflow:auto}.ops-dock.closed .ops-dock-body{display:none}
|
||||
.dock-job{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:5px 0;border-bottom:1px solid var(--line)}.dock-job:last-child{border-bottom:0}.dock-bar{display:inline-block;width:120px;height:8px;background:#eef1f5;border-radius:4px;overflow:hidden;vertical-align:middle}.dock-bar-in{display:block;height:100%;background:var(--pri)}
|
||||
.choice-list{display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:8px;padding:6px}.choice{display:flex;gap:8px;align-items:center;font-weight:400;margin:0;padding:4px 6px;border-radius:6px}.choice:hover{background:#f7f9fc}.choice.off{opacity:.55}
|
||||
.rel-cards{grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.rel-card h3{margin-top:0}.rel-history{margin:12px 0}
|
||||
|
||||
+24
-7
@@ -2,10 +2,11 @@
|
||||
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
|
||||
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
|
||||
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
|
||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 읽되 [0.3.17] 바뀐 노드만 바꾼다(dom-patch.mjs — 번쩍임·선택 유지). 아래 작업 진행 창(dock)은 5초마다. 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||
*/
|
||||
import * as R from './render.mjs';
|
||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||
import { patchHtml } from './dom-patch.mjs'; // [0.3.17] 주기 갱신은 바뀐 부분만
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||
async function api(method, path, body) {
|
||||
@@ -22,9 +23,9 @@ function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" ro
|
||||
/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */
|
||||
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
|
||||
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'radio' || f.type === 'checks' ? `<label>${R.esc(f.label)}</label>${f.html || ''}` : f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
|
||||
$('#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); };
|
||||
$('#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' : f.type === 'checks' ? fd.getAll(f.name) : fd.get(f.name); if (fields.some((f) => f.type === 'radio' && f.required && !out[f.name])) return toast('서버를 선택하세요'); m.close(); resolve(out); };
|
||||
});
|
||||
}
|
||||
/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
|
||||
@@ -47,7 +48,7 @@ async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message ||
|
||||
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; }
|
||||
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); patchHtml(body(), R.overviewView(o)); } catch (e) { patchHtml(body(), 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 }); },
|
||||
@@ -61,9 +62,9 @@ const PAGES = {
|
||||
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) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'; } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } },
|
||||
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); patchHtml(b, (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'); } 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 (_) {} },
|
||||
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)) 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; } }; },
|
||||
@@ -73,7 +74,17 @@ 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-deploy': async (b) => { const [sv, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/releases')]); const roles = (rel.guides && rel.guides[b.m] && rel.guides[b.m].roles) || []; const eligible = sv.servers.filter((x) => x.status === 'ready' && x.agentId && (b.m === 'agent' || roles.includes(x.role))).map((x) => x.id);
|
||||
const v = await ask(`${b.m} ${b.v} 배포 — 대상 서버 선택`, [{ name: 'serverIds', label: `대상 서버(역할 ${roles.join('·') || '전체'}, 준비된 서버만 선택 가능)`, type: 'checks', html: R.serverChoiceHtml(sv.servers, { name: 'serverIds', multi: true, eligible }) }], { note: 'HAL 이 릴리스보다 오래되지 않아야 하며(HAL 은 배포 대상이 아님), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다. 아래 작업 진행 창에서 진행 상황을 볼 수 있습니다.' }); if (!v) return;
|
||||
if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds, nonce: Date.now() }); toast(`배포 작업을 만들었습니다(대상 ${r.targets ? r.targets.length : v.serverIds.length}대)`); location.href = '/ops/jobs/' + r.job.id; },
|
||||
// [0.3.17] 역할별 통합 배포: 프로필·릴리스·서버(라디오)
|
||||
provision: async () => { const [sv, pr, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/provision/profiles'), api('GET', '/ops/api/releases')]); const versions = (rel.releases || []).filter((x) => x.status === 'approved').map((x) => x.version); if (!versions.length) return toast('승인된 릴리스가 없습니다');
|
||||
const v = await ask('역할별 통합 배포(새 서버)', [{ name: 'profile', label: '서버 역할(프로필)', type: 'select', options: pr.profiles.map((x) => ({ value: x.id, label: `${x.label} — ${x.modules.join(' → ')}` })) }, { name: 'version', label: '릴리스', type: 'select', options: versions }, { name: 'serverId', label: '대상 서버(준비된 서버만)', type: 'radio', required: true, html: R.serverChoiceHtml(sv.servers, { name: 'serverId' }) }], { note: '프로필의 모듈을 순서대로(agent → … → HawkEye) 같은 서버에 배포하고 역할·프로필·슬롯을 기록합니다. 포털은 하나만 — 이미 있으면 "포털 이전"을 쓰세요.' }); if (!v) return;
|
||||
const r = await call('POST', '/ops/api/provision', { ...v, nonce: Date.now() }); toast(`통합 배포 작업을 만들었습니다(${r.modules.join(' → ')})`); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'portal-relocate': async () => { const [sv, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/releases')]); const cur = (rel.targets || []).find((t) => t.module === 'portal' && t.active !== 0); if (!cur) return toast('활성 포털이 없습니다 — 통합 배포(포털 서버)로 설치하세요'); const versions = (rel.releases || []).filter((x) => x.status === 'approved').map((x) => x.version);
|
||||
const v = await ask(`포털 이전 — 현재 ${(rel.servers && rel.servers[cur.server_id] && rel.servers[cur.server_id].name) || cur.server_id}`, [{ name: 'version', label: '릴리스', type: 'select', options: versions }, { name: 'toServerId', label: '새 포털 서버', type: 'radio', required: true, html: R.serverChoiceHtml(sv.servers.filter((x) => x.id !== cur.server_id), { name: 'toServerId' }) }], { danger: true, note: '새 서버에 포털을 설치한 뒤 데이터·DNS 전환을 운영자가 마치고 재개하면 옛 포털을 비활성으로 표시합니다.' }); if (!v) return;
|
||||
const c = await threeStep({ title: '포털 이전', impacts: [`새 서버 ${v.toServerId} 에 포털 ${v.version} 을 설치합니다`, '데이터 이전·DNS 전환은 운영자가 직접 수행한 뒤 작업을 재개합니다', `옛 서버 ${cur.server_id} 의 포털은 재개 뒤 비활성으로 표시됩니다`], identifiers: [cur.server_id, v.toServerId], revertible: '전환 전에는 옛 포털이 그대로 동작합니다' }); if (!c) return;
|
||||
const r = await call('POST', '/ops/api/portal/relocate', { fromServerId: cur.server_id, toServerId: v.toServerId, version: v.version, ...c }); 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; },
|
||||
@@ -142,6 +153,12 @@ const ACT = {
|
||||
};
|
||||
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') || ''); } });
|
||||
// ---------------- [0.3.17] 작업 진행 창(dock): 5초마다 진행 중·히스토리를 받아 바뀐 부분만 갱신
|
||||
let dockTab = 'active';
|
||||
async function loadDock() { const body = $('#ops-dock-body'); if (!body) return; try { const [a, h] = await Promise.all([api('GET', '/ops/api/jobs?status=queued,running,awaiting_external,needs_user,reverting,verifying,paused&limit=50'), api('GET', '/ops/api/jobs?limit=30')]); const active = a.jobs || []; const done = (h.jobs || []).filter((j) => ['done', 'failed', 'cancelled', 'reverted'].includes(j.status)); patchHtml(body, R.jobsDockHtml({ active, history: done, tab: dockTab })); const c = $('#ops-dock-count'); if (c && c.textContent !== String(active.length)) { c.textContent = String(active.length); c.className = 'badge ' + (active.length ? 'running' : 'neutral'); } } catch (e) { patchHtml(body, R.relayError(e)); } }
|
||||
ACT['dock-tab'] = (b) => { dockTab = b.tab; document.querySelectorAll('[data-act=dock-tab]').forEach((x) => x.classList.toggle('on', x.dataset.tab === dockTab)); document.querySelectorAll('#ops-dock .dock-list').forEach((x) => { x.hidden = x.dataset.tab !== dockTab; }); };
|
||||
ACT['dock-toggle'] = () => { const d = $('#ops-dock'); d.classList.toggle('closed'); const b = $('[data-act=dock-toggle]'); if (b) b.setAttribute('aria-expanded', String(!d.classList.contains('closed'))); try { localStorage.setItem('aw_ops_dock', d.classList.contains('closed') ? 'closed' : 'open'); } catch (_) {} };
|
||||
if ($('#ops-dock')) { try { if (localStorage.getItem('aw_ops_dock') === 'closed') $('#ops-dock').classList.add('closed'); } catch (_) {} loadDock(); setInterval(() => { if (!document.hidden) loadDock(); }, 5000); }
|
||||
// ---------------- 부팅
|
||||
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); }
|
||||
|
||||
+36
-14
@@ -42,7 +42,7 @@ export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r)
|
||||
/** 서버 표(§28.1) */
|
||||
export function serversTable(servers) {
|
||||
return table([
|
||||
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)} · ${esc(s.location || '위치 미입력')}</span>` },
|
||||
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(s.location || '위치 미입력')}</span>${s.slots && Object.keys(s.slots).length ? `<br><span class="muted small">슬롯 ${esc(Object.entries(s.slots).map(([k, v]) => `${k} ${s.slotsFree && s.slotsFree[k] != null ? s.slotsFree[k] + '/' : ''}${v}`).join(', '))}</span>` : ''}` },
|
||||
{ l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}<br><span class="muted">마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}</span>` },
|
||||
{ l: '건강', f: (s) => healthBadge(s.stale ? 'stale' : s.health) },
|
||||
{ l: 'CPU·메모리', f: (s) => s.metrics ? `${esc(String(s.metrics.cpu ?? '—'))}% · ${esc(String(s.metrics.memPct ?? pct(s.metrics.memUsed, s.metrics.memTotal) ?? '—'))}%` : '정보 없음' },
|
||||
@@ -81,21 +81,42 @@ export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) {
|
||||
return `<svg viewBox="0 0 ${w} ${h}" class="chart" role="img" aria-label="${esc(label)} 추이 ${rows.length}개 표본"><text x="4" y="12" class="lbl">${esc(label)}</text><line x1="30" y1="${h - 20}" x2="${w - 10}" y2="${h - 20}" class="axis"/><line x1="30" y1="20" x2="30" y2="${h - 20}" class="axis"/><text x="2" y="24" class="lbl">${key === 'net' ? bytes(max) : '100%'}</text><polyline points="${pts}" fill="none" class="line"/><text x="30" y="${h - 4}" class="lbl">${fmt(rows[0].at)}</text><text x="${w - 150}" y="${h - 4}" class="lbl">${fmt(rows[rows.length - 1].at)}</text></svg>`;
|
||||
}
|
||||
/** 릴리스 화면(§32.1) */
|
||||
/**
|
||||
* [0.3.17] 배포 릴리스 화면: 기본은 "모듈별 최신 릴리스 배포" — 모듈마다 최신 승인 릴리스·기능 설명·필요한 것(requires)·제공(provides)·설치 방법·현재 배치, 상호 연결, 역할별 통합 배포.
|
||||
* HAL 은 배포 대상이 아니다(먼저 설치하는 도구). 과거 버전은 접힌 표에서 보고 대상별 되돌리기로 돌아간다.
|
||||
*/
|
||||
export function releasesView(data, changelog = {}) {
|
||||
const running = data.running || {}; const targets = data.targets || [];
|
||||
const byModule = (v) => (data.modules || []).map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === v).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">배치 없음</span>'}`; }).join(' · ');
|
||||
return `<p class="muted">배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — <b>HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2)</b>. HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'}</p>
|
||||
${table([
|
||||
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 중') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
|
||||
const running = data.running || {}; const targets = data.targets || []; const servers = data.servers || {}; const latest = data.latest || {}; const guides = data.guides || {}; const modules = data.modules || []; const approved = (data.releases || []).filter((r) => r.status === 'approved');
|
||||
const sname = (id) => (servers[id] && servers[id].name) || id;
|
||||
const placed = (m) => { const ts = targets.filter((t) => t.module === m); return ts.length ? ts.map((t) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> ${t.actual_version ? `<code>${esc(t.actual_version)}</code>` : '<span class="muted">버전 없음</span>'}${t.target_version && t.actual_version !== t.target_version ? ` <span class="err">(목표 ${esc(t.target_version)})</span>` : ''}${t.active === 0 ? ' <span class="badge neutral">비활성</span>' : ''} ${healthBadge(t.health)}`).join('<br>') : '<span class="muted">배치 없음</span>'; };
|
||||
const cards = modules.map((m) => { const g = guides[m] || { label: m, description: '', requires: [], provides: [], install: '' }; const v = latest[m]; return `<div class="card rel-card" data-module="${esc(m)}"><h3>${esc(g.label)} <span class="muted small">(${esc(m)})</span> ${v ? badge('ok', `최신 ${v}`) : badge('warn', '승인된 릴리스 없음')}</h3><p class="small">${esc(g.description)}</p>
|
||||
<dl class="kv small"><dt>필요한 것</dt><dd>${(g.requires || []).map(esc).join(', ') || '—'}</dd><dt>제공</dt><dd>${(g.provides || []).map(esc).join(', ') || '—'}</dd><dt>서버 역할</dt><dd>${(g.roles || []).map(esc).join('·')}</dd><dt>설치 방법</dt><dd>${esc(g.install)}</dd><dt>현재 배치</dt><dd>${placed(m)}</dd></dl>
|
||||
<div class="actions">${v ? `<button class="btn primary small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(v)}">${esc(g.label)} ${esc(v)} 배포(서버 선택)</button>` : ''}${g.singleton ? ` <button class="btn small" data-act="portal-relocate">포털 이전</button>` : ''}</div></div>`; }).join('');
|
||||
const history = table([
|
||||
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 버전') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
|
||||
{ l: '검사', f: (r) => r.checks ? (r.checks.testsPassed === false ? badge('failed', '테스트 실패') : r.checks.testsPassed ? badge('ok', '테스트 통과') : badge('neutral', '검사 없음')) + (r.checks.dirty ? ' ' + badge('warn', '수정본(dirty)') : '') : badge('neutral', '검사 없음') },
|
||||
{ l: '승인', f: (r) => r.status === 'approved' ? badge('ok', `승인 ${r.approved_by || ''}`) + `<br><span class="muted">${fmt(r.approved_at)}</span>` : badge('waiting', '승인 대기') + ` <button class="btn" data-act="release-approve" data-v="${esc(r.version)}">승인</button>` },
|
||||
{ l: 'HAL 배포', f: (r) => r.hal_deployed_at ? badge('done', fmt(r.hal_deployed_at)) : badge('neutral', '아직') },
|
||||
{ l: '모듈별 배치', f: (r) => byModule(r.version) },
|
||||
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">VERSION_HISTORY 항목 없음</span>' },
|
||||
{ l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
|
||||
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })}
|
||||
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}" class="mono">${esc(t.server_id)}</a>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}
|
||||
<div class="actions"><button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>`;
|
||||
{ l: '모듈별 배치', f: (r) => modules.map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === r.version).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">—</span>'}`; }).join(' · ') },
|
||||
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">항목 없음</span>' },
|
||||
{ l: '이 버전 배포', f: (r) => r.status === 'approved' ? modules.filter((m) => r.artifacts && (r.artifacts[m] || (guides[m] && /Agent 안의/.test(guides[m].description)))).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
|
||||
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' });
|
||||
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
|
||||
<h2>최신 릴리스 배포(모듈별)</h2><div class="cards rel-cards">${cards}</div>
|
||||
<div class="actions"><button class="btn primary" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>
|
||||
<h2>상호 연결(어떤 모듈이 있어야 무엇이 동작하는가)</h2><ul class="small">${(data.links || []).map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
|
||||
<h2>역할별 서버 프로필</h2>${table([{ l: '프로필', f: (p) => `<b>${esc(p.label)}</b> <span class="muted small">(${esc(p.id)})</span>` }, { l: '역할', k: 'role' }, { l: '배포 모듈(순서)', f: (p) => p.modules.map(esc).join(' → ') }, { l: '설명', f: (p) => esc(p.description) + (p.singleton ? ' <span class="badge warn">하나만</span>' : '') }, { l: '슬롯', f: (p) => esc(Object.entries(p.slots || {}).map(([k, v]) => `${k} ${v}`).join(', ') || '—') }], data.profiles || [], { empty: '프로필 없음' })}
|
||||
<details class="rel-history"><summary><b>과거 버전 보기</b> — 등록된 릴리스 ${(data.releases || []).length}개(승인 ${approved.length}). 이전 버전으로 돌아가려면 아래 배치 상태의 "되돌리기" 또는 그 버전의 배포 단추를 쓰세요.</summary>${history}</details>
|
||||
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> <span class="muted small mono">${esc(t.server_id)}</span>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) + (t.active === 0 ? ' ' + badge('neutral', '비활성') : '') }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}`;
|
||||
}
|
||||
/** [0.3.17] 작업 진행 창(dock): 진행 중 작업과 최근 히스토리. 두 목록을 모두 그리고 탭은 hidden 으로 전환한다. */
|
||||
export function jobsDockHtml({ active = [], history = [], tab = 'active' } = {}) {
|
||||
const row = (j) => `<div class="dock-job" data-job="${esc(j.id)}"><a href="/ops/jobs/${esc(j.id)}"><b>${esc(j.label || j.kind)}</b></a> <span class="muted small">${esc(j.kind)}</span> ${jobBadge(j.status)}${j.currentStep ? ` <span class="small">${esc(j.currentStep.title)}</span>` : ''}${j.progress != null && !['done', 'failed', 'cancelled'].includes(j.status) ? ` <span class="dock-bar"><span class="dock-bar-in" style="width:${Math.max(0, Math.min(100, Number(j.progress) || 0))}%"></span></span> <span class="small">${Math.max(0, Math.min(100, Number(j.progress) || 0))}%</span>` : ''}${j.error ? ` <span class="err small">${esc(j.error)}</span>` : ''} <span class="muted small">${esc(ago(j.updatedAt))}</span></div>`;
|
||||
return `<div class="dock-list" data-tab="active"${tab !== 'active' ? ' hidden' : ''}>${active.length ? active.map(row).join('') : '<div class="muted small">진행 중인 작업이 없습니다.</div>'}</div><div class="dock-list" data-tab="history"${tab !== 'history' ? ' hidden' : ''}>${history.length ? history.map(row).join('') : '<div class="muted small">최근 작업 기록이 없습니다.</div>'}</div>`;
|
||||
}
|
||||
/** [0.3.17] 대상 서버 선택 목록: 라디오(단일) 또는 체크(여러 개). 준비(ready)되지 않은 서버는 고를 수 없다. eligible 이 있으면 그 서버만 미리 체크. */
|
||||
export function serverChoiceHtml(servers, { name = 'serverId', multi = false, eligible = null } = {}) {
|
||||
if (!servers || !servers.length) return '<div class="aw-state">선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.</div>';
|
||||
return `<div class="choice-list">${servers.map((s) => { const ok = s.status === 'ready'; const checked = multi && ok && (!eligible || eligible.includes(s.id)); return `<label class="choice${ok ? '' : ' off'}"><input type="${multi ? 'checkbox' : 'radio'}" name="${esc(name)}" value="${esc(s.id)}"${ok ? '' : ' disabled'}${checked ? ' checked' : ''}> <b>${esc(s.name)}</b> <span class="muted small">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(SERVER_LABEL[s.status] || s.status)}${s.agentVersion ? ' · Agent ' + esc(s.agentVersion) : ' · Agent 없음'}${s.modules && Object.keys(s.modules).length ? ' · ' + Object.entries(s.modules).map(([m, x]) => `${esc(m)} ${esc(x.actual || '—')}`).join(', ') : ''}</span></label>`; }).join('')}</div>`;
|
||||
}
|
||||
export function incidentsView(incidents, notifications = []) {
|
||||
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
|
||||
@@ -129,7 +150,8 @@ export function accountsView(data) {
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
export function overviewView(o) {
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(c.name)}</b> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const COMP_LABEL = { HAL: 'HAL', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye' }; // [0.3.17] 구성요소 표시 이름(observability → HawkEye)
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(COMP_LABEL[c.name] || c.name)}</b> <span class="muted small">${COMP_LABEL[c.name] && COMP_LABEL[c.name] !== c.name ? esc(c.name) : ''}</span> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const jobs = o.jobs || {}; const srv = o.servers || {};
|
||||
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
|
||||
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</a></div>
|
||||
|
||||
Reference in New Issue
Block a user