HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
54db663531
commit
9f1e490b51
@@ -66,6 +66,8 @@ const PAGES = {
|
||||
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)) 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(); },
|
||||
// [0.4.0] 통합 설정 관제: 범위 바꾸면 그 범위로 다시 연다
|
||||
settings: () => { const sel = $('#settings-scope'); if (sel) sel.onchange = () => { location.href = `/ops/settings?module=${encodeURIComponent(sel.dataset.module)}&scope=${encodeURIComponent(sel.value)}`; }; },
|
||||
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; } }; },
|
||||
};
|
||||
@@ -87,6 +89,13 @@ const ACT = {
|
||||
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; },
|
||||
// ---- [0.4.0] 통합 설정 관제: 저장(재인증) · 비밀값 삭제 · 서버에 적용(서버 선택·재시작 여부 → 작업)
|
||||
'settings-save': async () => { const f = $('#settings-form'); if (!f) return; const values = {}; for (const el of f.querySelectorAll('[data-key]')) { const k = el.dataset.key; const v = el.value; if (el.dataset.type === 'secret') { if (v !== '') values[k] = v; continue; } values[k] = v === '' ? null : v; }
|
||||
const r = await call('PUT', `/ops/api/settings/${f.dataset.module}`, { scope: f.dataset.scope, values }); toast(r.changed.length ? `저장했습니다(${r.changed.length}개${r.restartNeeded ? ' · 재시작 필요 항목 포함' : ''}) — "서버에 적용"으로 반영하세요` : '바뀐 항목이 없습니다'); reload(); },
|
||||
'settings-delete': async (b) => { const f = $('#settings-form'); if (!f || !confirm(`${b.key} 비밀값을 HAL 에서 지웁니다(서버에는 다음 적용 때 반영)`)) return; await call('DELETE', `/ops/api/settings/${f.dataset.module}/${encodeURIComponent(b.key)}?scope=${encodeURIComponent(f.dataset.scope)}`); reload(); },
|
||||
'settings-apply': async (b) => { const sv = await api('GET', `/ops/api/settings/${b.m}`); const servers = (sv.placements || []).map((p) => ({ id: p.id, name: p.name, status: p.status === 'isolated' ? 'isolated' : 'ready' })); const restartDefault = !!(sv.catalog && sv.catalog.restartDefault);
|
||||
const v = await ask(`${(sv.catalog && sv.catalog.label) || b.m} 설정 적용 — 대상 서버`, [{ name: 'serverIds', label: '대상 서버(이 모듈이 배치된 서버)', type: 'checks', html: R.serverChoiceHtml(servers, { name: 'serverIds', multi: true, eligible: b.s ? [b.s] : null }) }, { name: 'restart', label: '적용 뒤 서비스 재시작(재시작 필요 항목을 바꾼 경우)', type: 'checkbox', value: restartDefault }], { okLabel: '적용 작업 만들기', note: 'Agent 가 HAL 에서 설정을 직접 받아 모듈 데이터 디렉터리의 hal-managed.json 에 쓰고, 모듈이 적용한 결과(hash)를 확인합니다.' });
|
||||
if (!v) return; if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/settings/${b.m}/apply`, { serverIds: v.serverIds, restart: !!v.restart, nonce: Date.now() }); toast(`설정 적용 작업을 만들었습니다(${r.targets.length}대)`); 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 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
|
||||
|
||||
+32
-1
@@ -118,6 +118,37 @@ export function serverChoiceHtml(servers, { name = 'serverId', multi = false, el
|
||||
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>`;
|
||||
}
|
||||
// ---------------- [0.4.0] 통합 설정 관제
|
||||
const SRC_LABEL = { global: '전체', server: '이 서버' };
|
||||
const showVal = (v) => Array.isArray(v) ? v.join(', ') : typeof v === 'boolean' ? (v ? '켬' : '끔') : String(v);
|
||||
/** 설정 입력 폼. cat = 설정 목록의 모듈 하나(groups·keys), scope = 'global' | 'server:<id>'. 비밀값은 값을 보이지 않고 설정됨/없음과 새 값 입력만. 전체 범위에는 서버별 항목이 없다. */
|
||||
export function settingsFormHtml(cat, { scope = 'global', values = {}, secrets = {}, effective = null } = {}) {
|
||||
const isServer = String(scope).startsWith('server:');
|
||||
const field = (k) => {
|
||||
const v = values[k.key]; const has = v !== undefined && v !== null; const inh = isServer && effective && effective.sources && effective.sources[k.key] === 'global' && !has ? `<span class="muted small">전체 값 ${esc(showVal(effective.values[k.key]))}</span>` : '';
|
||||
const tags = `${k.restart ? ' <span class="badge neutral">재시작 필요</span>' : ''}`; const id = `set-${esc(k.key.replace(/[^a-zA-Z0-9_-]/g, '_'))}`;
|
||||
let input;
|
||||
if (k.type === 'secret') { const on = !!secrets[k.key]; input = `<input id="${id}" type="password" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="secret" autocomplete="new-password" placeholder="새 값 입력(비우면 그대로)"> <span class="${on ? 'ok' : 'muted'}">${on ? '● 설정됨' : '○ 없음'}</span>${on ? ` <button type="button" class="btn small" data-act="settings-delete" data-key="${esc(k.key)}">삭제</button>` : ''}`; }
|
||||
else if (k.type === 'bool') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="bool"><option value="">(지정 안 함)</option><option value="true"${v === true ? ' selected' : ''}>켬</option><option value="false"${v === false ? ' selected' : ''}>끔</option></select>`;
|
||||
else if (k.type === 'enum') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="enum"><option value="">(지정 안 함)</option>${(k.options || []).map((o) => `<option value="${esc(o)}"${v === o ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
|
||||
else input = `<input id="${id}" type="${['int', 'number'].includes(k.type) ? 'number' : 'text'}"${k.type === 'number' ? ' step="any"' : ''}${k.min !== undefined ? ` min="${k.min}"` : ''}${k.max !== undefined ? ` max="${k.max}"` : ''} name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="${esc(k.type)}" value="${has ? esc(showVal(v)) : ''}"${k.hint ? ` placeholder="${esc(k.hint)}"` : ''}>`;
|
||||
return `<div class="set-field"><label for="${id}">${esc(k.label)} <span class="mono muted small">${esc(k.key)}</span>${tags}</label><div>${input} ${inh}</div></div>`;
|
||||
};
|
||||
const groups = (cat.groups || []).map((g) => { const keys = g.keys.filter((k) => isServer || k.scope !== 'server'); if (!keys.length) return ''; return `<fieldset class="set-group"><legend>${esc(g.label)}</legend>${keys.map(field).join('')}</fieldset>`; }).join('');
|
||||
return `<form id="settings-form" class="set-form" data-module="${esc(cat.module)}" data-scope="${esc(scope)}" autocomplete="off">${groups}<div class="actions"><button type="button" class="btn primary" data-act="settings-save">저장(${isServer ? '이 서버 덮어쓰기' : '전체'})</button> <span class="muted small">비운 칸은 지정을 지웁니다(${isServer ? '전체 값을 따름' : '모듈 기본값을 따름'}). 비밀값은 새 값을 넣을 때만 바뀝니다. 저장만으로는 서버에 반영되지 않습니다 — 아래 "서버에 적용"을 누르세요.</span></div></form>`;
|
||||
}
|
||||
/** 서버별 적용 상태 표 */
|
||||
export function settingsStateHtml(rows, { module = '' } = {}) {
|
||||
const st = (r) => r.status === 'failed' ? badge('failed', '적용 실패') : r.status === 'ok' && r.upToDate ? badge('ok', '적용됨') : r.status === 'ok' ? badge('warn', '적용 필요(설정 변경됨)') : badge('warn', '적용 필요');
|
||||
return `${table([{ l: '서버', f: (r) => `<a href="/ops/settings?module=${esc(module)}&scope=server:${esc(r.serverId)}"><b>${esc(r.serverName || r.serverId)}</b></a><br><span class="muted small">${esc(r.serverStatus || '')}</span>` }, { l: '상태', f: st }, { l: '적용 시각', f: (r) => fmt(r.appliedAt) }, { l: '적용된 설정', f: (r) => `<span class="mono small">${esc(String(r.appliedHash || '—').slice(0, 19))}</span>` }, { l: '오류', f: (r) => r.error ? `<span class="err small">${esc(r.error)}</span>` : '—' }, { l: '', f: (r) => `<button class="btn small" data-act="settings-apply" data-m="${esc(module)}" data-s="${esc(r.serverId)}">이 서버에 적용</button>` }], rows || [], { empty: '이 모듈이 배치된 서버가 없습니다 — 배포 릴리스에서 먼저 배포하세요' })}<div class="actions"><button class="btn primary" data-act="settings-apply" data-m="${esc(module)}">서버에 적용(선택)</button></div>`;
|
||||
}
|
||||
/** 통합 설정 관제 화면 본문 */
|
||||
export function settingsPageHtml({ modules = [], catalog = {}, module, scope = 'global', placements = [], stored = {}, effective = null, overview = {} }) {
|
||||
const cat = catalog[module] || { groups: [] }; const ov = overview[module] || { servers: [] };
|
||||
const tabs = `<div class="tabs">${modules.map((m) => `<a href="/ops/settings?module=${esc(m)}" class="${m === module ? 'on' : ''}">${esc((catalog[m] || {}).label || m)}${overview[m] && overview[m].stale ? ` <span class="badge warn">${overview[m].stale}</span>` : ''}</a>`).join('')}</div>`;
|
||||
const scopeSel = `<div class="row"><label>범위 <select id="settings-scope" data-module="${esc(module)}"><option value="global"${scope === 'global' ? ' selected' : ''}>전체(모든 서버 기본값)</option>${placements.map((p) => `<option value="server:${esc(p.id)}"${scope === 'server:' + p.id ? ' selected' : ''}>서버 ${esc(p.name)}(덮어쓰기)</option>`).join('')}</select></label></div>`;
|
||||
return `${tabs}<p class="muted">${esc(cat.description || '')}</p>${scopeSel}${settingsFormHtml(cat, { scope, values: stored.values || {}, secrets: stored.secrets || {}, effective })}<h2>서버별 적용 상태</h2><div id="settings-state">${settingsStateHtml(ov.servers, { module })}</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: '진행 중이거나 기록된 사건이 없습니다' })}
|
||||
<div class="actions"><button class="btn primary" data-act="incident-new">사건 등록</button></div>
|
||||
@@ -154,7 +185,7 @@ export function overviewView(o) {
|
||||
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>
|
||||
<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>` : '없음'], o.settings ? ['설정 적용 필요', o.settings.stale ? `<a class="err" href="/ops/settings">${o.settings.stale}건</a>` : '없음'] : null])}<a class="btn" href="/ops/servers">서버와 용량</a> <a class="btn" href="/ops/settings">통합 설정</a></div>
|
||||
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
|
||||
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
|
||||
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
|
||||
|
||||
Reference in New Issue
Block a user