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>
771 lines
35 KiB
HTML
771 lines
35 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 — 바이브 코딩 세션</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
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>
|