guardia-itsm/static/agents.html
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

673 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA — AI 에이전트</title>
<link rel="stylesheet" href="/static/style.css">
<style>
/* ── 에이전트 대시보드 전용 스타일 ── */
.agents-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; margin-top:16px; }
.agent-card {
background:var(--bg-card);
border:1px solid var(--border);
border-radius:10px;
padding:18px;
position:relative;
transition:box-shadow .2s;
}
.agent-card:hover { box-shadow:0 4px 20px rgba(0,0,0,.3); }
.agent-card.error { border-color:#ef4444; }
.agent-card.paused { opacity:.6; }
.agent-role-badge {
display:inline-block; font-size:11px; font-weight:600; padding:2px 8px;
border-radius:4px; margin-bottom:8px; text-transform:uppercase; letter-spacing:.5px;
}
.role-CEO { background:#7c3aed22; color:#a78bfa; border:1px solid #7c3aed44; }
.role-CTO { background:#2563eb22; color:#60a5fa; border:1px solid #2563eb44; }
.role-DEVELOPER { background:#059669222; color:#34d399; border:1px solid #05966944; }
.role-QA { background:#d9770622; color:#fb923c; border:1px solid #d9770644; }
.role-PM_AGENT { background:#0891b222; color:#22d3ee; border:1px solid #0891b244; }
.role-INCIDENT_TRIAGE{ background:#dc262622; color:#f87171; border:1px solid #dc262644; }
.role-KB_CURATOR { background:#d9770622; color:#fdba74; border:1px solid #d9770644; }
.role-SSL_WATCHER { background:#ca8a0422; color:#fcd34d; border:1px solid #ca8a0444; }
.role-WBS_MONITOR { background:#65a30d22; color:#a3e635; border:1px solid #65a30d44; }
.role-PM_SUGGESTER { background:#0d948822; color:#2dd4bf; border:1px solid #0d948844; }
.agent-status-dot {
width:8px; height:8px; border-radius:50%; display:inline-block; margin-right:6px;
}
.status-IDLE { background:#6b7280; }
.status-ACTIVE { background:#3b82f6; animation:pulse 1s infinite; }
.status-WORKING { background:#f59e0b; animation:pulse .7s infinite; }
.status-ERROR { background:#ef4444; }
.status-PAUSED { background:#6b7280; }
@keyframes pulse {
0%,100% { opacity:1; }
50% { opacity:.4; }
}
.agent-name { font-size:15px; font-weight:600; color:var(--text); margin-bottom:4px; }
.agent-meta { font-size:12px; color:var(--text-muted); margin-bottom:10px; }
.agent-stats { display:flex; gap:16px; font-size:12px; }
.agent-stat-item { text-align:center; }
.agent-stat-val { font-size:18px; font-weight:700; color:var(--accent); }
.agent-stat-lbl { color:var(--text-muted); font-size:11px; }
.agent-actions { display:flex; gap:8px; margin-top:12px; }
.btn-xs {
padding:4px 10px; font-size:11px; border-radius:4px; cursor:pointer;
border:1px solid var(--border); background:var(--bg); color:var(--text);
transition:background .15s;
}
.btn-xs:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
.btn-xs.danger:hover { background:#ef4444; border-color:#ef4444; }
/* ── 조직도 ── */
.orgchart { padding:20px 0; overflow-x:auto; }
.org-tree { display:flex; flex-direction:column; align-items:center; }
.org-level { display:flex; gap:24px; justify-content:center; margin:8px 0; }
.org-node {
background:var(--bg-card); border:1px solid var(--border); border-radius:8px;
padding:10px 16px; min-width:140px; text-align:center; position:relative;
}
.org-node::before {
content:''; position:absolute; top:-8px; left:50%; transform:translateX(-50%);
width:1px; height:8px; background:var(--border);
}
.org-node-top::before { display:none; }
.org-role { font-size:11px; color:var(--text-muted); margin-bottom:4px; }
.org-name { font-size:13px; font-weight:600; }
/* ── 승인 대기 ── */
.approval-list { display:flex; flex-direction:column; gap:10px; margin-top:12px; }
.approval-item {
background:var(--bg-card); border:1px solid var(--border); border-radius:8px;
padding:14px 16px;
}
.approval-item.critical { border-left:3px solid #ef4444; }
.approval-type { font-size:11px; color:var(--text-muted); margin-bottom:4px; }
.approval-title { font-size:13px; font-weight:600; margin-bottom:8px; }
.approval-meta { font-size:11px; color:var(--text-muted); margin-bottom:10px; }
.approval-btns { display:flex; gap:8px; }
.btn-approve {
padding:5px 14px; font-size:12px; border-radius:4px; cursor:pointer;
background:#10b981; color:#fff; border:none;
}
.btn-reject {
padding:5px 14px; font-size:12px; border-radius:4px; cursor:pointer;
background:var(--bg); color:#ef4444; border:1px solid #ef444466;
}
/* ── 태스크 피드 ── */
.task-feed { max-height:400px; overflow-y:auto; }
.task-item {
display:flex; gap:12px; padding:10px 0;
border-bottom:1px solid var(--border);
}
.task-item:last-child { border-bottom:none; }
.task-icon { width:32px; height:32px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; flex-shrink:0; background:var(--bg-card); }
.task-body { flex:1; min-width:0; }
.task-title { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.task-meta { font-size:11px; color:var(--text-muted); margin-top:2px; }
.task-status-badge {
font-size:10px; padding:2px 6px; border-radius:3px; font-weight:600;
white-space:nowrap;
}
.s-COMPLETED { background:#10b98122; color:#34d399; }
.s-IN_PROGRESS { background:#f59e0b22; color:#fbbf24; }
.s-FAILED { background:#ef444422; color:#f87171; }
.s-PENDING { background:#6b728022; color:#9ca3af; }
/* ── LLM 상태 배너 ── */
.llm-banner {
display:flex; align-items:center; gap:10px; padding:10px 16px;
border-radius:8px; margin-bottom:16px; font-size:13px;
}
.llm-online { background:#10b98118; border:1px solid #10b98144; color:#34d399; }
.llm-offline { background:#ef444418; border:1px solid #ef444444; color:#f87171; }
/* ── 비용 트래커 ── */
.cost-bar-wrap { background:var(--border); border-radius:4px; height:8px; margin:8px 0; }
.cost-bar { height:8px; border-radius:4px; background:var(--accent); transition:width .5s; }
.cost-bar.warn { background:#f59e0b; }
.cost-bar.danger { background:#ef4444; }
/* ── 섹션 헤더 ── */
.section-header {
display:flex; align-items:center; justify-content:space-between;
margin:24px 0 12px;
}
.section-title { font-size:16px; font-weight:600; }
.badge-count {
background:var(--accent); color:#fff; font-size:11px; padding:2px 7px;
border-radius:10px; font-weight:600;
}
/* ── 통계 카드 ── */
.stat-row { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:20px; }
.stat-box {
flex:1; min-width:120px; background:var(--bg-card); border:1px solid var(--border);
border-radius:8px; padding:14px; text-align:center;
}
.stat-box-val { font-size:28px; font-weight:700; color:var(--accent); }
.stat-box-lbl { font-size:11px; color:var(--text-muted); margin-top:4px; }
.empty-state { text-align:center; color:var(--text-muted); padding:40px; font-size:14px; }
</style>
</head>
<body>
<script>document.body.dataset.theme = localStorage.getItem("guardia_theme") || "dark";</script>
<div id="app">
<!-- ── Sidebar (기존 GUARDiA 사이드바 동일) ── -->
<aside id="sidebar">
<div id="sidebar-logo">
<div class="logo-icon">G</div>
<div>
<div class="logo-title">GUARDiA ITSM</div>
<div class="logo-sub">인프라 자동화 플랫폼</div>
</div>
</div>
<nav id="sidebar-nav">
<div class="nav-item" data-href="/" onclick="location.href='/'">
<span class="nav-icon">📊</span> 대시보드
</div>
<div class="nav-item active">
<span class="nav-icon">🤖</span> AI 에이전트
</div>
<div class="nav-separator"></div>
<div class="nav-item" onclick="location.href='/'">
<span class="nav-icon">🗂️</span> SR 관리
</div>
<div class="nav-item" onclick="location.href='/'">
<span class="nav-icon">🔐</span> 감사 로그
</div>
</nav>
<div id="sidebar-footer">
<div id="llm-status-dot" class="status-dot offline"></div>
<span id="llm-status-text">LLM 확인 중...</span>
</div>
</aside>
<!-- ── Main Content ── -->
<main id="main-content">
<div id="page-header">
<h1 class="page-title">🤖 AI 에이전트 관리</h1>
<div style="display:flex;gap:10px;align-items:center;">
<span id="approval-badge" class="badge-count" style="display:none"></span>
<button class="btn btn-primary" onclick="openCreateModal()">+ 에이전트 등록</button>
<button class="btn" onclick="loadAll()">🔄 새로고침</button>
</div>
</div>
<!-- LLM 상태 배너 -->
<div id="llm-banner" class="llm-banner llm-offline">
<span id="llm-icon">⚠️</span>
<span id="llm-msg">Ollama 서버 상태 확인 중...</span>
<a href="javascript:checkLLM()" style="margin-left:auto;font-size:12px;color:inherit;">다시 확인</a>
</div>
<!-- 통계 카드 -->
<div class="stat-row" id="stat-row">
<div class="stat-box"><div class="stat-box-val" id="st-total">-</div><div class="stat-box-lbl">전체 에이전트</div></div>
<div class="stat-box"><div class="stat-box-val" id="st-active">-</div><div class="stat-box-lbl">활성 에이전트</div></div>
<div class="stat-box"><div class="stat-box-val" id="st-tasks">-</div><div class="stat-box-lbl">오늘 태스크</div></div>
<div class="stat-box"><div class="stat-box-val" id="st-tokens">-</div><div class="stat-box-lbl">오늘 토큰</div></div>
<div class="stat-box"><div class="stat-box-val" id="st-pending" style="color:#f59e0b">-</div><div class="stat-box-lbl">승인 대기</div></div>
</div>
<!---->
<div class="tab-bar" id="agent-tabs">
<button class="tab active" onclick="switchTab('agents')">에이전트</button>
<button class="tab" onclick="switchTab('orgchart')">조직도</button>
<button class="tab" onclick="switchTab('approvals')">승인 대기 <span id="tab-approval-cnt"></span></button>
<button class="tab" onclick="switchTab('tasks')">태스크 피드</button>
</div>
<!-- ── 탭: 에이전트 카드 ── -->
<div id="tab-agents">
<div class="agents-grid" id="agents-grid">
<div class="empty-state">에이전트를 불러오는 중...</div>
</div>
</div>
<!-- ── 탭: 조직도 ── -->
<div id="tab-orgchart" style="display:none;">
<div class="orgchart">
<div class="org-tree" id="org-tree">
<div class="empty-state">조직도를 불러오는 중...</div>
</div>
</div>
</div>
<!-- ── 탭: 승인 대기 ── -->
<div id="tab-approvals" style="display:none;">
<div style="display:flex;gap:10px;align-items:center;margin-bottom:12px;">
<label style="font-size:13px;color:var(--text-muted);">
<input type="checkbox" id="chk-pending-only" checked onchange="loadApprovals()">
승인 대기만 보기
</label>
</div>
<div class="approval-list" id="approval-list">
<div class="empty-state">승인 대기 항목 없음 ✅</div>
</div>
</div>
<!-- ── 탭: 태스크 피드 ── -->
<div id="tab-tasks" style="display:none;">
<div class="task-feed" id="task-feed">
<div class="empty-state">최근 태스크가 없습니다.</div>
</div>
</div>
</main>
</div>
<!-- ── 에이전트 생성 모달 ── -->
<div id="create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1000;display:none;align-items:center;justify-content:center;">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:28px;width:480px;max-width:95vw;">
<h3 style="margin:0 0 20px;font-size:16px;">🤖 에이전트 등록</h3>
<div class="form-group">
<label class="form-label">에이전트 이름</label>
<input id="inp-name" class="form-input" placeholder="예: 인시던트 트리아지 에이전트">
</div>
<div class="form-group">
<label class="form-label">역할</label>
<select id="inp-role" class="form-input">
<option value="INCIDENT_TRIAGE">INCIDENT_TRIAGE — 인시던트 자동 분류</option>
<option value="KB_CURATOR">KB_CURATOR — 지식베이스 자동 생성</option>
<option value="SSL_WATCHER">SSL_WATCHER — SSL 만료 모니터링</option>
<option value="WBS_MONITOR">WBS_MONITOR — WBS 위험 분석</option>
<option value="PM_SUGGESTER">PM_SUGGESTER — PM 스케줄 권고</option>
<option value="CEO">CEO — 개발 총괄</option>
<option value="CTO">CTO — 기술 총괄</option>
<option value="DEVELOPER">DEVELOPER — 코드 생성</option>
<option value="QA">QA — 테스트 생성</option>
<option value="PM_AGENT">PM_AGENT — 일정 관리</option>
</select>
</div>
<div class="form-group">
<label class="form-label">LLM 모델 (Ollama)</label>
<input id="inp-model" class="form-input" value="guardia-agent" placeholder="guardia-agent">
</div>
<div class="form-group">
<label class="form-label">하트비트 크론 (비우면 수동 실행만)</label>
<input id="inp-cron" class="form-input" placeholder="예: */15 * * * * (15분마다)">
</div>
<div class="form-group">
<label class="form-label">시스템 프롬프트 (선택)</label>
<textarea id="inp-prompt" class="form-input" rows="3" placeholder="에이전트 역할 지침..."></textarea>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px;">
<button class="btn" onclick="closeCreateModal()">취소</button>
<button class="btn btn-primary" onclick="createAgent()">등록</button>
</div>
</div>
</div>
<script>
// ── 인증 토큰 ────────────────────────────────────────────────────────────────
const token = () => localStorage.getItem('guardia_token') || '';
const headers = () => ({ 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' });
async function api(path, opt = {}) {
const r = await fetch('/api/agents' + path, { ...opt, headers: headers() });
if (r.status === 401) { location.href = '/login'; return null; }
if (!r.ok) { const e = await r.json().catch(()=>({detail:'오류'})); throw new Error(e.detail || r.statusText); }
if (r.status === 204) return null;
return r.json();
}
// ── 탭 전환 ──────────────────────────────────────────────────────────────────
let currentTab = 'agents';
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
['agents','orgchart','approvals','tasks'].forEach(t => {
document.getElementById(`tab-${t}`).style.display = t === tab ? '' : 'none';
});
if (tab === 'agents') renderAgents();
if (tab === 'orgchart') loadOrgchart();
if (tab === 'approvals') loadApprovals();
if (tab === 'tasks') loadTasks();
}
// ── 전체 로드 ────────────────────────────────────────────────────────────────
async function loadAll() {
await Promise.all([checkLLM(), loadStats(), renderAgents()]);
}
// ── LLM 헬스체크 ────────────────────────────────────────────────────────────
async function checkLLM() {
try {
const d = await api('/llm/health');
if (!d) return;
const banner = document.getElementById('llm-banner');
const dot = document.getElementById('llm-status-dot');
const txt = document.getElementById('llm-status-text');
if (d.online) {
banner.className = 'llm-banner llm-online';
document.getElementById('llm-icon').textContent = '✅';
document.getElementById('llm-msg').textContent =
`Ollama 온라인 (${d.base_url}) — 모델 ${d.models.length}개 설치됨: ${d.models.map(m=>m.name).join(', ')}`;
dot.className = 'status-dot online';
txt.textContent = 'LLM 정상';
} else {
banner.className = 'llm-banner llm-offline';
document.getElementById('llm-icon').textContent = '❌';
document.getElementById('llm-msg').textContent =
`Ollama 오프라인 (${d.base_url}) — C:\\GUARDiA\\ollama\\setup.ps1 실행 필요`;
dot.className = 'status-dot offline';
txt.textContent = 'LLM 오프라인';
}
} catch(e) {
console.warn('LLM 헬스체크 실패:', e);
}
}
// ── 통계 ────────────────────────────────────────────────────────────────────
async function loadStats() {
try {
const d = await api('/stats');
if (!d) return;
document.getElementById('st-total').textContent = d.total_agents;
document.getElementById('st-active').textContent = d.active_agents;
document.getElementById('st-tasks').textContent = d.total_tasks_today;
document.getElementById('st-tokens').textContent = d.total_tokens_today.toLocaleString();
document.getElementById('st-pending').textContent = d.pending_approvals;
const cnt = d.pending_approvals;
const badge = document.getElementById('approval-badge');
const tabCnt = document.getElementById('tab-approval-cnt');
if (cnt > 0) {
badge.style.display = '';
badge.textContent = cnt;
tabCnt.textContent = ` (${cnt})`;
} else {
badge.style.display = 'none';
tabCnt.textContent = '';
}
} catch(e) { console.warn('통계 로드 실패:', e); }
}
// ── 에이전트 카드 렌더링 ────────────────────────────────────────────────────
let agentData = [];
async function renderAgents() {
try {
agentData = await api('') || [];
const grid = document.getElementById('agents-grid');
if (!agentData.length) {
grid.innerHTML = '<div class="empty-state">등록된 에이전트가 없습니다.<br>위의 "+ 에이전트 등록" 버튼으로 추가하세요.</div>';
return;
}
grid.innerHTML = agentData.map(a => `
<div class="agent-card ${a.status === 'ERROR' ? 'error' : a.status === 'PAUSED' ? 'paused' : ''}">
<div class="agent-role-badge role-${a.role}">${a.role.replace('_',' ')}</div>
<div class="agent-name">
<span class="agent-status-dot status-${a.status}"></span>${a.name}
</div>
<div class="agent-meta">
${a.llm_model} &nbsp;·&nbsp;
${a.heartbeat_cron ? '⏰ ' + a.heartbeat_cron : '수동 실행'}
${a.last_heartbeat ? ' &nbsp;·&nbsp; 마지막: ' + relTime(a.last_heartbeat) : ''}
</div>
<div class="agent-stats">
<div class="agent-stat-item">
<div class="agent-stat-val">${a.total_tasks}</div>
<div class="agent-stat-lbl">누적 태스크</div>
</div>
<div class="agent-stat-item">
<div class="agent-stat-val">${(a.total_tokens||0).toLocaleString()}</div>
<div class="agent-stat-lbl">누적 토큰</div>
</div>
<div class="agent-stat-item">
<div class="agent-stat-val" style="color:${statusColor(a.status)}">${a.status}</div>
<div class="agent-stat-lbl">상태</div>
</div>
</div>
<div class="agent-actions">
<button class="btn-xs" onclick="manualHeartbeat(${a.id},'${a.name}')">▶ 실행</button>
${a.is_active
? `<button class="btn-xs" onclick="pauseAgent(${a.id})">⏸ 중지</button>`
: `<button class="btn-xs" onclick="resumeAgent(${a.id})">▶ 재개</button>`}
<button class="btn-xs" onclick="viewTasks(${a.id})">📋 태스크</button>
<button class="btn-xs danger" onclick="deleteAgent(${a.id},'${a.name}')">삭제</button>
</div>
</div>
`).join('');
} catch(e) {
document.getElementById('agents-grid').innerHTML = `<div class="empty-state">에이전트 로드 실패: ${e.message}</div>`;
}
}
function statusColor(s) {
return {IDLE:'#6b7280',ACTIVE:'#3b82f6',WORKING:'#f59e0b',ERROR:'#ef4444',PAUSED:'#6b7280'}[s]||'#6b7280';
}
function relTime(iso) {
const d = (Date.now() - new Date(iso+'Z')) / 1000;
if (d < 60) return `${Math.floor(d)}초 전`;
if (d < 3600) return `${Math.floor(d/60)}분 전`;
if (d < 86400) return `${Math.floor(d/3600)}시간 전`;
return `${Math.floor(d/86400)}일 전`;
}
// ── 조직도 ──────────────────────────────────────────────────────────────────
async function loadOrgchart() {
try {
const nodes = await api('/orgchart') || [];
const tree = document.getElementById('org-tree');
if (!nodes.length) { tree.innerHTML = '<div class="empty-state">CEO 에이전트가 등록되지 않았습니다.</div>'; return; }
function renderNode(node) {
const dot = `<span class="agent-status-dot status-${node.status}" style="display:inline-block"></span>`;
const children = node.children && node.children.length
? `<div style="display:flex;gap:16px;justify-content:center;margin-top:16px;position:relative">
${node.children.map(renderNode).join('')}
</div>`
: '';
return `<div style="display:flex;flex-direction:column;align-items:center">
<div class="org-node">
<div class="org-role">${node.role.replace('_',' ')}</div>
<div class="org-name">${dot} ${node.name}</div>
</div>
${children}
</div>`;
}
tree.innerHTML = `<div style="display:flex;gap:40px">${nodes.map(renderNode).join('')}</div>`;
} catch(e) {
document.getElementById('org-tree').innerHTML = `<div class="empty-state">조직도 로드 실패: ${e.message}</div>`;
}
}
// ── 승인 대기 ────────────────────────────────────────────────────────────────
const ACTION_LABELS = {
TRIAGE_INCIDENT: '인시던트 트리아지',
CREATE_SR: 'SR 자동 생성',
CREATE_KB: 'KB 자동 생성',
CREATE_RISK: '위험 자동 등록',
CODE_CHANGE: '코드 변경',
SUGGEST_PM: 'PM 스케줄 권고',
ASSIGN_ENGINEER: '엔지니어 배정',
};
async function loadApprovals() {
const pendingOnly = document.getElementById('chk-pending-only')?.checked ?? true;
try {
const items = await api(`/approvals?pending_only=${pendingOnly}`) || [];
const list = document.getElementById('approval-list');
if (!items.length) { list.innerHTML = '<div class="empty-state">승인 대기 항목 없음 ✅</div>'; return; }
list.innerHTML = items.map(a => {
const data = a.action_data || {};
const isCritical = data.severity === 'CRITICAL';
return `
<div class="approval-item ${isCritical ? 'critical' : ''}">
<div class="approval-type">
${isCritical ? '🔴 CRITICAL — ' : ''}
${ACTION_LABELS[a.action_type] || a.action_type}
&nbsp;·&nbsp; Agent #${a.agent_id}
</div>
<div class="approval-title">${formatApprovalTitle(a)}</div>
<div class="approval-meta">
요청: ${relTime(a.requested_at)}
${a.status !== 'PENDING' ? ` &nbsp;·&nbsp; 상태: <strong>${a.status}</strong>` : ''}
</div>
${a.action_data ? `<pre style="font-size:11px;color:var(--text-muted);margin:8px 0;overflow:auto;max-height:80px">${JSON.stringify(a.action_data,null,2)}</pre>` : ''}
${a.status === 'PENDING' ? `
<div class="approval-btns">
<button class="btn-approve" onclick="reviewApproval(${a.id},true)">✅ 승인</button>
<button class="btn-reject" onclick="reviewApproval(${a.id},false)">❌ 거부</button>
</div>` : ''}
</div>
`;
}).join('');
} catch(e) {
document.getElementById('approval-list').innerHTML = `<div class="empty-state">로드 실패: ${e.message}</div>`;
}
}
function formatApprovalTitle(a) {
const d = a.action_data || {};
if (a.action_type === 'TRIAGE_INCIDENT') return `인시던트 #${d.incident_ref || d.incident_id}${d.severity} / ${d.category}`;
if (a.action_type === 'CREATE_SR') return `SR 자동 생성 — 서버 ID ${d.server_id}, D-${d.days_left}`;
if (a.action_type === 'CREATE_KB') return `KB 생성: SR #${d.sr_id}${d.kb_doc_id}`;
if (a.action_type === 'CREATE_RISK') return `위험 등록: 프로젝트 #${d.project_id}, 점수 ${d.score}`;
if (a.action_type === 'CODE_CHANGE') return `코드 변경: ${(d.preview||'').slice(0,60)}...`;
if (a.action_type === 'SUGGEST_PM') return `PM 미등록: ${d.server_name}`;
return JSON.stringify(d).slice(0,80);
}
async function reviewApproval(id, approved) {
const notes = approved ? null : prompt('거부 사유를 입력하세요:');
if (!approved && notes === null) return;
try {
await api(`/approvals/${id}/review`, {
method: 'PATCH',
body: JSON.stringify({ approved, notes }),
});
showToast(approved ? '✅ 승인 완료' : '❌ 거부 완료');
loadApprovals();
loadStats();
} catch(e) { showToast('처리 실패: ' + e.message, true); }
}
// ── 태스크 피드 ──────────────────────────────────────────────────────────────
const TASK_ICONS = {
COMPLETED:'✅', IN_PROGRESS:'⚙️', FAILED:'❌', PENDING:'⏳', CANCELLED:'🚫'
};
async function loadTasks(agentId) {
try {
// 모든 에이전트의 태스크를 합쳐서 보여줌
const agents = agentData.length ? agentData : (await api('') || []);
const allTasks = [];
await Promise.all(agents.slice(0,10).map(async a => {
const tasks = await api(`/${a.id}/tasks?limit=10`) || [];
tasks.forEach(t => { t._agent_name = a.name; t._agent_role = a.role; });
allTasks.push(...tasks);
}));
allTasks.sort((a,b) => new Date(b.created_at) - new Date(a.created_at));
const feed = document.getElementById('task-feed');
if (!allTasks.length) { feed.innerHTML = '<div class="empty-state">태스크 없음</div>'; return; }
feed.innerHTML = allTasks.slice(0,50).map(t => `
<div class="task-item">
<div class="task-icon">${TASK_ICONS[t.status] || '❓'}</div>
<div class="task-body">
<div style="display:flex;align-items:center;gap:8px;">
<span class="task-title">${t.title}</span>
<span class="task-status-badge s-${t.status}">${t.status}</span>
</div>
<div class="task-meta">
${t._agent_name} (${t._agent_role.replace('_',' ')})
&nbsp;·&nbsp; ${relTime(t.created_at)}
${t.tokens_used ? ` &nbsp;·&nbsp; 토큰: ${t.tokens_used.toLocaleString()}` : ''}
</div>
${t.output_data && t.status === 'FAILED' ? `<div style="font-size:11px;color:#ef4444;margin-top:4px">${(t.output_data.error||'').slice(0,100)}</div>` : ''}
</div>
</div>
`).join('');
} catch(e) { document.getElementById('task-feed').innerHTML = `<div class="empty-state">로드 실패: ${e.message}</div>`; }
}
// ── 에이전트 제어 ────────────────────────────────────────────────────────────
async function manualHeartbeat(id, name) {
try {
await api(`/${id}/heartbeat`, { method: 'POST' });
showToast(`${name} 하트비트 시작`);
setTimeout(renderAgents, 2000);
} catch(e) { showToast('실행 실패: ' + e.message, true); }
}
async function pauseAgent(id) {
try {
await api(`/${id}/pause`, { method: 'POST' });
showToast('⏸ 에이전트 일시 중지');
renderAgents();
} catch(e) { showToast(e.message, true); }
}
async function resumeAgent(id) {
try {
await api(`/${id}/resume`, { method: 'POST' });
showToast('▶ 에이전트 재개');
renderAgents();
} catch(e) { showToast(e.message, true); }
}
async function deleteAgent(id, name) {
if (!confirm(`에이전트 "${name}"을 삭제하시겠습니까?`)) return;
try {
await api(`/${id}`, { method: 'DELETE' });
showToast('🗑 에이전트 삭제 완료');
renderAgents();
} catch(e) { showToast(e.message, true); }
}
function viewTasks(agentId) {
document.querySelectorAll('.tab')[3].click();
}
// ── 에이전트 생성 모달 ────────────────────────────────────────────────────────
function openCreateModal() {
document.getElementById('create-modal').style.display = 'flex';
}
function closeCreateModal() {
document.getElementById('create-modal').style.display = 'none';
}
async function createAgent() {
const body = {
name: document.getElementById('inp-name').value.trim(),
role: document.getElementById('inp-role').value,
llm_model: document.getElementById('inp-model').value.trim() || 'guardia-agent',
heartbeat_cron: document.getElementById('inp-cron').value.trim() || null,
system_prompt: document.getElementById('inp-prompt').value.trim() || null,
llm_provider: 'ollama',
is_active: true,
};
if (!body.name) { alert('에이전트 이름을 입력하세요.'); return; }
try {
await api('', { method: 'POST', body: JSON.stringify(body) });
showToast('✅ 에이전트 등록 완료');
closeCreateModal();
renderAgents();
loadStats();
} catch(e) { showToast('생성 실패: ' + e.message, true); }
}
// ── 토스트 ────────────────────────────────────────────────────────────────────
function showToast(msg, error=false) {
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText = `position:fixed;bottom:24px;right:24px;background:${error?'#ef4444':'#10b981'};
color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;z-index:9999;
box-shadow:0 4px 12px rgba(0,0,0,.4);`;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
// ── 초기화 ────────────────────────────────────────────────────────────────────
loadAll();
// 30초마다 자동 새로고침
setInterval(() => { if (currentTab === 'agents') renderAgents(); loadStats(); }, 30000);
</script>
</body>
</html>