zioinfo-mail/itsm/static/batch.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

686 lines
30 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 변수 브릿지 (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 &nbsp;/&nbsp; <code>*/15 * * * *</code> = 15분마다<br>
형식: <code>분 시 일 월 요일</code> &nbsp;(요일: 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 &nbsp;/&nbsp; <code>*/15 * * * *</code> = 15분마다<br>형식: <code>분 시 일 월 요일</code> &nbsp;(요일: 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 사용자 정보 ──
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>