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>
673 lines
32 KiB
HTML
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} ·
|
|
${a.heartbeat_cron ? '⏰ ' + a.heartbeat_cron : '수동 실행'}
|
|
${a.last_heartbeat ? ' · 마지막: ' + 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}
|
|
· Agent #${a.agent_id}
|
|
</div>
|
|
<div class="approval-title">${formatApprovalTitle(a)}</div>
|
|
<div class="approval-meta">
|
|
요청: ${relTime(a.requested_at)}
|
|
${a.status !== 'PENDING' ? ` · 상태: <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('_',' ')})
|
|
· ${relTime(t.created_at)}
|
|
${t.tokens_used ? ` · 토큰: ${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>
|