- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
580 lines
26 KiB
HTML
580 lines
26 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 — SSL 관리</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<style>
|
|
.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; }
|
|
.page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
|
|
.page-title { font-size:22px; font-weight:700; color:var(--text); }
|
|
|
|
/* 현황 카드 */
|
|
.stat-row { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:20px; }
|
|
.stat-box {
|
|
flex:1; min-width:130px; background:var(--bg-card); border:1px solid var(--border);
|
|
border-radius:10px; padding:16px 20px; text-align:center; cursor:pointer;
|
|
transition:border-color .2s, transform .1s;
|
|
}
|
|
.stat-box:hover { transform:translateY(-2px); }
|
|
.stat-box.active-filter { border-color:var(--accent); }
|
|
.stat-box-val { font-size:30px; font-weight:700; line-height:1.1; }
|
|
.stat-box-lbl { font-size:12px; color:var(--text-muted); margin-top:6px; }
|
|
.val-ok { color:#4ade80; }
|
|
.val-warn { color:#fcd34d; }
|
|
.val-urgent { color:#fb923c; }
|
|
.val-expired{ color:#f87171; }
|
|
|
|
/* 슬라이더 컨트롤 */
|
|
.slider-row {
|
|
display:flex; align-items:center; gap:14px;
|
|
background:var(--bg-card); border:1px solid var(--border);
|
|
border-radius:8px; padding:12px 18px; margin-bottom:16px;
|
|
}
|
|
.slider-row label { font-size:13px; color:var(--text-muted); white-space:nowrap; }
|
|
.slider-row input[type=range] {
|
|
flex:1; accent-color:var(--accent);
|
|
}
|
|
.slider-val { font-size:14px; font-weight:700; color:var(--accent); min-width:60px; }
|
|
|
|
/* 테이블 */
|
|
.table-wrap { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
|
|
.ssl-table { width:100%; border-collapse:collapse; }
|
|
.ssl-table th { font-size:12px; color:var(--text-muted); font-weight:600; padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); background:var(--bg); white-space:nowrap; }
|
|
.ssl-table td { font-size:13px; padding:11px 14px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
|
.ssl-table tr:last-child td { border-bottom:none; }
|
|
.ssl-table tr:hover td { background:var(--bg-hover); }
|
|
|
|
/* 경고 레벨 배지 */
|
|
.ssl-badge {
|
|
display:inline-block; font-size:11px; font-weight:700; padding:3px 9px;
|
|
border-radius:5px; letter-spacing:.3px;
|
|
}
|
|
.ssl-OK { background:#16a34a22; color:#4ade80; border:1px solid #16a34a44; }
|
|
.ssl-WARN { background:#ca8a0422; color:#fcd34d; border:1px solid #ca8a0444; }
|
|
.ssl-URGENT { background:#ea580c22; color:#fb923c; border:1px solid #ea580c44; }
|
|
.ssl-EXPIRED{ background:#dc262622; color:#f87171; border:1px solid #dc262644; }
|
|
|
|
/* 게이지 바 */
|
|
.gauge-wrap { width:100px; height:6px; background:var(--border); border-radius:3px; display:inline-block; vertical-align:middle; }
|
|
.gauge-fill { height:6px; border-radius:3px; transition:width .4s; }
|
|
.gauge-ok { background:#4ade80; }
|
|
.gauge-warn { background:#fcd34d; }
|
|
.gauge-urgent { background:#fb923c; }
|
|
.gauge-expired{ background:#f87171; }
|
|
|
|
/* 버튼 */
|
|
.btn-sm {
|
|
padding:4px 10px; font-size:11px; border-radius:5px; cursor:pointer;
|
|
border:1px solid var(--border); background:var(--bg); color:var(--text);
|
|
transition:background .15s; white-space:nowrap;
|
|
}
|
|
.btn-sm:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
|
|
.btn-sm.checking { opacity:.6; cursor:wait; }
|
|
.btn-sm-renew { border-color:#10b981; color:#10b981; }
|
|
.btn-sm-renew:hover { background:#10b981; color:#fff; border-color:#10b981; }
|
|
|
|
/* 모달 */
|
|
.modal-overlay {
|
|
display:none; position:fixed; inset:0; background:rgba(0,0,0,.65);
|
|
z-index:1000; align-items:flex-start; justify-content:center;
|
|
overflow-y:auto; padding:40px 16px;
|
|
}
|
|
.modal-overlay.open { display:flex; }
|
|
.modal-box {
|
|
background:var(--bg-card); border:1px solid var(--border); border-radius:12px;
|
|
padding:28px; width:520px; max-width:100%; position:relative;
|
|
}
|
|
.modal-box.modal-wide { width:700px; }
|
|
.modal-title { font-size:17px; font-weight:700; margin-bottom:20px; color:var(--text); }
|
|
.modal-close {
|
|
position:absolute; top:16px; right:16px; background:none; border:none;
|
|
color:var(--text-muted); font-size:20px; cursor:pointer; line-height:1;
|
|
}
|
|
.modal-close:hover { color:var(--text); }
|
|
|
|
/* 폼 */
|
|
.form-row { margin-bottom:14px; }
|
|
.form-row label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:5px; font-weight:500; }
|
|
.form-input {
|
|
width:100%; background:var(--bg); border:1px solid var(--border); color:var(--text);
|
|
border-radius:6px; padding:8px 12px; font-size:13px; box-sizing:border-box;
|
|
}
|
|
.form-input:focus { outline:none; border-color:var(--accent); }
|
|
.form-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
|
|
|
|
/* 이력 테이블 */
|
|
.hist-table { width:100%; border-collapse:collapse; margin-top:4px; }
|
|
.hist-table th { font-size:11px; color:var(--text-muted); padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); background:var(--bg); }
|
|
.hist-table td { font-size:12px; padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
|
|
.hist-table tr:last-child td { border-bottom:none; }
|
|
|
|
.empty-state { text-align:center; color:var(--text-muted); padding:48px; font-size:14px; }
|
|
.server-name-link { color:var(--accent); cursor:pointer; text-decoration:underline; text-underline-offset:2px; }
|
|
.server-name-link:hover { opacity:.8; }
|
|
|
|
/* 필터 바 */
|
|
.filter-bar {
|
|
display:flex; gap:10px; align-items:center; flex-wrap:wrap;
|
|
background:var(--bg-card); border:1px solid var(--border);
|
|
border-radius:8px; padding:10px 16px; margin-bottom:16px;
|
|
}
|
|
.filter-bar label { font-size:13px; color:var(--text-muted); }
|
|
.filter-select {
|
|
background:var(--bg); border:1px solid var(--border); color:var(--text);
|
|
border-radius:6px; padding:5px 10px; font-size:13px;
|
|
}
|
|
|
|
/* 점검 결과 뱃지 */
|
|
.check-result { font-size:11px; padding:2px 7px; border-radius:4px; font-weight:600; }
|
|
.check-ok { background:#16a34a22; color:#4ade80; }
|
|
.check-fail { background:#dc262622; color:#f87171; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script>document.body.dataset.theme = localStorage.getItem("guardia_theme") || "dark";</script>
|
|
<div class="page-wrap">
|
|
|
|
<!-- 네비게이션 바 -->
|
|
<nav class="topnav">
|
|
<a class="topnav-logo" href="/">GUARDiA ITSM</a>
|
|
<div class="topnav-links">
|
|
<a class="topnav-link" href="/">대시보드</a>
|
|
<a class="topnav-link" href="/incidents">장애관리</a>
|
|
<a class="topnav-link active" 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" href="/vibe">바이브</a>
|
|
<a class="topnav-link" href="/si">SI</a>
|
|
<a class="topnav-link" href="/agents">AI에이전트</a>
|
|
</div>
|
|
<div class="topnav-right">
|
|
<span id="nav-user" style="font-size:12px;color:var(--text-muted)"></span>
|
|
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px" onclick="logout()">로그아웃</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- 메인 콘텐츠 -->
|
|
<div class="page-content">
|
|
<div class="page-header">
|
|
<div class="page-title">SSL 인증서 관리</div>
|
|
<div style="display:flex;gap:8px;align-items:center;">
|
|
<span id="refresh-countdown" style="font-size:12px;color:var(--text-muted)"></span>
|
|
<button class="btn btn-secondary" style="font-size:13px" onclick="loadAll()">새로고침</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 현황 카드 -->
|
|
<div class="stat-row">
|
|
<div class="stat-box" id="card-ok" onclick="setLevelFilter('OK')">
|
|
<div class="stat-box-val val-ok" id="cnt-ok">—</div>
|
|
<div class="stat-box-lbl">OK (정상)</div>
|
|
</div>
|
|
<div class="stat-box" id="card-warn" onclick="setLevelFilter('WARN')">
|
|
<div class="stat-box-val val-warn" id="cnt-warn">—</div>
|
|
<div class="stat-box-lbl">WARN (30일 이내)</div>
|
|
</div>
|
|
<div class="stat-box" id="card-urgent" onclick="setLevelFilter('URGENT')">
|
|
<div class="stat-box-val val-urgent" id="cnt-urgent">—</div>
|
|
<div class="stat-box-lbl">URGENT (7일 이내)</div>
|
|
</div>
|
|
<div class="stat-box" id="card-expired" onclick="setLevelFilter('EXPIRED')">
|
|
<div class="stat-box-val val-expired" id="cnt-expired">—</div>
|
|
<div class="stat-box-lbl">EXPIRED (만료)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 조회 범위 슬라이더 -->
|
|
<div class="slider-row">
|
|
<label>만료 임박 조회 범위</label>
|
|
<input type="range" id="days-slider" min="0" max="90" value="30" oninput="onSliderChange(this.value)">
|
|
<span class="slider-val" id="slider-label">30일 이내</span>
|
|
<button class="btn btn-secondary" style="font-size:12px;padding:4px 12px" onclick="loadExpiring()">적용</button>
|
|
</div>
|
|
|
|
<!-- 레벨 필터 -->
|
|
<div class="filter-bar">
|
|
<label>경고 레벨</label>
|
|
<select class="filter-select" id="f-level" onchange="renderTable()">
|
|
<option value="">전체</option>
|
|
<option value="EXPIRED">EXPIRED</option>
|
|
<option value="URGENT">URGENT</option>
|
|
<option value="WARN">WARN</option>
|
|
<option value="OK">OK</option>
|
|
</select>
|
|
<label style="margin-left:8px;">서버명 검색</label>
|
|
<input type="text" id="f-server" class="filter-select" placeholder="서버명..." oninput="renderTable()" style="min-width:160px;">
|
|
</div>
|
|
|
|
<!-- 만료 현황 테이블 -->
|
|
<div class="table-wrap">
|
|
<table class="ssl-table">
|
|
<thead>
|
|
<tr>
|
|
<th>서버명</th>
|
|
<th>역할/인스티튜션</th>
|
|
<th>만료일</th>
|
|
<th>남은 일수</th>
|
|
<th>게이지</th>
|
|
<th>경고 레벨</th>
|
|
<th>SSH 점검</th>
|
|
<th>갱신</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="ssl-tbody">
|
|
<tr><td colspan="8" class="empty-state">데이터를 불러오는 중...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ 갱신 기록 모달 ══ -->
|
|
<div id="renew-modal" class="modal-overlay">
|
|
<div class="modal-box">
|
|
<button class="modal-close" onclick="closeRenewModal()">✕</button>
|
|
<div class="modal-title" id="renew-modal-title">SSL 갱신 기록</div>
|
|
|
|
<div class="form-row">
|
|
<label>새 만료일 *</label>
|
|
<input id="renew-expire" type="date" class="form-input">
|
|
</div>
|
|
<div class="form-row">
|
|
<label>처리자</label>
|
|
<input id="renew-by" class="form-input" placeholder="처리자 이름">
|
|
</div>
|
|
<div class="form-row">
|
|
<label>인증서 도메인</label>
|
|
<input id="renew-domain" class="form-input" placeholder="예: *.example.com">
|
|
</div>
|
|
<div class="form-row">
|
|
<label>인증서 경로</label>
|
|
<input id="renew-path" class="form-input" placeholder="/etc/ssl/certs/cert.pem">
|
|
</div>
|
|
<div class="form-row">
|
|
<label>발급기관</label>
|
|
<input id="renew-issuer" class="form-input" placeholder="Let's Encrypt, DigiCert 등">
|
|
</div>
|
|
<div class="form-row">
|
|
<label>메모</label>
|
|
<textarea id="renew-note" class="form-input" rows="3" placeholder="갱신 관련 메모..."></textarea>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn btn-secondary" onclick="closeRenewModal()">취소</button>
|
|
<button class="btn btn-primary" onclick="submitRenew()">갱신 기록 저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ 갱신 이력 모달 ══ -->
|
|
<div id="history-modal" class="modal-overlay">
|
|
<div class="modal-box modal-wide">
|
|
<button class="modal-close" onclick="closeHistoryModal()">✕</button>
|
|
<div class="modal-title" id="history-modal-title">갱신 이력</div>
|
|
<div id="history-content">
|
|
<div class="empty-state">로딩 중...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── 인증 ──────────────────────────────────────────────────────────────────────
|
|
(function() {
|
|
const tok = localStorage.getItem('guardia_token');
|
|
if (!tok) { location.href = '/login'; }
|
|
try {
|
|
const payload = JSON.parse(atob(tok.split('.')[1]));
|
|
document.getElementById('nav-user').textContent = payload.sub || '';
|
|
} catch(e) {}
|
|
})();
|
|
|
|
function logout() {
|
|
localStorage.removeItem('guardia_token');
|
|
location.href = '/login';
|
|
}
|
|
|
|
const token = () => localStorage.getItem('guardia_token') || '';
|
|
const headers = () => ({ 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' });
|
|
|
|
async function apiFetch(path, opt = {}) {
|
|
const r = await fetch(path, { ...opt, headers: headers() });
|
|
if (r.status === 401) { location.href = '/login'; return null; }
|
|
if (!r.ok) {
|
|
const e = await r.json().catch(() => ({ detail: '요청 실패' }));
|
|
throw new Error(e.detail || r.statusText);
|
|
}
|
|
if (r.status === 204) return null;
|
|
return r.json();
|
|
}
|
|
|
|
// ── 상태 ──────────────────────────────────────────────────────────────────────
|
|
let allItems = []; // 전체 만료 데이터
|
|
let daysVal = 30;
|
|
let renewServerId = null;
|
|
let countdown = 30;
|
|
|
|
// ── 자동 새로고침 ─────────────────────────────────────────────────────────────
|
|
function startCountdown() {
|
|
const el = document.getElementById('refresh-countdown');
|
|
clearInterval(window._cdi);
|
|
countdown = 30;
|
|
window._cdi = setInterval(() => {
|
|
countdown--;
|
|
el.textContent = `자동갱신 ${countdown}s`;
|
|
if (countdown <= 0) { loadAll(); countdown = 30; }
|
|
}, 1000);
|
|
}
|
|
|
|
// ── 슬라이더 ──────────────────────────────────────────────────────────────────
|
|
function onSliderChange(v) {
|
|
daysVal = parseInt(v);
|
|
document.getElementById('slider-label').textContent = v === '0' ? '만료됨만' : `${v}일 이내`;
|
|
}
|
|
|
|
// ── 전체 로드 ────────────────────────────────────────────────────────────────
|
|
async function loadAll() {
|
|
await Promise.all([loadSummary(), loadExpiring()]);
|
|
startCountdown();
|
|
}
|
|
|
|
// ── 요약 통계 ────────────────────────────────────────────────────────────────
|
|
async function loadSummary() {
|
|
try {
|
|
const d = await apiFetch('/api/ssl/summary');
|
|
if (!d) return;
|
|
const lv = d.by_level || {};
|
|
document.getElementById('cnt-ok').textContent = lv.OK || 0;
|
|
document.getElementById('cnt-warn').textContent = lv.WARN || 0;
|
|
document.getElementById('cnt-urgent').textContent = lv.URGENT || 0;
|
|
document.getElementById('cnt-expired').textContent = lv.EXPIRED || 0;
|
|
} catch(e) { console.warn('SSL 요약 로드 실패:', e); }
|
|
}
|
|
|
|
// ── 만료 현황 목록 ────────────────────────────────────────────────────────────
|
|
async function loadExpiring() {
|
|
try {
|
|
const days = daysVal;
|
|
const include = document.getElementById('f-level').value === 'OK' ? 'true' : 'false';
|
|
const d = await apiFetch(`/api/ssl/expiring?days=${days}&include_ok=true`);
|
|
allItems = d || [];
|
|
renderTable();
|
|
} catch(e) {
|
|
document.getElementById('ssl-tbody').innerHTML =
|
|
`<tr><td colspan="8" class="empty-state">로드 실패: ${e.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
// ── 레벨 필터 카드 클릭 ──────────────────────────────────────────────────────
|
|
function setLevelFilter(level) {
|
|
const sel = document.getElementById('f-level');
|
|
sel.value = (sel.value === level) ? '' : level;
|
|
// 카드 active 표시
|
|
['OK','WARN','URGENT','EXPIRED'].forEach(l => {
|
|
document.getElementById('card-' + l.toLowerCase()).classList.toggle('active-filter', sel.value === l);
|
|
});
|
|
renderTable();
|
|
}
|
|
|
|
// ── 테이블 렌더링 ─────────────────────────────────────────────────────────────
|
|
function renderTable() {
|
|
const levelFilter = document.getElementById('f-level').value;
|
|
const serverFilter = document.getElementById('f-server').value.trim().toLowerCase();
|
|
|
|
let items = allItems;
|
|
if (levelFilter) items = items.filter(i => i.alert_level === levelFilter);
|
|
if (serverFilter) items = items.filter(i => (i.server_name||'').toLowerCase().includes(serverFilter));
|
|
|
|
const tbody = document.getElementById('ssl-tbody');
|
|
if (!items.length) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">해당 조건의 서버가 없습니다.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = items.map(item => {
|
|
const level = item.alert_level || 'OK';
|
|
const daysLeft = item.days_left;
|
|
const gaugeW = daysLeft == null ? 0 : daysLeft < 0 ? 0 : Math.min(100, Math.round((daysLeft / 30) * 100));
|
|
const gaugeClass = { OK:'gauge-ok', WARN:'gauge-warn', URGENT:'gauge-urgent', EXPIRED:'gauge-expired' }[level] || 'gauge-ok';
|
|
const daysStr = daysLeft == null ? '-' : daysLeft < 0 ? `만료 ${Math.abs(daysLeft)}일 경과` : `${daysLeft}일`;
|
|
const daysColor= { EXPIRED:'#f87171', URGENT:'#fb923c', WARN:'#fcd34d', OK:'#4ade80' }[level] || '';
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<span class="server-name-link" onclick="openHistoryModal(${item.server_id}, '${esc(item.server_name)}')">${esc(item.server_name)}</span>
|
|
</td>
|
|
<td style="font-size:12px;color:var(--text-muted);">${esc(item.inst_name || item.server_role || '-')}</td>
|
|
<td style="font-size:12px;white-space:nowrap;">${item.ssl_expire_date || '-'}</td>
|
|
<td style="font-weight:600;color:${daysColor};">${daysStr}</td>
|
|
<td>
|
|
<div class="gauge-wrap">
|
|
<div class="gauge-fill ${gaugeClass}" style="width:${gaugeW}%"></div>
|
|
</div>
|
|
</td>
|
|
<td><span class="ssl-badge ssl-${level}">${level}</span></td>
|
|
<td>
|
|
<button class="btn-sm" id="check-btn-${item.server_id}" onclick="doSSHCheck(${item.server_id}, this)">
|
|
SSH 점검
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<button class="btn-sm btn-sm-renew" onclick="openRenewModal(${item.server_id}, '${esc(item.server_name)}')">
|
|
갱신 기록
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
|
// ── SSH 점검 실행 ────────────────────────────────────────────────────────────
|
|
async function doSSHCheck(serverId, btn) {
|
|
btn.classList.add('checking');
|
|
btn.textContent = '점검 중...';
|
|
try {
|
|
const d = await apiFetch(`/api/ssl/check/${serverId}`, { method: 'POST' });
|
|
if (!d) return;
|
|
const label = d.days_left != null
|
|
? (d.days_left < 0 ? `만료 ${Math.abs(d.days_left)}일 경과` : `D-${d.days_left}`)
|
|
: '';
|
|
showToast(`점검 완료: ${d.server_name} — ${d.alert_level} ${label}`, false);
|
|
if (d.updated) loadAll();
|
|
else {
|
|
btn.textContent = 'SSH 점검';
|
|
btn.classList.remove('checking');
|
|
}
|
|
} catch(e) {
|
|
showToast('점검 실패: ' + e.message, true);
|
|
btn.textContent = 'SSH 점검';
|
|
btn.classList.remove('checking');
|
|
}
|
|
}
|
|
|
|
// ── 갱신 기록 모달 ───────────────────────────────────────────────────────────
|
|
function openRenewModal(serverId, serverName) {
|
|
renewServerId = serverId;
|
|
document.getElementById('renew-modal-title').textContent = `SSL 갱신 기록 — ${serverName}`;
|
|
// 기본 만료일: 1년 후
|
|
const future = new Date();
|
|
future.setFullYear(future.getFullYear() + 1);
|
|
document.getElementById('renew-expire').value = future.toISOString().split('T')[0];
|
|
document.getElementById('renew-by').value = '';
|
|
document.getElementById('renew-domain').value = '';
|
|
document.getElementById('renew-path').value = '';
|
|
document.getElementById('renew-issuer').value = '';
|
|
document.getElementById('renew-note').value = '';
|
|
document.getElementById('renew-modal').classList.add('open');
|
|
}
|
|
function closeRenewModal() {
|
|
document.getElementById('renew-modal').classList.remove('open');
|
|
renewServerId = null;
|
|
}
|
|
|
|
async function submitRenew() {
|
|
if (!renewServerId) return;
|
|
const newExpire = document.getElementById('renew-expire').value;
|
|
if (!newExpire) { showToast('새 만료일을 입력하세요.', true); return; }
|
|
|
|
const body = {
|
|
server_id: renewServerId,
|
|
new_expire: newExpire,
|
|
renewed_by: document.getElementById('renew-by').value.trim() || null,
|
|
cert_domain: document.getElementById('renew-domain').value.trim() || null,
|
|
cert_path: document.getElementById('renew-path').value.trim() || null,
|
|
issuer: document.getElementById('renew-issuer').value.trim() || null,
|
|
note: document.getElementById('renew-note').value.trim() || null,
|
|
};
|
|
try {
|
|
await apiFetch(`/api/ssl/renew/${renewServerId}`, {
|
|
method: 'POST', body: JSON.stringify(body)
|
|
});
|
|
showToast('갱신 기록이 저장되었습니다.');
|
|
closeRenewModal();
|
|
loadAll();
|
|
} catch(e) { showToast('저장 실패: ' + e.message, true); }
|
|
}
|
|
|
|
// ── 갱신 이력 모달 ───────────────────────────────────────────────────────────
|
|
async function openHistoryModal(serverId, serverName) {
|
|
document.getElementById('history-modal-title').textContent = `갱신 이력 — ${serverName}`;
|
|
document.getElementById('history-content').innerHTML = '<div class="empty-state">로딩 중...</div>';
|
|
document.getElementById('history-modal').classList.add('open');
|
|
|
|
try {
|
|
const items = await apiFetch(`/api/ssl/history/${serverId}`) || [];
|
|
if (!items.length) {
|
|
document.getElementById('history-content').innerHTML =
|
|
'<div class="empty-state">갱신 이력이 없습니다.</div>';
|
|
return;
|
|
}
|
|
document.getElementById('history-content').innerHTML = `
|
|
<table class="hist-table">
|
|
<thead>
|
|
<tr>
|
|
<th>갱신일</th>
|
|
<th>이전 만료일</th>
|
|
<th>신규 만료일</th>
|
|
<th>처리자</th>
|
|
<th>도메인</th>
|
|
<th>발급기관</th>
|
|
<th>메모</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${items.map(h => `
|
|
<tr>
|
|
<td style="white-space:nowrap;">${fmtDate(h.created_at)}</td>
|
|
<td style="white-space:nowrap;">${h.old_expire || '-'}</td>
|
|
<td style="white-space:nowrap;color:#4ade80;font-weight:600;">${h.new_expire || '-'}</td>
|
|
<td>${esc(h.renewed_by || '-')}</td>
|
|
<td style="font-size:11px;">${esc(h.cert_domain || '-')}</td>
|
|
<td style="font-size:11px;">${esc(h.issuer || '-')}</td>
|
|
<td style="font-size:11px;max-width:160px;word-break:break-all;">${esc(h.note || '-')}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
} catch(e) {
|
|
document.getElementById('history-content').innerHTML =
|
|
`<div style="color:#ef4444;text-align:center;padding:24px;">로드 실패: ${e.message}</div>`;
|
|
}
|
|
}
|
|
function closeHistoryModal() {
|
|
document.getElementById('history-modal').classList.remove('open');
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '-';
|
|
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
|
return d.toLocaleString('ko-KR', { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
|
|
}
|
|
|
|
// ── 토스트 ────────────────────────────────────────────────────────────────────
|
|
function showToast(msg, error = false) {
|
|
const t = document.createElement('div');
|
|
t.textContent = msg;
|
|
t.style.cssText = `position:fixed;bottom:24px;right:24px;background:${error ? '#dc2626' : '#059669'};
|
|
color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;z-index:9999;
|
|
box-shadow:0 4px 12px rgba(0,0,0,.4);max-width:340px;`;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 3500);
|
|
}
|
|
|
|
// ── 초기화 ────────────────────────────────────────────────────────────────────
|
|
loadAll();
|
|
</script>
|
|
</body>
|
|
</html>
|