Files
AriWeb-Suite/hal/ui/public/ops.js
zzabisagent-labandClaude Fable 5.1 79b45ca961 기존 AriMail 인스턴스 ↔ HAL 관리형 MailService/Node 가져오기: hal/mailsvc.mjs(등록부·dry-run·트랜잭션 적용·되돌리기·heartbeat 동기화) + scripts/mail-discover.mjs(읽기 전용 discovery) + CLI/운영자 API/화면, 테스트 9건, RUNBOOK §9
- mail_services(도메인 단위 논리 리소스, 불변 id, 출처 권한, 도메인별 active owner 1 — 부분 유일 인덱스)·mail_imports 표 추가(기존 표 변경 없음)
- discovery 문서에 비밀값이 있으면 거부(참조만 저장), 충돌·중복·누락은 전체 거부, 적용은 트랜잭션 + 감사(immutable), 재적용 멱등, 적용 단위 되돌리기
- hal/index.mjs: mail-import · mail-import-rollback · mail-services · server-token(시스템 신원 토큰, 감사) / app.mjs: /ops/api/mail-services* / ui: /ops/mail HAL 관리형 표 + 포털 중계(#portal-mail)
- core.heartbeat → syncFromHeartbeat(Agent 의 arimail 모듈 상태를 last_sync·health 로 반영, 행 없으면 무관)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 05:25:05 +00:00

145 lines
39 KiB
JavaScript

/* hal/ui/public/ops.js — 운영자 콘솔 브라우저 스크립트.
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 비밀번호 창 → 재시도, 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
*/
import * as R from './render.mjs';
const $ = (s, el = document) => el.querySelector(s);
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
async function api(method, path, body) {
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' });
let j = null; try { j = await r.json(); } catch (_) {}
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
return j;
}
/** 재인증이 필요하면 비밀번호를 받아 재인증한 뒤 한 번 더 시도 */
async function call(method, path, body) { try { return await api(method, path, body); } catch (e) { if (e.code === 'reauth_required') { if (await reauth()) return api(method, path, body); } throw e; } }
const portal = (method, path, body) => call(method, '/ops/api/portal' + path, body);
let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); }
function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; }
/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
return new Promise((resolve) => {
const m = modal(`<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>`);
$('#askc', m.el).onclick = () => { m.close(); resolve(null); };
$('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : fd.get(f.name); m.close(); resolve(out); };
});
}
/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
function showOnce(title, value, note = '') { const m = modal(`<h2>${R.esc(title)}</h2><p class="err">이 값은 지금 한 번만 표시되며 다시 볼 수 없습니다. 안전한 곳에 바로 옮기세요.</p>${note ? `<p class="muted">${R.esc(note)}</p>` : ''}<pre class="once" id="once">${R.esc(value)}</pre><div class="actions"><button class="btn" id="cp">복사</button><button class="btn primary" id="ok">닫기(확인했습니다)</button></div>`); $('#cp', m.el).onclick = () => { navigator.clipboard?.writeText(value); toast('복사했습니다'); }; $('#ok', m.el).onclick = m.close; }
/** 고위험 작업 3단계 확인(§25): 영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부 */
function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌리기 가능 여부를 확인하세요' }) {
return new Promise((resolve) => {
let step = 1; const m = modal('');
const render = () => { m.el.innerHTML = `<div class="box"><h2>${R.esc(title)} — ${step}/3단계</h2>${step === 1 ? `<p><b>1단계 영향 범위 확인</b></p><ul>${impacts.map((i) => `<li>${R.esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="c1"> 위 영향 범위를 확인했습니다</label>` : step === 2 ? `<p><b>2단계 대상 식별정보 재확인</b> — 아래 식별정보를 정확히 입력하세요: <code>${identifiers.map(R.esc).join(', ')}</code></p><input id="c2" placeholder="식별정보(쉼표로 구분)" autocomplete="off">` : `<p><b>3단계 최종 실행</b></p><p>${R.esc(revertible)}</p><label><input type="checkbox" id="c3"> 최종 실행을 확인합니다</label>`}<div class="actions"><button class="btn" id="cancel">취소</button><button class="btn ${step === 3 ? 'danger' : 'primary'}" id="next">${step === 3 ? '실행' : '다음'}</button></div></div>`;
$('#cancel', m.el).onclick = () => { m.close(); resolve(null); };
$('#next', m.el).onclick = () => { if (step === 1 && !$('#c1', m.el).checked) return toast('영향 범위를 확인해야 합니다'); if (step === 2) { const got = $('#c2', m.el).value.split(',').map((s) => s.trim()).filter(Boolean).sort(); if (JSON.stringify(got) !== JSON.stringify(identifiers.slice().sort())) return toast('대상 식별정보가 다릅니다'); } if (step === 3 && !$('#c3', m.el).checked) return toast('최종 실행을 확인해야 합니다'); if (step < 3) { step++; render(); } else { m.close(); resolve({ confirmImpact: true, confirmIdentifiers: identifiers, confirmExecute: true }); } }; };
render();
});
}
async function reauth() { const v = await ask('재인증(최근 10분)', [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: '재인증' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
const reload = () => location.reload();
async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message || String(e)); } }
// ---------------- 페이지별 데이터 로드(포털 중계 포함)
const body = () => $('#body');
async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } }
const PAGES = {
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); body().innerHTML = R.overviewView(o); } catch (e) { body().innerHTML = R.relayError(e); return; }
const el = $('#pending-approvals'); if (!el) return; const partial = []; const count = async (p, pick) => { try { const r = await relay(p); return pick(r); } catch (e) { partial.push(p); return 0; } };
const [orders, restores, relocations, transfers] = await Promise.all([count('/commerce/orders?status=awaiting_approval', (r) => (r.orders || []).length), count('/backup/restores', (r) => (r.restores || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/relocation', (r) => (r.relocations || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/transfer', (r) => (r.transfers || []).filter((x) => ['reviewing', 'accepted'].includes(x.status)).length)]);
el.innerHTML = partial.length === 4 ? R.relayError({ code: 'portal_unreachable' }) : R.pendingApprovals({ orders, restores, relocations, transfers, partial: partial.length ? partial : null }); },
customers: async () => { const q = new URLSearchParams(location.search).get('q') || ''; try { body().innerHTML = R.customersSearch(await relay('/customers?q=' + encodeURIComponent(q))); } catch (e) { body().innerHTML = R.relayError(e); } },
customer: async (id) => { try { body().innerHTML = R.customerView(await relay('/customers/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
orders: async () => { try { body().innerHTML = R.ordersView(await relay('/commerce/orders')); } catch (e) { body().innerHTML = R.relayError(e); } },
products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } },
sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } },
site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
mail: async () => { const el = document.getElementById('portal-mail') || body(); try { el.innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { el.innerHTML = R.relayError(e); } },
domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } },
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); b.innerHTML = (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<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 (_) {} },
recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); },
login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; },
setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; },
};
// ---------------- 동작(data-act)
const ACT = {
'toggle-nav': () => $('#side')?.classList.toggle('open'), logout: async () => { await api('POST', '/ops/api/logout'); location.href = '/ops/login'; }, reauth: () => reauth(),
job: async (b) => { await call('POST', `/ops/api/jobs/${b.id}/${b.op}`); toast('요청했습니다'); reload(); },
'release-approve': async (b) => { if (!(await ask('릴리스 승인', [{ name: 'ok', label: `릴리스 ${b.v} 를 승인합니다(검사 실패 릴리스는 거부됨)`, type: 'checkbox' }]))) return; await call('POST', `/ops/api/releases/${b.v}/approve`); toast('승인했습니다'); reload(); },
'module-deploy': async (b) => { const sv = await api('GET', '/ops/api/servers'); const v = await ask(`${b.m} ${b.v} 배포`, [{ name: 'serverIds', label: '대상 서버 id(쉼표, 비우면 해당 역할 전체)', hint: sv.servers.map((s) => `${s.id}=${s.name}(${s.role},${s.status})`).join(' ') }], { note: 'HAL 이 먼저 배포되어 있어야 하며(§32.2), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다.' }); if (!v) return; const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds ? v.serverIds.split(',').map((s) => s.trim()).filter(Boolean) : [], nonce: Date.now() }); location.href = '/ops/jobs/' + r.job.id; },
'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
'server-approve': async (b) => { await call('POST', `/ops/api/servers/${b.id}/approve`); toast('승인했습니다 — 배치 가능'); reload(); },
'server-maint-start': async (b) => { const v = await ask('유지보수 시작', [{ name: 'note', label: '유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)', required: true }], { note: '신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).' }); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { note: v.note }); reload(); },
'server-maint-end': async (b) => { const v = await ask('유지보수 종료', [{ name: 'acceptsNew', label: '신규 배치 재개', type: 'checkbox', value: true }]); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { end: true, acceptsNew: v.acceptsNew }); reload(); },
'server-accepts': async (b) => { await call('PATCH', `/ops/api/servers/${b.id}`, { accepts_new: Number(b.v) }); reload(); },
'server-isolate': async (b) => { const v = await ask('서버 격리', [{ name: 'reason', label: '격리 이유', required: true }], { danger: true }); if (!v) return; await call('PATCH', `/ops/api/servers/${b.id}`, { status: 'isolated', isolated_reason: v.reason }); reload(); },
'server-delete': async (b) => { const c = await threeStep({ title: '서버 삭제', impacts: [`서버 ${b.id} 와 그 Agent 를 해지합니다`, `배치된 서비스 ${b.hosted}개가 있으면 먼저 이전해야 합니다`, '지표·작업 기록은 남습니다'], identifiers: [b.id], revertible: '삭제 후 복구할 수 없습니다(다시 등록해야 함)' }); if (!c) return; await call('POST', `/ops/api/servers/${b.id}/delete`, c); location.href = '/ops/servers'; },
'incident-new': async () => { const v = await ask('사건 등록', [{ name: 'key', label: '사건 키(같은 원인은 같은 키로 묶임)', required: true }, { name: 'title', label: '제목', required: true }, { name: 'severity', label: '심각도', type: 'select', options: ['info', 'warning', 'critical'] }, { name: 'component', label: '구성요소' }, { name: 'customerImpact', label: '고객 영향(없으면 비움 — 내부 경고는 공개하지 않음)' }, { name: 'public', label: '공개 상태 페이지에 표시', type: 'checkbox' }, { name: 'publicNote', label: '고객 공지 문구' }]); if (!v) return; await call('POST', '/ops/api/incidents', v); reload(); },
'incident-note': async (b) => { const v = await ask('대응 메모', [{ name: 'note', label: '메모', type: 'textarea', required: true }, { name: 'assignee', label: '담당자' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); },
'incident-public': async (b) => { const v = await ask('고객 공지', [{ name: 'publicNote', label: '공개 문구(개인정보·내부 위치 없이)', type: 'textarea', required: true }, { name: 'public', label: '공개', type: 'checkbox', value: true }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); },
'incident-resolve': async (b) => { const v = await ask('사건 종료', [{ name: 'verifiedUserPath', label: '실제 사용자 경로(외부 접속·메일 왕복 등)로 복구를 확인했습니다', type: 'checkbox' }, { name: 'note', label: '복구 확인 내용', type: 'textarea' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, { ...v, resolve: true }); reload(); },
'em-declare': async () => { const v = await ask('긴급 복구 선언', [{ name: 'incidentKey', label: '사건 번호(키)', required: true }, { name: 'reason', label: '복구 이유', type: 'textarea', required: true }, { name: 'scope', label: '허용 대상(쉼표: hal,hal_data,vault,cert_base,artifact_store,first_agent)', required: true }], { danger: true, note: 'HAL 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. HAL 이 정상이면 거부됩니다.' }); if (!v) return; await call('POST', '/ops/api/emergency', { ...v, scope: v.scope.split(',').map((s) => s.trim()).filter(Boolean) }); reload(); },
'em-approve': async (b) => { const step = Number(b.step); const fields = step === 1 ? [{ name: 'impactText', label: '1단계 — 영향 범위를 적으세요', type: 'textarea', required: true }] : step === 2 ? [{ name: 'identifiers', label: '2단계 — 변경되지 않는 대상 식별정보(쉼표)', required: true }] : [{ name: 'finalConfirm', label: '3단계 — 최종 실행을 확인합니다', type: 'checkbox' }, { name: 'revertible', label: '되돌리기 가능', type: 'checkbox' }]; const v = await ask(`승인 ${step}단계`, fields, { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/approve`, { step, ...v, identifiers: v.identifiers ? v.identifiers.split(',').map((s) => s.trim()) : undefined }); reload(); },
'em-credential': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/credential`); showOnce('긴급 자격(짧은 유효 시간)', r.credential, `만료 ${R.fmt(r.expiresAt)} · 정상화 후 폐기됩니다`); setTimeout(reload, 500); },
'em-perform': async (b) => { const v = await ask('긴급 작업 수행', [{ name: 'credential', label: '긴급 자격', type: 'password', required: true }, { name: 'action', label: '작업', type: 'select', options: ['hal.restart', 'hal.restore_snapshot', 'vault.verify', 'cert.reissue_internal', 'artifact.restore', 'agent.reconnect'] }, { name: 'target', label: '대상(선택)' }], { danger: true, note: '허용 범위 밖 작업·만료 자격·일반 배포는 거부됩니다.' }); if (!v) return; try { const r = await call('POST', `/ops/api/emergency/${b.id}/perform`, v); toast('수행 결과: ' + JSON.stringify(r.result).slice(0, 200)); } catch (e) { toast('거부: ' + e.message); } reload(); },
'em-verify': async (b) => { const v = await ask('실제 결과 확인·자격 폐기', [{ name: 'note', label: '실제 확인 내용(운영자 로그인·HAL 제어·HTTPS·고객 서비스 외부 확인)', type: 'textarea', required: true }]); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/verify`, { verify: { note: v.note } }); reload(); },
'em-reconcile': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/reconcile`); toast(`차이 ${(r.result.drift || []).length}건`); reload(); },
'em-close': async (b) => { const v = await ask('사건 종료', [{ name: 'note', label: '종료 메모', type: 'textarea' }], { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/close`, v); reload(); },
'dr-config': async () => { const v = await ask('재해복구 설정', [{ name: 'rto_minutes', label: '복구 목표 시간 RTO(분)', type: 'number', required: true }, { name: 'rpo_minutes', label: '허용 데이터 시점 RPO(분)', type: 'number', required: true }, { name: 'snapshot_interval_minutes', label: '복구 사본 생성 주기(분)', type: 'number', required: true }, { name: 'retention_count', label: '보존 개수', type: 'number', required: true }, { name: 'external_store', label: '외부 보관 위치' }]); if (!v) return; await call('PUT', '/ops/api/dr/config', Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === 'external_store' ? x : Number(x)]))); reload(); },
'dr-snapshot': async () => { await call('POST', '/ops/api/dr/snapshots'); toast('복구 사본을 만들었습니다'); reload(); },
'dr-drill': async (b) => { await call('POST', '/ops/api/dr/drills', { snapshotId: b.id || null }); toast('훈련을 시작했습니다 — 실제 복구 뒤 결과를 입력하세요'); reload(); },
'dr-drill-finish': async (b) => { const v = await ask('훈련 결과', [{ name: 'result', label: '결과', type: 'select', options: ['success', 'failed'] }, { name: 'measuredRtoMinutes', label: '측정 RTO(분)', type: 'number', required: true }, { name: 'measuredRpoMinutes', label: '측정 RPO(분)', type: 'number', required: true }, { name: 'findings', label: '실패·조치 항목(줄바꿈)', type: 'textarea' }, { name: 'steps', label: '수행 단계(쉼표)', value: '운영자 로그인,HAL 제어,HTTPS,고객 서비스 외부 확인' }]); if (!v) return; await call('POST', `/ops/api/dr/drills/${b.id}/finish`, { result: v.result, measuredRtoMinutes: Number(v.measuredRtoMinutes), measuredRpoMinutes: Number(v.measuredRpoMinutes), findings: v.findings.split('\n').filter(Boolean), steps: v.steps.split(',').map((s) => ({ id: s.trim(), ok: v.result === 'success' })) }); reload(); },
'dr-restore': async (b) => { const v = await ask('복구 요청', [{ name: 'note', label: '이유', type: 'textarea', required: true }], { danger: true, note: '복구 시작에는 별도 승인과 기존 제어 위치 중지 확인이 필요합니다. 복구 후 HAL 하나만 운영 상태여야 합니다(§34).' }); if (!v) return; await call('POST', '/ops/api/dr/restores', { snapshotId: b.id, note: v.note }); reload(); },
'dr-approve': async (b) => { await call('POST', `/ops/api/dr/restores/${b.id}/approve`); reload(); },
'dr-start': async (b) => { const v = await ask('복구 시작', [{ name: 'oldControlStopped', label: '기존 제어 위치(이전 HAL)를 중지했음을 확인합니다', type: 'checkbox' }, { name: 'confirmText', label: '확인 문구 입력: 복구 시작', required: true }], { danger: true }); if (!v) return; const r = await call('POST', `/ops/api/dr/restores/${b.id}/start`, v); toast(r.message || '복구를 시작했습니다'); reload(); },
'dr-finish': async (b) => { const v = await ask('복구 완료 — 실제 확인 입력', [{ name: 'login', label: '실제 운영자 로그인 확인', type: 'checkbox' }, { name: 'control', label: 'HAL 제어 확인', type: 'checkbox' }, { name: 'https', label: 'HTTPS 확인', type: 'checkbox' }, { name: 'customer', label: '고객 서비스 한 개 이상 외부 확인', type: 'checkbox' }]); if (!v) return; await call('POST', `/ops/api/dr/restores/${b.id}/finish`, { checks: v }); reload(); },
password: async () => { const v = await ask('비밀번호 변경', [{ name: 'current', label: '현재 비밀번호', type: 'password', required: true }, { name: 'next', label: '새 비밀번호(10자 이상)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/password', v); toast('바꿨습니다(다른 세션 종료)'); reload(); },
'mfa-begin': async () => { const r = await call('POST', '/ops/api/mfa/begin'); const v = await ask('MFA 등록', [{ name: 'code', label: `인증 앱에 추가(otpauth): ${r.otpauth} — 수동 키: ${r.secret}. 앱의 6자리 코드를 입력하세요`, required: true }]); if (!v) return; const c = await call('POST', '/ops/api/mfa/confirm', { code: v.code }); showOnce('복구 코드(1회만 표시)', c.recoveryCodes.join('\n'), '각 코드는 한 번만 쓸 수 있습니다.'); setTimeout(reload, 300); },
'mfa-disable': async () => { const v = await ask('MFA 해제', [{ name: 'code', label: '인증 코드', required: true }], { danger: true }); if (!v) return; await call('POST', '/ops/api/mfa/disable', v); reload(); },
'session-revoke': async (b) => { await call('POST', '/ops/api/sessions/revoke', { id: b.id }); reload(); }, 'session-revoke-all': async () => { await call('POST', '/ops/api/sessions/revoke', { all: true }); reload(); },
'grant-issue': async () => { const v = await ask('접근권 발급', [{ name: 'accountId', label: '운영자 id(비우면 나)' }, { name: 'hours', label: '유효 시간(1~72시간)', type: 'number', value: 8 }, { name: 'reason', label: '사유', required: true }]); if (!v) return; const r = await call('POST', '/ops/api/access-grants', { accountId: v.accountId || undefined, hours: Number(v.hours), reason: v.reason }); showOnce('접근권 URL(1회)', r.grant.url, `허용 네트워크 밖 기기에서 이 URL 을 열면 그 기기에 묶입니다. 만료 ${R.fmt(r.grant.expiresAt)}`); },
'grant-revoke': async (b) => { await call('POST', `/ops/api/access-grants/${b.id}/revoke`); reload(); },
'account-new': async () => { const v = await ask('운영자 추가', [{ name: 'login', label: '아이디', required: true }, { name: 'displayName', label: '표시 이름' }, { name: 'password', label: '초기 비밀번호(첫 로그인 뒤 변경 강제)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/accounts', v); reload(); },
// ---- 포털 중계 동작(§26.2·§27·§30)
'cust-change': async (b) => { const v = await ask('고객 대신 변경', [{ name: 'field', label: '항목', type: 'select', options: ['group.name', 'group.contact_email', 'group.contact_phone', 'site.name', 'site.maintenance', 'site.search_visible'] }, { name: 'subjectId', label: '사이트 id(사이트 항목일 때)' }, { name: 'value', label: '변경 후 값(참/거짓 항목은 1/0)', required: true }, { name: 'reason', label: '사유', required: true }, { name: 'approver', label: '승인자' }]); if (!v) return; const r = await portal('POST', `/customers/${b.g}/change`, { ...v, value: /\.(maintenance|search_visible)$/.test(v.field) ? v.value === '1' : v.value }); toast(`변경: ${JSON.stringify(r.change.before)} → ${JSON.stringify(r.change.after)}`); reload(); },
'support-issue': async (b) => { const v = await ask('고객 지원용 일시 접근', [{ name: 'targetType', label: '대상 종류', type: 'select', options: ['group', 'site', 'mail_service'] }, { name: 'targetId', label: '대상 id' }, { name: 'reason', label: '이유', required: true }, { name: 'hours', label: '만료(시간, 최대 24)', type: 'number', value: 4 }, { name: 'scope', label: '허용 범위(쉼표: view,change)', value: 'view' }, { name: 'approver', label: '승인자' }]); if (!v) return; await portal('POST', '/support-access', { ...v, groupId: b.g, hours: Number(v.hours), scope: v.scope.split(',').map((s) => s.trim()) }); reload(); },
'support-revoke': async (b) => { await portal('POST', `/support-access/${b.id}/revoke`, {}); reload(); },
'order-paid': async (b) => { const v = await ask('입금 확인', [{ name: 'ref', label: '입금 확인 참조(입금자·시각 등)', required: true }]); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/paid`, v); reload(); },
'order-approve': async (b) => { const o = await portal('GET', `/commerce/orders/${b.id}`); const od = o.order || {}; const v = await ask('주문 승인', [{ name: 'ok', label: `대상 ${od.group?.name || od.group_id} · 금액 ${od.total} ${od.currency} · 권리 변화 ${(od.entitlementDelta || []).map((e) => e.resource + '+' + e.amount).join(', ')} — 확인했습니다`, type: 'checkbox' }]); if (!v || !v.ok) return; const r = await portal('POST', `/commerce/orders/${b.id}/approve`, {}); toast(r.already ? '이미 승인된 주문입니다(권리 중복 생성 없음)' : `승인 — 권리 ${r.granted}건 활성화`); reload(); },
'order-reject': async (b) => { const v = await ask('주문 거절', [{ name: 'reason', label: '사유(고객에게 표시)', required: true }], { danger: true }); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/reject`, v); reload(); },
'order-refund': async (b) => { const v = await ask('환불·크레딧', [{ name: 'kind', label: '종류', type: 'select', options: ['refund', 'credit', 'adjust'] }, { name: 'amount', label: '금액', type: 'number', required: true }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', '/commerce/credits', { groupId: b.g, orderId: b.id, kind: v.kind, amount: Number(v.amount), reason: v.reason }); reload(); },
'product-new-version': async (b) => { const v = await ask('새 상품 버전', [{ name: 'patch', label: '변경 내용(JSON, 예 {"price": 12000})', type: 'textarea', required: true }], { note: '판매된 버전은 바꾸지 않고 새 버전을 만듭니다. 기존 고객은 자기 버전을 유지합니다.' }); if (!v) return; let patch; try { patch = JSON.parse(v.patch); } catch (_) { return toast('JSON 형식이 아닙니다'); } await portal('POST', '/commerce/products', { productId: b.p, baseVersionId: b.id, patch }); reload(); },
'product-publish': async (b) => { await portal('POST', `/commerce/products/${b.id}/publish`, { published: b.pub === '1' }); reload(); },
'catalog-seed': async () => { await portal('POST', '/commerce/catalog/seed', {}); reload(); },
'site-refresh': async (b) => { await portal('POST', `/sites/${b.id}/refresh`, {}); reload(); },
'mail-rates': async (b) => { const v = await ask('발송 제한·제출량 조회', [{ name: 'domain', label: '도메인(선택)' }, { name: 'reason', label: '접근 사유(기록됨)', required: true }]); if (!v) return; const r = await portal('GET', `/mail/servers/${b.id}/rates?domain=${encodeURIComponent(v.domain || '')}`); modal(`<h2>발송 제한·제출량</h2><pre class="pre">${R.esc(JSON.stringify(r, null, 1))}</pre><div class="actions"><button class="btn" onclick="document.getElementById('modal').hidden=true">닫기</button></div>`); },
'mail-test': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/test`, {}); toast('외부 송수신 시험을 시작했습니다' + (r.job ? ' — 작업 ' + r.job.id : '')); },
'mail-security': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/security`, {}); modal(`<h2>DNS 보안 확인</h2><pre class="pre">${R.esc(JSON.stringify(r, null, 1))}</pre><div class="actions"><button class="btn" onclick="document.getElementById('modal').hidden=true">닫기</button></div>`); },
'mail-operating': async (b) => { const v = await ask('운영 상태(이전 중 두 서버 동시 처리 방지)', [{ name: 'operating', label: '상태', type: 'select', options: ['active', 'migrating_out', 'migrating_in', 'frozen'] }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', `/mail/services/${b.id}/operating`, v); reload(); },
'mail-cancel': async (b) => { const v = await ask('메일 서비스 정지·해지', [{ name: 'reason', label: '사유', required: true }, { name: 'ok', label: '송수신이 즉시 중지되고 30일 읽기·내보내기 뒤 삭제됩니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/mail/services/${b.id}/cancel`, { reason: v.reason }); reload(); },
'domains-sweep': async () => { await portal('POST', '/domains/sweep', {}); await portal('POST', '/source/sweep', {}).catch(() => {}); reload(); },
'backup-retry': async (b) => { await portal('POST', `/backup/options/${b.id}/retry`, {}); reload(); }, 'backup-override': async (b) => { const v = await ask('같은 서버 저장 허용(예외)', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/options/${b.id}/override-same-server`, v); reload(); },
'restore-approve': async (b) => { const d = await portal('GET', `/backup/restores/${b.id}`); const r = d.restore || d; const fc = d.finalConfirmation || {}; const v = await ask('복원 승인 — 최종 확인', [{ name: 'ok', label: `고객 그룹 ${fc.groupName || r.group_name} · 사이트 ${fc.siteName || r.site_name} · 도메인 ${fc.primaryDomain || r.primary_domain || '—'} · 백업 시각 ${R.fmt(fc.backupAt || r.backup_created_at)} — 위 내용을 최종 확인합니다`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/backup/restores/${b.id}/approve`, { confirm: fc }); reload(); },
'restore-reject': async (b) => { const v = await ask('복원 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/restores/${b.id}/reject`, v); reload(); }, 'restore-retry': async (b) => { await portal('POST', `/backup/restores/${b.id}/retry`, {}); reload(); },
'reloc-approve': async (b) => { const d = await portal('GET', `/relocation/${b.id}`); const fc = d.finalConfirmation || { subjectId: d.relocation?.subject_id, toServerId: d.relocation?.to_server_id }; const v = await ask('이전 승인 — 최종 확인', [{ name: 'scheduledAt', label: '작업 시간(ISO, 비우면 즉시)' }, { name: 'ok', label: `대상 ${fc.subjectName || fc.subjectId} → 서버 ${fc.toServerId} · 전환 직전 조건이 달라지면 자동 중지됩니다 — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/approve`, { scheduledAt: v.scheduledAt || null, confirm: fc }); reload(); },
'reloc-reject': async (b) => { const v = await ask('이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/relocation/${b.id}/reject`, v); reload(); },
'reloc-retry': async (b) => { await portal('POST', `/relocation/${b.id}/retry`, {}); reload(); }, 'reloc-resume': async (b) => { await portal('POST', `/relocation/${b.id}/resume`, {}); reload(); }, 'reloc-revert': async (b) => { const v = await ask('이전 되돌리기', [{ name: 'ok', label: '새 위치를 운영 위치로 확정하지 않고 이전 위치로 돌립니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/revert`, {}); reload(); },
'reloc-new': async () => { const v = await ask('플랫폼 사유 이전', [{ name: 'subjectType', label: '대상 종류', type: 'select', options: ['site', 'mail'] }, { name: 'subjectId', label: '대상 id', required: true }, { name: 'toServerId', label: '도착 서버 id(비우면 자동 선택)' }, { name: 'reason', label: '이유(장애 회피·용량 조정 — 고객 과금 없음)', required: true }]); if (!v) return; await portal('POST', '/relocation', v); reload(); },
'transfer-approve': async (b) => { const d = await portal('GET', `/transfer/${b.id}`); const fc = d.finalConfirmation || {}; const v = await ask('소유권 이전 승인', [{ name: 'proof', label: '소유 증빙 확인 내용', required: true }, { name: 'domains', label: '도메인 확인 결과', required: true }, { name: 'ok', label: `${fc.fromGroupId} → ${fc.toGroupId} 전체가 한 번에 이전됩니다(일부만 이전되지 않음) — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/transfer/${b.id}/approve`, { review: { proof: v.proof, domains: v.domains }, confirm: fc }); reload(); },
'transfer-reject': async (b) => { const v = await ask('소유권 이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/transfer/${b.id}/reject`, v); reload(); },
'transfer-resolve': async (b) => { const v = await ask('분쟁 해결', [{ name: 'resolution', label: '결정', type: 'select', options: ['continue', 'cancel'] }, { name: 'note', label: '근거', required: true }]); if (!v) return; await portal('POST', `/transfer/${b.id}/resolve-dispute`, v); reload(); },
};
document.addEventListener('click', (e) => { const el = e.target.closest('[data-act]'); if (!el) return; e.preventDefault(); const fn = ACT[el.dataset.act]; if (fn) guarded(() => fn(el.dataset)); });
document.addEventListener('submit', (e) => { const f = e.target.closest('[data-form=customer-search]'); if (f) { e.preventDefault(); location.href = '/ops/customers?q=' + encodeURIComponent(new FormData(f).get('q') || ''); } });
// ---------------- 부팅
const main = document.querySelector('[data-page]'); const page = main?.dataset.page; const id = main?.dataset.id;
if (page && PAGES[page]) { guarded(() => PAGES[page](id)); if (['overview', 'jobs', 'job', 'servers'].includes(page)) setInterval(() => { if (!document.hidden) guarded(() => PAGES[page](id)); }, 15000); }