zioinfo-mail/itsm/static/vibe.html
DESKTOP-TKLFCPR\ython e228faabf5 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

771 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA — 바이브 코딩 세션</title>
<link rel="stylesheet" href="/static/style.css">
<style>
/* ── CSS 변수 브릿지 ── */
:root {
--bg: var(--main-bg);
--bg-card: var(--card-bg);
--bg-input: var(--input-bg);
--bg-hover: var(--sidebar-hover-bg);
--text: var(--text-primary);
}
/* ── 공통 CSS ── */
.page-wrap { display:flex; flex-direction:column; height:100vh; }
.topnav { display:flex; align-items:center; gap:16px; padding:0 24px; height:52px; background:var(--bg-card); border-bottom:1px solid var(--border); flex-shrink:0; }
.topnav-logo { font-weight:700; font-size:16px; color:var(--accent); text-decoration:none; }
.topnav-links { display:flex; gap:4px; }
.topnav-link { padding:6px 12px; font-size:13px; color:var(--text-muted); text-decoration:none; border-radius:6px; transition:background .15s,color .15s; }
.topnav-link:hover,.topnav-link.active { background:var(--bg-hover); color:var(--text); }
.topnav-right { margin-left:auto; display:flex; align-items:center; gap:12px; }
.page-content { flex:1; overflow-y:auto; padding:24px; }
.stats-row { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; margin-bottom:20px; }
.stat-card { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:16px 18px; }
.stat-val { font-size:28px; font-weight:700; color:var(--accent); }
.stat-lbl { font-size:12px; color:var(--text-muted); margin-top:2px; }
.toolbar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; align-items:center; }
.search-box { background:var(--bg-input); border:1px solid var(--border); border-radius:6px; padding:7px 12px; color:var(--text); font-size:13px; min-width:200px; }
.filter-select { background:var(--bg-input); border:1px solid var(--border); border-radius:6px; padding:7px 10px; color:var(--text); font-size:13px; }
.sr-table { width:100%; border-collapse:collapse; }
.sr-table th { background:var(--bg-card); color:var(--text-muted); font-size:12px; font-weight:600; padding:10px 12px; text-align:left; border-bottom:1px solid var(--border); }
.sr-table td { padding:10px 12px; border-bottom:1px solid var(--border); font-size:13px; color:var(--text); vertical-align:top; }
.sr-table tr:hover td { background:var(--bg-hover); }
.badge { display:inline-block; font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; }
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:100; display:none; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal-box { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:28px; width:560px; max-width:95vw; max-height:88vh; overflow-y:auto; position:relative; }
.modal-box h2 { font-size:18px; margin-bottom:18px; color:var(--text); }
.modal-close { position:absolute; top:14px; right:16px; background:none; border:none; font-size:22px; cursor:pointer; color:var(--text-muted); }
label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--text-muted); margin-bottom:12px; }
label input, label select, label textarea { background:var(--bg-input); border:1px solid var(--border); border-radius:6px; padding:8px 10px; color:var(--text); font-size:13px; }
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.tabs { display:flex; gap:4px; margin-bottom:20px; border-bottom:1px solid var(--border); }
.tab-btn { padding:8px 16px; font-size:13px; background:none; border:none; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; }
.tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.tab-content { display:none; }
.tab-content.active { display:block; }
.code-block { background:#0d1117; color:#e6edf3; border-radius:8px; padding:14px 16px; font-family:'Courier New',monospace; font-size:12px; line-height:1.6; overflow-x:auto; white-space:pre; }
/* ── 바이브 세션 상태 배지 ── */
.vs-PENDING { background:#374151; color:#9ca3af; }
.vs-CODING { background:#2563eb22; color:#60a5fa; }
.vs-BUILDING { background:#d9770622; color:#fb923c; animation:blink 1s infinite; }
.vs-TESTING { background:#ca8a0422; color:#fcd34d; }
.vs-DEPLOYING { background:#7c3aed22; color:#a78bfa; animation:blink 1s infinite; }
.vs-COMPLETED { background:#16a34a22; color:#4ade80; }
.vs-FAILED { background:#dc262622; color:#f87171; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.5} }
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.7;transform:scale(1.15)} }
/* ── 파이프라인 스텝 ── */
.pipeline-steps { display:flex; align-items:flex-start; gap:0; margin:12px 0; }
.pipeline-step { flex:1; text-align:center; position:relative; }
.pipeline-step::after { content:''; position:absolute; top:12px; left:50%; width:100%; height:2px; background:var(--border); z-index:0; }
.pipeline-step:last-child::after { display:none; }
.pipeline-dot { width:24px; height:24px; border-radius:50%; border:2px solid var(--border); background:var(--bg); display:inline-flex; align-items:center; justify-content:center; font-size:11px; position:relative; z-index:1; margin:0 auto; }
.pipeline-dot.done { background:#16a34a; border-color:#16a34a; color:#fff; }
.pipeline-dot.active { background:var(--accent); border-color:var(--accent); color:#fff; animation:pulse .8s infinite; }
.pipeline-label { font-size:10px; color:var(--text-muted); margin-top:4px; line-height:1.3; }
.pipeline-label.done { color:#4ade80; }
.pipeline-label.active { color:var(--accent); font-weight:600; }
/* ── 세션 카드 ── */
.session-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:16px; }
.session-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:18px; transition:box-shadow .2s; }
.session-card:hover { box-shadow:0 4px 20px rgba(0,0,0,.3); }
.session-card.card-FAILED { border-color:#ef444444; }
.session-card.card-COMPLETED { border-color:#16a34a44; }
.session-card.card-BUILDING,.session-card.card-DEPLOYING { border-color:var(--accent-dark)44; }
.session-sr-id { font-size:12px; color:var(--text-muted); font-family:monospace; margin-bottom:6px; }
.session-project { font-size:15px; font-weight:600; color:var(--text-bright); margin-bottom:4px; }
.session-meta { font-size:12px; color:var(--text-muted); margin-bottom:10px; }
.session-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.btn { padding:7px 14px; font-size:12px; border-radius:6px; cursor:pointer; border:1px solid var(--border); background:var(--bg-card); color:var(--text); transition:background .15s,color .15s; white-space:nowrap; }
.btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
.btn-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
.btn-primary:hover { background:var(--accent-dark); border-color:var(--accent-dark); }
.btn-sm { padding:5px 10px; font-size:11px; }
.btn-build { background:#d9770622; color:#fb923c; border-color:#d9770644; }
.btn-build:hover { background:#d97706; color:#fff; border-color:#d97706; }
.btn-deploy { background:#7c3aed22; color:#a78bfa; border-color:#7c3aed44; }
.btn-deploy:hover { background:#7c3aed; color:#fff; border-color:#7c3aed; }
.btn-deploy-prd { background:#dc262622; color:#f87171; border-color:#dc262644; }
.btn-deploy-prd:hover { background:#dc2626; color:#fff; border-color:#dc2626; }
.table-wrap { overflow-x:auto; background:var(--bg-card); border:1px solid var(--border); border-radius:10px; }
.empty-state { text-align:center; color:var(--text-muted); padding:48px; font-size:14px; }
/* ── Jenkins 상태 배너 ── */
.jenkins-banner { display:flex; align-items:center; gap:10px; padding:10px 16px; border-radius:8px; margin-bottom:16px; font-size:13px; }
.jenkins-banner.online { background:#10b98118; border:1px solid #10b98144; color:#34d399; }
.jenkins-banner.offline { background:#ef444418; border:1px solid #ef444444; color:#f87171; }
.jenkins-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.jenkins-dot.online { background:#34d399; animation:pulse .8s infinite; }
.jenkins-dot.offline { background:#f87171; }
.detail-row { display:flex; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
.detail-kv { font-size:12px; }
.detail-kv .k { color:var(--text-muted); }
.detail-kv .v { color:var(--text); font-weight:500; }
</style>
</head>
<body>
<script>document.body.dataset.theme = localStorage.getItem("guardia_theme") || "dark";</script>
<div class="page-wrap">
<!-- ── Top Nav ── -->
<nav class="topnav">
<a class="topnav-logo" href="/">GUARDiA</a>
<div class="topnav-links">
<a class="topnav-link" href="/">대시보드</a>
<a class="topnav-link" href="/incidents">인시던트</a>
<a class="topnav-link" href="/ssl">SSL</a>
<a class="topnav-link" href="/pm">PM</a>
<a class="topnav-link" href="/oncall">온콜</a>
<a class="topnav-link" href="/batch">배치</a>
<a class="topnav-link active" href="/vibe">바이브</a>
<a class="topnav-link" href="/si">SI</a>
<a class="topnav-link" href="/agents">에이전트</a>
</div>
<div class="topnav-right">
<span id="nav-user" style="font-size:13px;color:var(--text-muted)"></span>
<button class="btn" onclick="logout()" style="padding:4px 10px;font-size:12px;">로그아웃</button>
</div>
</nav>
<!-- ── Page Content ── -->
<div class="page-content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<h1 style="font-size:20px;font-weight:700;color:var(--text-bright);">바이브 코딩 세션</h1>
<button class="btn btn-primary" onclick="openStartModal()">+ 세션 시작</button>
</div>
<!---->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('active', this)">활성 세션</button>
<button class="tab-btn" onclick="switchTab('history', this)">전체 이력</button>
<button class="tab-btn" onclick="switchTab('projects', this)">프로젝트</button>
</div>
<!-- 탭 1: 활성 세션 -->
<div id="tab-active" class="tab-content active">
<!-- Jenkins 상태 -->
<div id="jenkins-banner" class="jenkins-banner offline">
<div id="jenkins-dot" class="jenkins-dot offline"></div>
<span id="jenkins-status-text">Jenkins 연결 확인 중...</span>
<button class="btn btn-sm" onclick="checkJenkins()" style="margin-left:auto;">새로고침</button>
</div>
<!-- 통계 -->
<div class="stats-row" style="margin-bottom:20px;">
<div class="stat-card">
<div class="stat-val" id="stat-active-count"></div>
<div class="stat-lbl">활성 세션</div>
</div>
<div class="stat-card">
<div class="stat-val" id="stat-building-count" style="color:#fb923c;"></div>
<div class="stat-lbl">빌드 중</div>
</div>
<div class="stat-card">
<div class="stat-val" id="stat-deploying-count" style="color:#a78bfa;"></div>
<div class="stat-lbl">배포 중</div>
</div>
<div class="stat-card">
<div class="stat-val" id="stat-today-completed" style="color:#4ade80;"></div>
<div class="stat-lbl">오늘 완료</div>
</div>
</div>
<div id="active-sessions-grid" class="session-grid">
<div class="empty-state">로딩 중...</div>
</div>
</div>
<!-- 탭 2: 전체 이력 -->
<div id="tab-history" class="tab-content">
<div class="toolbar">
<select class="filter-select" id="hist-filter-status" onchange="loadHistory()">
<option value="">전체 상태</option>
<option value="PENDING">PENDING</option>
<option value="CODING">CODING</option>
<option value="BUILDING">BUILDING</option>
<option value="TESTING">TESTING</option>
<option value="DEPLOYING">DEPLOYING</option>
<option value="COMPLETED">COMPLETED</option>
<option value="FAILED">FAILED</option>
</select>
<button class="btn" onclick="loadHistory()" style="margin-left:auto;">새로고침</button>
</div>
<div class="table-wrap">
<table class="sr-table">
<thead>
<tr>
<th>SR ID</th>
<th>프로젝트명</th>
<th>상태</th>
<th>시작 시각</th>
<th>완료 시각</th>
<th>빌드 결과</th>
<th>소요 시간</th>
</tr>
</thead>
<tbody id="history-tbody">
<tr><td colspan="7" class="empty-state">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 탭 3: 프로젝트 -->
<div id="tab-projects" class="tab-content">
<div class="toolbar">
<button class="btn btn-primary" onclick="openProjectModal()" style="margin-left:auto;">+ 프로젝트 등록</button>
</div>
<div class="table-wrap">
<table class="sr-table">
<thead>
<tr>
<th>프로젝트명</th>
<th>소스 경로</th>
<th>빌드 명령어</th>
<th>배포 서버</th>
<th>상태</th>
</tr>
</thead>
<tbody id="projects-tbody">
<tr><td colspan="5" class="empty-state">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── 세션 시작 모달 ── -->
<div class="modal-overlay" id="start-modal">
<div class="modal-box">
<button class="modal-close" onclick="closeModal('start-modal')">×</button>
<h2>바이브 코딩 세션 시작</h2>
<form onsubmit="submitStartSession(event)">
<label>SR (서비스 요청) *
<select id="f-sr-id" required>
<option value="">SR 선택...</option>
</select>
</label>
<label>프로젝트 *
<select id="f-project-id" required>
<option value="">프로젝트 선택...</option>
</select>
</label>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
<button type="button" class="btn" onclick="closeModal('start-modal')">취소</button>
<button type="submit" class="btn btn-primary">세션 시작</button>
</div>
</form>
</div>
</div>
<!-- ── 프로젝트 등록 모달 ── -->
<div class="modal-overlay" id="project-modal">
<div class="modal-box">
<button class="modal-close" onclick="closeModal('project-modal')">×</button>
<h2>프로젝트 등록</h2>
<form onsubmit="submitProject(event)">
<div class="form-row">
<label>프로젝트명 *
<input type="text" id="fp-name" required placeholder="guardia-core">
</label>
<label>브랜치
<input type="text" id="fp-branch" placeholder="main">
</label>
</div>
<label>설명
<input type="text" id="fp-description" placeholder="프로젝트 설명">
</label>
<label>소스 경로 *
<input type="text" id="fp-source-path" required placeholder="/opt/projects/guardia-core">
</label>
<label>저장소 URL
<input type="text" id="fp-repo-url" placeholder="https://github.com/org/repo.git">
</label>
<label>빌드 명령어
<input type="text" id="fp-build-cmd" placeholder="./gradlew build -x test">
</label>
<label>배포 서버
<select id="fp-deploy-server-id">
<option value="">서버 선택...</option>
</select>
</label>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
<button type="button" class="btn" onclick="closeModal('project-modal')">취소</button>
<button type="submit" class="btn btn-primary">등록</button>
</div>
</form>
</div>
</div>
<!-- ── 세션/이력 상세 모달 ── -->
<div class="modal-overlay" id="detail-modal">
<div class="modal-box" style="width:700px;">
<button class="modal-close" onclick="closeModal('detail-modal')">×</button>
<h2>세션 상세</h2>
<div id="detail-content"></div>
</div>
</div>
<script>
// ── 전역 상태 ──
let allSessions = [];
let projects = [];
let servers = [];
let srList = [];
// ── 파이프라인 단계 정의 ──
const PIPELINE_STEPS = ['PENDING', 'CODING', 'BUILDING', 'TESTING', 'DEPLOYING', 'COMPLETED'];
const STEP_LABELS = { PENDING:'대기', CODING:'코딩', BUILDING:'빌드', TESTING:'테스트', DEPLOYING:'배포', COMPLETED:'완료' };
// ── 인증 ──
function getToken() { return localStorage.getItem('guardia_token'); }
function logout() { localStorage.removeItem('guardia_token'); location.href = '/login'; }
async function apiFetch(url, opts = {}) {
const token = getToken();
if (!token) { location.href = '/login'; return; }
const res = await fetch(url, {
...opts,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
...(opts.headers || {})
}
});
if (res.status === 401) { location.href = '/login'; return; }
return res;
}
// ── 유틸 ──
function escHtml(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDate(d) {
if (!d) return '—';
return new Date(d).toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function fmtDuration(start, end) {
if (!start || !end) return '—';
const ms = new Date(end) - new Date(start);
if (ms < 60000) return Math.round(ms/1000) + 's';
return Math.floor(ms/60000) + 'm ' + Math.floor((ms%60000)/1000) + 's';
}
function statusBadge(status) {
return `<span class="badge vs-${status||'PENDING'}">${status||'PENDING'}</span>`;
}
function projectName(projectId) {
const p = projects.find(p => String(p.id) === String(projectId));
return p ? p.project_name : String(projectId||'');
}
function serverName(id) {
const s = servers.find(s => String(s.id) === String(id));
return s ? (s.name || s.hostname) : String(id||'');
}
// ── 탭 전환 ──
function switchTab(tab, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'history') loadHistory();
if (tab === 'projects') loadProjects();
}
// ── 파이프라인 렌더 ──
function renderPipeline(currentStatus) {
const currentIdx = PIPELINE_STEPS.indexOf(currentStatus);
return `
<div class="pipeline-steps">
${PIPELINE_STEPS.map((step, i) => {
let dotClass = '';
let lblClass = '';
if (i < currentIdx) { dotClass = 'done'; lblClass = 'done'; }
else if (i === currentIdx) { dotClass = 'active'; lblClass = 'active'; }
const icon = i < currentIdx ? '✓' : (i === currentIdx ? '●' : '');
return `
<div class="pipeline-step">
<div class="pipeline-dot ${dotClass}">${icon}</div>
<div class="pipeline-label ${lblClass}">${STEP_LABELS[step]}</div>
</div>
`;
}).join('')}
</div>
`;
}
// ── Jenkins 상태 확인 ──
async function checkJenkins() {
const banner = document.getElementById('jenkins-banner');
const dot = document.getElementById('jenkins-dot');
const text = document.getElementById('jenkins-status-text');
try {
const res = await apiFetch('/api/vibe/jenkins/health');
if (!res) return;
if (res.ok) {
const d = await res.json();
const online = d.status === 'ok' || d.connected === true;
banner.className = 'jenkins-banner ' + (online ? 'online' : 'offline');
dot.className = 'jenkins-dot ' + (online ? 'online' : 'offline');
text.textContent = online
? `Jenkins 연결 정상 (${d.version || '버전 불명'})`
: 'Jenkins 연결 불가 — 빌드/배포 기능이 제한됩니다.';
} else {
banner.className = 'jenkins-banner offline';
dot.className = 'jenkins-dot offline';
text.textContent = 'Jenkins 연결 불가 — 빌드/배포 기능이 제한됩니다.';
}
} catch(e) {
banner.className = 'jenkins-banner offline';
dot.className = 'jenkins-dot offline';
text.textContent = 'Jenkins 상태 확인 실패: ' + e.message;
}
}
// ── 활성 세션 로드 ──
async function loadActiveSessions() {
try {
const res = await apiFetch('/api/vibe?status=PENDING,CODING,BUILDING,TESTING,DEPLOYING');
if (!res || !res.ok) { renderActiveEmpty('세션 목록을 불러올 수 없습니다.'); return; }
const data = await res.json();
allSessions = data;
updateActiveStats(data);
renderActiveSessions(data);
} catch(e) { renderActiveEmpty('오류: ' + e.message); }
}
function updateActiveStats(data) {
const activeStatuses = ['PENDING','CODING','BUILDING','TESTING','DEPLOYING'];
const active = data.filter(s => activeStatuses.includes(s.status));
document.getElementById('stat-active-count').textContent = active.length;
document.getElementById('stat-building-count').textContent = data.filter(s => s.status === 'BUILDING').length;
document.getElementById('stat-deploying-count').textContent = data.filter(s => s.status === 'DEPLOYING').length;
const today = new Date().toDateString();
document.getElementById('stat-today-completed').textContent =
data.filter(s => s.status === 'COMPLETED' && new Date(s.completed_at||s.updated_at||'').toDateString() === today).length;
}
function renderActiveEmpty(msg) {
document.getElementById('active-sessions-grid').innerHTML = `<div class="empty-state">${msg}</div>`;
}
function renderActiveSessions(list) {
const grid = document.getElementById('active-sessions-grid');
if (!list.length) {
grid.innerHTML = '<div class="empty-state">활성 세션이 없습니다. 세션을 시작하세요.</div>';
return;
}
grid.innerHTML = list.map(s => {
const proj = projectName(s.project_id);
const isBusy = ['BUILDING','DEPLOYING'].includes(s.status);
return `
<div class="session-card card-${s.status}">
<div class="session-sr-id">${escHtml(s.sr_id || ('SESSION-' + s.id))}</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
<div class="session-project">${escHtml(proj || '프로젝트 ' + s.project_id)}</div>
${statusBadge(s.status)}
</div>
<div class="session-meta">시작: ${fmtDate(s.started_at || s.created_at)}</div>
${renderPipeline(s.status)}
<div class="session-actions">
<button class="btn btn-sm btn-build" onclick="triggerBuild(${s.id})" ${isBusy ? 'disabled' : ''}>빌드</button>
<button class="btn btn-sm btn-deploy" onclick="triggerDeploy(${s.id},'dev')" ${isBusy ? 'disabled' : ''}>배포(dev)</button>
<button class="btn btn-sm btn-deploy-prd" onclick="triggerDeploy(${s.id},'prd')" ${isBusy ? 'disabled' : ''}>배포(prd)</button>
<button class="btn btn-sm" onclick="showSessionDetail(${s.id})" style="margin-left:auto;">상세</button>
</div>
</div>
`;
}).join('');
}
// ── 빌드 트리거 ──
async function triggerBuild(sessionId) {
if (!confirm('빌드를 트리거 하시겠습니까?')) return;
try {
const res = await apiFetch(`/api/vibe/${sessionId}/build`, { method: 'POST' });
if (!res) return;
if (res.ok || res.status === 202) {
alert('빌드가 시작되었습니다.');
loadActiveSessions();
} else {
const d = await res.json();
alert('빌드 트리거 실패: ' + (d.detail || JSON.stringify(d)));
}
} catch(e) { alert('오류: ' + e.message); }
}
// ── 배포 트리거 ──
async function triggerDeploy(sessionId, environment) {
const envLabel = environment === 'prd' ? '운영(prd)' : '개발(dev)';
if (!confirm(`${envLabel} 환경에 배포 하시겠습니까?`)) return;
try {
const res = await apiFetch(`/api/vibe/${sessionId}/deploy`, {
method: 'POST',
body: JSON.stringify({ environment })
});
if (!res) return;
if (res.ok || res.status === 202) {
alert(`${envLabel} 배포가 시작되었습니다.`);
loadActiveSessions();
} else {
const d = await res.json();
alert('배포 트리거 실패: ' + (d.detail || JSON.stringify(d)));
}
} catch(e) { alert('오류: ' + e.message); }
}
// ── 세션 상태 변경 ──
async function updateSessionStatus(sessionId, status) {
try {
const res = await apiFetch(`/api/vibe/${sessionId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
});
if (res && res.ok) loadActiveSessions();
} catch(e) { console.error(e); }
}
// ── 이력 로드 ──
async function loadHistory() {
const status = document.getElementById('hist-filter-status').value;
const params = new URLSearchParams({ limit: 100 });
if (status) params.append('status', status);
try {
const res = await apiFetch('/api/vibe?' + params);
if (!res || !res.ok) { renderHistoryEmpty('이력을 불러올 수 없습니다.'); return; }
const data = await res.json();
renderHistory(data);
} catch(e) { renderHistoryEmpty('오류: ' + e.message); }
}
function renderHistoryEmpty(msg) {
document.getElementById('history-tbody').innerHTML =
`<tr><td colspan="7" class="empty-state">${msg}</td></tr>`;
}
function renderHistory(list) {
const tbody = document.getElementById('history-tbody');
if (!list.length) { tbody.innerHTML = '<tr><td colspan="7" class="empty-state">이력이 없습니다.</td></tr>'; return; }
tbody.innerHTML = list.map(s => {
const buildResult = s.build_result
? `<span class="badge ${s.build_result === 'SUCCESS' ? 'vs-COMPLETED' : 'vs-FAILED'}">${escHtml(s.build_result)}</span>`
: '—';
return `
<tr style="cursor:pointer;" onclick="showSessionDetail(${s.id})">
<td style="font-family:monospace;font-size:12px;">${escHtml(s.sr_id || 'SESSION-' + s.id)}</td>
<td style="font-weight:500;">${escHtml(projectName(s.project_id))}</td>
<td>${statusBadge(s.status)}</td>
<td style="font-size:12px;">${fmtDate(s.started_at || s.created_at)}</td>
<td style="font-size:12px;">${fmtDate(s.completed_at)}</td>
<td>${buildResult}</td>
<td style="font-size:12px;">${fmtDuration(s.started_at || s.created_at, s.completed_at)}</td>
</tr>
`;
}).join('');
}
// ── 세션 상세 ──
async function showSessionDetail(sessionId) {
try {
let s;
const res = await apiFetch(`/api/vibe/${sessionId}`);
if (res && res.ok) {
s = await res.json();
} else {
// fallback: 로컬 캐시
s = allSessions.find(x => x.id === sessionId) || {};
}
document.getElementById('detail-content').innerHTML = `
<div class="detail-row">
<div class="detail-kv"><span class="k">SR ID: </span><span class="v" style="font-family:monospace;">${escHtml(s.sr_id||('SESSION-'+s.id))}</span></div>
<div class="detail-kv"><span class="k">프로젝트: </span><span class="v">${escHtml(projectName(s.project_id))}</span></div>
<div class="detail-kv"><span class="k">상태: </span>${statusBadge(s.status)}</div>
</div>
<div class="detail-row">
<div class="detail-kv"><span class="k">시작: </span><span class="v">${fmtDate(s.started_at||s.created_at)}</span></div>
<div class="detail-kv"><span class="k">완료: </span><span class="v">${fmtDate(s.completed_at)}</span></div>
<div class="detail-kv"><span class="k">소요: </span><span class="v">${fmtDuration(s.started_at||s.created_at, s.completed_at)}</span></div>
</div>
<div style="margin:12px 0 6px;font-size:13px;font-weight:600;color:var(--text-bright);">파이프라인</div>
${renderPipeline(s.status||'PENDING')}
${s.build_log ? `<div style="margin-top:14px;font-size:12px;color:var(--text-muted);margin-bottom:4px;">빌드 로그</div><div class="code-block">${escHtml(s.build_log)}</div>` : ''}
${s.test_result ? `<div style="margin-top:10px;font-size:12px;color:var(--text-muted);margin-bottom:4px;">테스트 결과</div><div class="code-block">${escHtml(typeof s.test_result === 'string' ? s.test_result : JSON.stringify(s.test_result, null, 2))}</div>` : ''}
${s.deploy_log ? `<div style="margin-top:10px;font-size:12px;color:var(--text-muted);margin-bottom:4px;">배포 로그</div><div class="code-block">${escHtml(s.deploy_log)}</div>` : ''}
${(!s.build_log && !s.test_result && !s.deploy_log) ? '<div style="color:var(--text-muted);font-size:13px;margin-top:16px;">로그가 없습니다.</div>' : ''}
`;
openModal('detail-modal');
} catch(e) { alert('상세 정보 로드 오류: ' + e.message); }
}
// ── 프로젝트 로드 ──
async function loadProjects() {
try {
const res = await apiFetch('/api/projects');
if (!res || !res.ok) { renderProjectsEmpty('프로젝트 목록을 불러올 수 없습니다.'); return; }
projects = await res.json();
renderProjects(projects);
// 세션 시작 모달 프로젝트 드롭다운 갱신
const pSel = document.getElementById('f-project-id');
pSel.innerHTML = '<option value="">프로젝트 선택...</option>' +
projects.map(p => `<option value="${p.id}">${escHtml(p.project_name)}</option>`).join('');
// 프로젝트 등록 모달 서버 드롭다운 갱신
const dSel = document.getElementById('fp-deploy-server-id');
dSel.innerHTML = '<option value="">서버 선택...</option>' +
servers.map(s => `<option value="${s.id}">${escHtml(s.name||s.hostname)}</option>`).join('');
} catch(e) { renderProjectsEmpty('오류: ' + e.message); }
}
function renderProjectsEmpty(msg) {
document.getElementById('projects-tbody').innerHTML =
`<tr><td colspan="5" class="empty-state">${msg}</td></tr>`;
}
function renderProjects(list) {
const tbody = document.getElementById('projects-tbody');
if (!list.length) { tbody.innerHTML = '<tr><td colspan="5" class="empty-state">등록된 프로젝트가 없습니다.</td></tr>'; return; }
tbody.innerHTML = list.map(p => `
<tr>
<td>
<div style="font-weight:600;color:var(--text-bright);">${escHtml(p.project_name)}</div>
<div style="font-size:11px;color:var(--text-muted);">${escHtml(p.description||'')}</div>
</td>
<td style="font-size:12px;font-family:monospace;">${escHtml(p.source_path||'—')}</td>
<td><code style="font-size:11px;background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;">${escHtml(p.build_cmd||'—')}</code></td>
<td style="font-size:12px;">${escHtml(serverName(p.deploy_server_id))}</td>
<td><span class="badge ${p.is_active !== false ? 'vs-COMPLETED' : 'vs-PENDING'}">${p.is_active !== false ? '활성' : '비활성'}</span></td>
</tr>
`).join('');
}
// ── 서버 목록 로드 ──
async function loadServers() {
try {
const res = await apiFetch('/api/servers');
if (!res || !res.ok) return;
servers = await res.json();
} catch(e) { console.error(e); }
}
// ── SR 목록 로드 ──
async function loadSRList() {
try {
const res = await apiFetch('/api/tasks?status=APPROVED&sr_type=DEPLOY');
if (!res || !res.ok) return;
srList = await res.json();
const sel = document.getElementById('f-sr-id');
sel.innerHTML = '<option value="">SR 선택...</option>' +
srList.map(s => `<option value="${s.id}">${escHtml(s.sr_id || s.id)}${escHtml(s.title || s.subject || '')}</option>`).join('');
} catch(e) { console.error(e); }
}
// ── 세션 시작 모달 ──
function openStartModal() {
openModal('start-modal');
}
async function submitStartSession(e) {
e.preventDefault();
const srId = document.getElementById('f-sr-id').value;
const projectId = document.getElementById('f-project-id').value;
try {
const res = await apiFetch('/api/vibe', {
method: 'POST',
body: JSON.stringify({ sr_id: srId, project_id: projectId })
});
if (!res) return;
if (res.ok || res.status === 201) {
closeModal('start-modal');
loadActiveSessions();
} else {
const d = await res.json();
alert('세션 시작 실패: ' + (d.detail || JSON.stringify(d)));
}
} catch(e) { alert('오류: ' + e.message); }
}
// ── 프로젝트 등록 모달 ──
function openProjectModal() {
document.getElementById('fp-deploy-server-id').innerHTML = '<option value="">서버 선택...</option>' +
servers.map(s => `<option value="${s.id}">${escHtml(s.name||s.hostname)}</option>`).join('');
openModal('project-modal');
}
async function submitProject(e) {
e.preventDefault();
const body = {
project_name: document.getElementById('fp-name').value,
description: document.getElementById('fp-description').value,
source_path: document.getElementById('fp-source-path').value,
repo_url: document.getElementById('fp-repo-url').value,
branch: document.getElementById('fp-branch').value,
build_cmd: document.getElementById('fp-build-cmd').value,
deploy_server_id: document.getElementById('fp-deploy-server-id').value || null,
};
try {
const res = await apiFetch('/api/projects', { method: 'POST', body: JSON.stringify(body) });
if (!res) return;
if (res.ok || res.status === 201) {
closeModal('project-modal');
loadProjects();
} else {
const d = await res.json();
alert('등록 실패: ' + (d.detail || JSON.stringify(d)));
}
} catch(e) { alert('오류: ' + e.message); }
}
// ── 모달 ──
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.addEventListener('click', e => {
if (e.target.classList.contains('modal-overlay')) e.target.classList.remove('open');
});
// ── 사용자 정보 ──
function loadUserInfo() {
const user = localStorage.getItem('guardia_user');
if (user) {
try {
const u = JSON.parse(user);
document.getElementById('nav-user').textContent = u.name || u.email || '';
} catch(e) {}
}
}
// ── 자동 새로고침 (30초) ──
let autoRefreshTimer = null;
function startAutoRefresh() {
autoRefreshTimer = setInterval(() => {
const activeTab = document.querySelector('.tab-btn.active');
if (activeTab && activeTab.textContent.includes('활성')) loadActiveSessions();
}, 30000);
}
// ── 초기화 ──
(async function init() {
if (!getToken()) { location.href = '/login'; return; }
loadUserInfo();
await Promise.all([loadServers(), loadSRList()]);
await loadProjects();
await loadActiveSessions();
checkJenkins();
startAutoRefresh();
})();
</script>
</body>
</html>