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:
+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