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>
686 lines
30 KiB
HTML
686 lines
30 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 변수 브릿지 (topnav 공통 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; }
|
||
|
||
/* ── 배치 전용 ── */
|
||
.run-SUCCESS { background:#16a34a22; color:#4ade80; }
|
||
.run-FAILED { background:#dc262622; color:#f87171; }
|
||
.run-TIMEOUT { background:#ea580c22; color:#fb923c; }
|
||
.run-RUNNING { background:#2563eb22; color:#60a5fa; animation:blink 1s infinite; }
|
||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||
|
||
.toggle-switch { position:relative; width:44px; height:22px; display:inline-block; }
|
||
.toggle-switch input { opacity:0; width:0; height:0; }
|
||
.toggle-slider { position:absolute; cursor:pointer; inset:0; background:var(--border); border-radius:22px; transition:.3s; }
|
||
.toggle-slider:before { position:absolute; content:""; height:16px; width:16px; left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.3s; }
|
||
input:checked + .toggle-slider { background:var(--accent); }
|
||
input:checked + .toggle-slider:before { transform:translateX(22px); }
|
||
|
||
.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-danger:hover { background:#ef4444; border-color:#ef4444; }
|
||
.btn-run { background:#16a34a22; color:#4ade80; border-color:#16a34a44; }
|
||
.btn-run:hover { background:#16a34a; color:#fff; border-color:#16a34a; }
|
||
.action-btns { display:flex; gap:6px; flex-wrap:nowrap; }
|
||
|
||
.cron-hint { font-size:11px; color:var(--text-muted); margin-top:4px; line-height:1.5; }
|
||
.cron-hint code { background:rgba(255,255,255,.08); padding:1px 5px; border-radius:3px; font-family:'Courier New',monospace; }
|
||
|
||
.run-status-banner { display:flex; align-items:center; gap:8px; padding:10px 14px; border-radius:8px; font-size:13px; margin-bottom:16px; }
|
||
.run-status-banner.running { background:#2563eb18; border:1px solid #2563eb44; color:#60a5fa; }
|
||
|
||
.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; }
|
||
|
||
.job-active { background:#16a34a22; color:#4ade80; }
|
||
.job-inactive { background:#37415122; color:#9ca3af; }
|
||
|
||
.detail-row { display:flex; gap:6px; 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 active" href="/batch">배치</a>
|
||
<a class="topnav-link" 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="openJobModal()">+ 작업 등록</button>
|
||
</div>
|
||
|
||
<!-- 통계 -->
|
||
<div class="stats-row">
|
||
<div class="stat-card">
|
||
<div class="stat-val" id="stat-total">—</div>
|
||
<div class="stat-lbl">전체 작업</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-val" id="stat-active">—</div>
|
||
<div class="stat-lbl">활성 작업</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-val" id="stat-today-runs">—</div>
|
||
<div class="stat-lbl">오늘 실행 횟수</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-val" id="stat-today-fails" style="color:#f87171;">—</div>
|
||
<div class="stat-lbl">오늘 실패 횟수</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 탭 -->
|
||
<div class="tabs">
|
||
<button class="tab-btn active" onclick="switchTab('jobs', this)">배치 작업 목록</button>
|
||
<button class="tab-btn" onclick="switchTab('runs', this)">실행 이력</button>
|
||
</div>
|
||
|
||
<!-- 탭 1: 작업 목록 -->
|
||
<div id="tab-jobs" class="tab-content active">
|
||
<div class="toolbar">
|
||
<input class="search-box" type="text" id="job-search" placeholder="작업명 검색..." oninput="filterJobs()">
|
||
<select class="filter-select" id="job-filter-status" onchange="filterJobs()">
|
||
<option value="">전체</option>
|
||
<option value="active">활성</option>
|
||
<option value="inactive">비활성</option>
|
||
</select>
|
||
<select class="filter-select" id="job-filter-server" onchange="filterJobs()">
|
||
<option value="">전체 서버</option>
|
||
</select>
|
||
<button class="btn" onclick="loadJobs()" style="margin-left:auto;">새로고침</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="sr-table">
|
||
<thead>
|
||
<tr>
|
||
<th>작업명</th>
|
||
<th>서버</th>
|
||
<th>Cron</th>
|
||
<th>마지막 실행</th>
|
||
<th>마지막 결과</th>
|
||
<th>활성화</th>
|
||
<th>액션</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="jobs-tbody">
|
||
<tr><td colspan="7" class="empty-state">로딩 중...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 탭 2: 실행 이력 -->
|
||
<div id="tab-runs" class="tab-content">
|
||
<div class="toolbar">
|
||
<select class="filter-select" id="run-filter-job" onchange="loadRuns()">
|
||
<option value="">모든 작업</option>
|
||
</select>
|
||
<select class="filter-select" id="run-filter-result" onchange="loadRuns()">
|
||
<option value="">전체 결과</option>
|
||
<option value="SUCCESS">SUCCESS</option>
|
||
<option value="FAILED">FAILED</option>
|
||
<option value="TIMEOUT">TIMEOUT</option>
|
||
<option value="RUNNING">RUNNING</option>
|
||
</select>
|
||
<button class="btn" onclick="loadRuns()" style="margin-left:auto;">새로고침</button>
|
||
</div>
|
||
<div id="run-status-area"></div>
|
||
<div class="table-wrap">
|
||
<table class="sr-table">
|
||
<thead>
|
||
<tr>
|
||
<th>실행 시각</th>
|
||
<th>작업명</th>
|
||
<th>서버</th>
|
||
<th>결과</th>
|
||
<th>종료 코드</th>
|
||
<th>소요 시간</th>
|
||
<th>stdout 요약</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="runs-tbody">
|
||
<tr><td colspan="7" class="empty-state">작업을 선택하거나 새로고침 하세요.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 작업 등록/수정 모달 ── -->
|
||
<div class="modal-overlay" id="job-modal">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('job-modal')">×</button>
|
||
<h2 id="job-modal-title">배치 작업 등록</h2>
|
||
<form id="job-form" onsubmit="submitJob(event)">
|
||
<input type="hidden" id="job-id">
|
||
<div class="form-row">
|
||
<label>작업명 *
|
||
<input type="text" id="f-job-name" required placeholder="daily-backup">
|
||
</label>
|
||
<label>서버 *
|
||
<select id="f-server-id" required>
|
||
<option value="">서버 선택...</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label>설명
|
||
<input type="text" id="f-description" placeholder="작업 설명">
|
||
</label>
|
||
<label>Cron 표현식 *
|
||
<input type="text" id="f-cron-expr" required placeholder="0 2 * * *" oninput="updateCronHint(this.value)">
|
||
<div class="cron-hint" id="cron-hint-text">
|
||
예: <code>0 2 * * *</code> = 매일 02:00 / <code>*/15 * * * *</code> = 15분마다<br>
|
||
형식: <code>분 시 일 월 요일</code> (요일: 0=일,1=월,...,6=토)
|
||
</div>
|
||
</label>
|
||
<label>실행 명령어 *
|
||
<textarea id="f-command" rows="3" required placeholder="/opt/scripts/backup.sh"></textarea>
|
||
</label>
|
||
<div class="form-row">
|
||
<label>타임아웃(초)
|
||
<input type="number" id="f-timeout-sec" placeholder="3600" min="1">
|
||
</label>
|
||
<label>실패 시 알림
|
||
<select id="f-alert-on-fail">
|
||
<option value="true">예</option>
|
||
<option value="false">아니오</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label>활성화
|
||
<select id="f-is-active">
|
||
<option value="true">활성</option>
|
||
<option value="false">비활성</option>
|
||
</select>
|
||
</label>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
|
||
<button type="button" class="btn" onclick="closeModal('job-modal')">취소</button>
|
||
<button type="submit" class="btn btn-primary" id="job-submit-btn">등록</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 실행 이력 상세 모달 ── -->
|
||
<div class="modal-overlay" id="run-detail-modal">
|
||
<div class="modal-box" style="width:700px;">
|
||
<button class="modal-close" onclick="closeModal('run-detail-modal')">×</button>
|
||
<h2>실행 이력 상세</h2>
|
||
<div id="run-detail-content"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── 상태 ──
|
||
let jobs = [];
|
||
let runs = [];
|
||
let servers = [];
|
||
let editingJobId = null;
|
||
|
||
// ── 인증 ──
|
||
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 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 === 'runs') loadRuns();
|
||
}
|
||
|
||
// ── 유틸 ──
|
||
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(ms) {
|
||
if (ms == null) return '—';
|
||
if (ms < 1000) return ms + 'ms';
|
||
if (ms < 60000) return (ms/1000).toFixed(1) + 's';
|
||
return Math.floor(ms/60000) + 'm ' + Math.floor((ms%60000)/1000) + 's';
|
||
}
|
||
function resultBadge(r) {
|
||
if (!r) return '<span class="badge" style="background:#37415122;color:#9ca3af;">미실행</span>';
|
||
return `<span class="badge run-${r}">${r}</span>`;
|
||
}
|
||
|
||
// ── 서버 목록 로드 ──
|
||
async function loadServers() {
|
||
try {
|
||
const res = await apiFetch('/api/servers');
|
||
if (!res || !res.ok) return;
|
||
servers = await res.json();
|
||
// 필터 드롭다운
|
||
const sf = document.getElementById('job-filter-server');
|
||
sf.innerHTML = '<option value="">전체 서버</option>' +
|
||
servers.map(s => `<option value="${s.id}">${s.name || s.hostname}</option>`).join('');
|
||
// 폼 드롭다운
|
||
const formSf = document.getElementById('f-server-id');
|
||
formSf.innerHTML = '<option value="">서버 선택...</option>' +
|
||
servers.map(s => `<option value="${s.id}">${s.name || s.hostname}</option>`).join('');
|
||
// 실행 이력 작업 드롭다운은 loadJobs 후 처리
|
||
} catch(e) { console.error(e); }
|
||
}
|
||
|
||
function serverName(id) {
|
||
const s = servers.find(s => String(s.id) === String(id));
|
||
return s ? (s.name || s.hostname) : id;
|
||
}
|
||
|
||
// ── 작업 목록 ──
|
||
async function loadJobs() {
|
||
try {
|
||
const res = await apiFetch('/api/batch/jobs');
|
||
if (!res || !res.ok) { renderJobsEmpty('작업 목록을 불러올 수 없습니다.'); return; }
|
||
jobs = await res.json();
|
||
updateStats();
|
||
renderJobs(jobs);
|
||
// 실행이력 작업 선택 드롭다운 갱신
|
||
const rj = document.getElementById('run-filter-job');
|
||
rj.innerHTML = '<option value="">모든 작업</option>' +
|
||
jobs.map(j => `<option value="${j.id}">${j.job_name}</option>`).join('');
|
||
} catch(e) { renderJobsEmpty('오류: ' + e.message); }
|
||
}
|
||
|
||
function updateStats() {
|
||
document.getElementById('stat-total').textContent = jobs.length;
|
||
document.getElementById('stat-active').textContent = jobs.filter(j => j.is_active).length;
|
||
// 오늘 실행/실패는 runs에서 집계 (runs 로드 후 별도 업데이트)
|
||
}
|
||
|
||
function renderJobsEmpty(msg) {
|
||
document.getElementById('jobs-tbody').innerHTML =
|
||
`<tr><td colspan="7" class="empty-state">${msg}</td></tr>`;
|
||
}
|
||
|
||
function filterJobs() {
|
||
const q = document.getElementById('job-search').value.toLowerCase();
|
||
const st = document.getElementById('job-filter-status').value;
|
||
const sv = document.getElementById('job-filter-server').value;
|
||
const filtered = jobs.filter(j => {
|
||
const matchQ = !q || j.job_name.toLowerCase().includes(q) || (j.description||'').toLowerCase().includes(q);
|
||
const matchSt = !st || (st === 'active' ? j.is_active : !j.is_active);
|
||
const matchSv = !sv || String(j.server_id) === String(sv);
|
||
return matchQ && matchSt && matchSv;
|
||
});
|
||
renderJobs(filtered);
|
||
}
|
||
|
||
function renderJobs(list) {
|
||
const tbody = document.getElementById('jobs-tbody');
|
||
if (!list.length) { tbody.innerHTML = '<tr><td colspan="7" class="empty-state">작업이 없습니다.</td></tr>'; return; }
|
||
tbody.innerHTML = list.map(j => `
|
||
<tr>
|
||
<td>
|
||
<div style="font-weight:600;color:var(--text-bright);">${escHtml(j.job_name)}</div>
|
||
<div style="font-size:11px;color:var(--text-muted);">${escHtml(j.description||'')}</div>
|
||
</td>
|
||
<td style="font-size:12px;">${escHtml(serverName(j.server_id))}</td>
|
||
<td><code style="font-size:11px;background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;">${escHtml(j.cron_expr||'')}</code></td>
|
||
<td style="font-size:12px;">${fmtDate(j.last_run_at)}</td>
|
||
<td>${resultBadge(j.last_result)}</td>
|
||
<td>
|
||
<label class="toggle-switch" title="${j.is_active ? '비활성화' : '활성화'}">
|
||
<input type="checkbox" ${j.is_active ? 'checked' : ''} onchange="toggleJob(${j.id}, this)">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</td>
|
||
<td>
|
||
<div class="action-btns">
|
||
<button class="btn btn-run" onclick="runJob(${j.id}, '${escHtml(j.job_name)}')">▶ 실행</button>
|
||
<button class="btn" onclick="showJobRuns(${j.id})">이력</button>
|
||
<button class="btn" onclick="openJobModal(${j.id})">수정</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
// ── 즉시 실행 ──
|
||
async function runJob(id, name) {
|
||
if (!confirm(`"${name}" 작업을 즉시 실행하시겠습니까?`)) return;
|
||
try {
|
||
const res = await apiFetch(`/api/batch/jobs/${id}/run`, { method: 'POST' });
|
||
if (!res) return;
|
||
if (res.status === 202) {
|
||
showRunBanner(`"${name}" 실행 중...`);
|
||
setTimeout(() => {
|
||
document.querySelectorAll('.tab-btn')[1].click();
|
||
document.getElementById('run-filter-job').value = id;
|
||
loadRuns();
|
||
}, 800);
|
||
} else {
|
||
const d = await res.json();
|
||
alert('실행 오류: ' + (d.detail || JSON.stringify(d)));
|
||
}
|
||
} catch(e) { alert('오류: ' + e.message); }
|
||
}
|
||
|
||
function showRunBanner(msg) {
|
||
const area = document.getElementById('run-status-area');
|
||
area.innerHTML = `<div class="run-status-banner running">⟳ ${msg}</div>`;
|
||
setTimeout(() => area.innerHTML = '', 6000);
|
||
}
|
||
|
||
// ── 토글 활성화 ──
|
||
async function toggleJob(id, checkbox) {
|
||
const enable = checkbox.checked;
|
||
try {
|
||
const res = await apiFetch(`/api/batch/jobs/${id}/${enable ? 'enable' : 'disable'}`, { method: 'POST' });
|
||
if (!res || !res.ok) {
|
||
checkbox.checked = !enable;
|
||
alert('상태 변경 실패');
|
||
return;
|
||
}
|
||
const job = jobs.find(j => j.id === id);
|
||
if (job) job.is_active = enable;
|
||
} catch(e) {
|
||
checkbox.checked = !enable;
|
||
alert('오류: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ── 이력 이동 ──
|
||
function showJobRuns(jobId) {
|
||
document.querySelectorAll('.tab-btn')[1].click();
|
||
document.getElementById('run-filter-job').value = jobId;
|
||
loadRuns();
|
||
}
|
||
|
||
// ── 실행 이력 ──
|
||
async function loadRuns() {
|
||
const jobId = document.getElementById('run-filter-job').value;
|
||
const result = document.getElementById('run-filter-result').value;
|
||
try {
|
||
let url;
|
||
if (jobId) {
|
||
url = `/api/batch/jobs/${jobId}/runs`;
|
||
} else {
|
||
// 전체 이력 — 여러 작업 이력 합산 (백엔드가 전체 endpoint 없으면 각 작업 순회)
|
||
url = '/api/batch/runs';
|
||
}
|
||
const res = await apiFetch(url);
|
||
if (!res || !res.ok) { renderRunsEmpty('실행 이력을 불러올 수 없습니다.'); return; }
|
||
let data = await res.json();
|
||
// result 필터
|
||
if (result) data = data.filter(r => r.result === result);
|
||
runs = data;
|
||
updateTodayStats(data);
|
||
renderRuns(data);
|
||
} catch(e) { renderRunsEmpty('오류: ' + e.message); }
|
||
}
|
||
|
||
function updateTodayStats(data) {
|
||
const today = new Date().toDateString();
|
||
const todayRuns = data.filter(r => new Date(r.started_at).toDateString() === today);
|
||
document.getElementById('stat-today-runs').textContent = todayRuns.length;
|
||
document.getElementById('stat-today-fails').textContent = todayRuns.filter(r => r.result === 'FAILED' || r.result === 'TIMEOUT').length;
|
||
}
|
||
|
||
function renderRunsEmpty(msg) {
|
||
document.getElementById('runs-tbody').innerHTML =
|
||
`<tr><td colspan="7" class="empty-state">${msg}</td></tr>`;
|
||
}
|
||
|
||
function renderRuns(list) {
|
||
const tbody = document.getElementById('runs-tbody');
|
||
if (!list.length) { tbody.innerHTML = '<tr><td colspan="7" class="empty-state">실행 이력이 없습니다.</td></tr>'; return; }
|
||
tbody.innerHTML = list.map(r => {
|
||
const duration = (r.started_at && r.ended_at)
|
||
? fmtDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||
: (r.result === 'RUNNING' ? '실행 중' : '—');
|
||
const stdoutSummary = r.stdout ? escHtml(r.stdout.substring(0, 80)) + (r.stdout.length > 80 ? '...' : '') : '—';
|
||
const jobName = getJobName(r.job_id);
|
||
return `
|
||
<tr style="cursor:pointer;" onclick="showRunDetail(${r.id})">
|
||
<td style="font-size:12px;white-space:nowrap;">${fmtDate(r.started_at)}</td>
|
||
<td style="font-weight:500;">${escHtml(jobName)}</td>
|
||
<td style="font-size:12px;">${escHtml(serverName(r.server_id || ''))}</td>
|
||
<td>${resultBadge(r.result)}</td>
|
||
<td style="font-size:12px;">${r.exit_code != null ? r.exit_code : '—'}</td>
|
||
<td style="font-size:12px;">${duration}</td>
|
||
<td style="font-size:11px;color:var(--text-muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${stdoutSummary}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function getJobName(jobId) {
|
||
const job = jobs.find(j => j.id === jobId);
|
||
return job ? job.job_name : String(jobId || '');
|
||
}
|
||
|
||
// ── 실행 이력 상세 ──
|
||
async function showRunDetail(runId) {
|
||
try {
|
||
const res = await apiFetch(`/api/batch/runs/${runId}`);
|
||
if (!res || !res.ok) { alert('상세 정보를 불러올 수 없습니다.'); return; }
|
||
const r = await res.json();
|
||
const duration = (r.started_at && r.ended_at)
|
||
? fmtDuration(new Date(r.ended_at) - new Date(r.started_at)) : '—';
|
||
const jobName = getJobName(r.job_id);
|
||
document.getElementById('run-detail-content').innerHTML = `
|
||
<div class="detail-row">
|
||
<div class="detail-kv"><span class="k">작업명: </span><span class="v">${escHtml(jobName)}</span></div>
|
||
<div class="detail-kv"><span class="k">결과: </span>${resultBadge(r.result)}</div>
|
||
<div class="detail-kv"><span class="k">종료 코드: </span><span class="v">${r.exit_code != null ? r.exit_code : '—'}</span></div>
|
||
<div class="detail-kv"><span class="k">소요: </span><span class="v">${duration}</span></div>
|
||
</div>
|
||
<div class="detail-row">
|
||
<div class="detail-kv"><span class="k">시작: </span><span class="v">${fmtDate(r.started_at)}</span></div>
|
||
<div class="detail-kv"><span class="k">종료: </span><span class="v">${fmtDate(r.ended_at)}</span></div>
|
||
</div>
|
||
${r.stdout ? `<div style="margin-top:12px;font-size:12px;color:var(--text-muted);margin-bottom:4px;">stdout</div><div class="code-block">${escHtml(r.stdout)}</div>` : ''}
|
||
${r.stderr ? `<div style="margin-top:10px;font-size:12px;color:#f87171;margin-bottom:4px;">stderr</div><div class="code-block" style="border:1px solid #f8717144;">${escHtml(r.stderr)}</div>` : ''}
|
||
${r.error_msg ? `<div style="margin-top:10px;font-size:12px;color:#fb923c;margin-bottom:4px;">오류 메시지</div><div class="code-block" style="border:1px solid #fb923c44;">${escHtml(r.error_msg)}</div>` : ''}
|
||
`;
|
||
openModal('run-detail-modal');
|
||
} catch(e) { alert('오류: ' + e.message); }
|
||
}
|
||
|
||
// ── 작업 등록/수정 모달 ──
|
||
function openJobModal(jobId) {
|
||
editingJobId = jobId || null;
|
||
document.getElementById('job-modal-title').textContent = jobId ? '배치 작업 수정' : '배치 작업 등록';
|
||
document.getElementById('job-submit-btn').textContent = jobId ? '저장' : '등록';
|
||
document.getElementById('job-form').reset();
|
||
document.getElementById('job-id').value = '';
|
||
// 서버 드롭다운 갱신
|
||
const formSf = document.getElementById('f-server-id');
|
||
formSf.innerHTML = '<option value="">서버 선택...</option>' +
|
||
servers.map(s => `<option value="${s.id}">${s.name || s.hostname}</option>`).join('');
|
||
|
||
if (jobId) {
|
||
const job = jobs.find(j => j.id === jobId);
|
||
if (job) {
|
||
document.getElementById('job-id').value = job.id;
|
||
document.getElementById('f-job-name').value = job.job_name || '';
|
||
document.getElementById('f-description').value = job.description || '';
|
||
document.getElementById('f-server-id').value = job.server_id || '';
|
||
document.getElementById('f-cron-expr').value = job.cron_expr || '';
|
||
document.getElementById('f-command').value = job.command || '';
|
||
document.getElementById('f-timeout-sec').value = job.timeout_sec || '';
|
||
document.getElementById('f-alert-on-fail').value = String(job.alert_on_fail !== false);
|
||
document.getElementById('f-is-active').value = String(job.is_active !== false);
|
||
updateCronHint(job.cron_expr || '');
|
||
}
|
||
}
|
||
openModal('job-modal');
|
||
}
|
||
|
||
function updateCronHint(val) {
|
||
const hint = document.getElementById('cron-hint-text');
|
||
const presets = {
|
||
'0 2 * * *': '매일 02:00 실행',
|
||
'*/15 * * * *':'15분마다 실행',
|
||
'0 * * * *': '매 시간 정각 실행',
|
||
'0 0 * * *': '매일 자정 실행',
|
||
'0 0 * * 0': '매주 일요일 자정 실행',
|
||
'0 0 1 * *': '매월 1일 자정 실행',
|
||
'*/5 * * * *': '5분마다 실행',
|
||
'30 6 * * 1-5':'평일 06:30 실행',
|
||
};
|
||
const desc = presets[val.trim()];
|
||
hint.innerHTML = desc
|
||
? `<span style="color:#4ade80;">✓ ${desc}</span><br>형식: <code>분 시 일 월 요일</code>`
|
||
: `예: <code>0 2 * * *</code> = 매일 02:00 / <code>*/15 * * * *</code> = 15분마다<br>형식: <code>분 시 일 월 요일</code> (요일: 0=일,1=월,...,6=토)`;
|
||
}
|
||
|
||
async function submitJob(e) {
|
||
e.preventDefault();
|
||
const jobId = document.getElementById('job-id').value;
|
||
const body = {
|
||
job_name: document.getElementById('f-job-name').value,
|
||
description: document.getElementById('f-description').value,
|
||
server_id: document.getElementById('f-server-id').value,
|
||
cron_expr: document.getElementById('f-cron-expr').value,
|
||
command: document.getElementById('f-command').value,
|
||
timeout_sec: parseInt(document.getElementById('f-timeout-sec').value) || null,
|
||
alert_on_fail: document.getElementById('f-alert-on-fail').value === 'true',
|
||
is_active: document.getElementById('f-is-active').value === 'true',
|
||
};
|
||
try {
|
||
const res = jobId
|
||
? await apiFetch(`/api/batch/jobs/${jobId}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||
: await apiFetch('/api/batch/jobs', { method: 'POST', body: JSON.stringify(body) });
|
||
if (!res) return;
|
||
if (res.ok) {
|
||
closeModal('job-modal');
|
||
loadJobs();
|
||
} 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');
|
||
});
|
||
|
||
// ── XSS 방어 ──
|
||
function escHtml(s) {
|
||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ── 사용자 정보 ──
|
||
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) {}
|
||
}
|
||
}
|
||
|
||
// ── 초기화 ──
|
||
(async function init() {
|
||
if (!getToken()) { location.href = '/login'; return; }
|
||
loadUserInfo();
|
||
await loadServers();
|
||
await loadJobs();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|