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>
594 lines
30 KiB
HTML
594 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>
|
||
.page-wrap { display:flex; flex-direction:column; height:100vh; }
|
||
|
||
/* 상단 네비 */
|
||
.topnav { display:flex; align-items:center; gap:16px; padding:0 24px; height:52px; background:var(--card-bg); border-bottom:1px solid var(--border); flex-shrink:0; }
|
||
.topnav-logo { font-weight:700; font-size:16px; color:var(--accent); text-decoration:none; }
|
||
.topnav-right { margin-left:auto; display:flex; align-items:center; gap:12px; font-size:13px; color:var(--text-muted); }
|
||
|
||
/* 본문 */
|
||
.page-content { flex:1; overflow-y:auto; padding:28px; max-width:920px; margin:0 auto; width:100%; box-sizing:border-box; }
|
||
.page-header { display:flex; align-items:center; gap:12px; margin-bottom:24px; }
|
||
.page-title { font-size:22px; font-weight:700; color:var(--text-bright); }
|
||
|
||
/* 상태 배너 */
|
||
.status-banner { border-radius:10px; padding:14px 20px; margin-bottom:20px; font-size:14px; display:flex; align-items:center; gap:10px; }
|
||
.status-banner.valid { background:rgba(52,211,153,.1); border:1px solid rgba(52,211,153,.3); color:#34d399; }
|
||
.status-banner.trial { background:rgba(251,191,36,.1); border:1px solid rgba(251,191,36,.4); color:#fbbf24; }
|
||
.status-banner.warn { background:rgba(251,191,36,.1); border:1px solid rgba(251,191,36,.3); color:#fbbf24; }
|
||
.status-banner.expired { background:rgba(248,113,113,.1); border:1px solid rgba(248,113,113,.3); color:#f87171; }
|
||
.status-banner.none { background:rgba(107,114,128,.08); border:1px solid rgba(107,114,128,.2); color:var(--text-muted); }
|
||
.banner-icon { font-size:20px; flex-shrink:0; }
|
||
|
||
/* 카드 */
|
||
.card { background:var(--card-bg); border:1px solid var(--border); border-radius:12px; padding:20px 24px; margin-bottom:20px; }
|
||
.card-title { font-size:15px; font-weight:600; color:var(--text-bright); margin-bottom:16px; }
|
||
|
||
/* 체험 CTA 카드 */
|
||
.trial-cta {
|
||
border-radius:14px; padding:28px 32px; margin-bottom:20px;
|
||
background:linear-gradient(135deg,rgba(251,191,36,.08) 0%,rgba(251,191,36,.03) 100%);
|
||
border:1px solid rgba(251,191,36,.3);
|
||
display:flex; align-items:center; gap:24px; flex-wrap:wrap;
|
||
}
|
||
.trial-cta-left { flex:1; min-width:200px; }
|
||
.trial-cta-title { font-size:18px; font-weight:700; color:#fbbf24; margin-bottom:6px; }
|
||
.trial-cta-desc { font-size:13px; color:var(--text-muted); line-height:1.6; }
|
||
.trial-cta-desc strong { color:var(--text-primary); }
|
||
.trial-badges { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
|
||
.trial-limit-tag { background:rgba(251,191,36,.12); color:#fbbf24; font-size:11px; font-weight:600; padding:3px 10px; border-radius:12px; }
|
||
.btn-trial { background:linear-gradient(135deg,#f59e0b,#fbbf24); color:#1a1a1a; border:none; border-radius:10px; padding:12px 28px; font-size:14px; font-weight:700; cursor:pointer; transition:opacity .15s, transform .1s; white-space:nowrap; }
|
||
.btn-trial:hover { opacity:.9; transform:translateY(-1px); }
|
||
.btn-trial:disabled { opacity:.5; cursor:not-allowed; transform:none; }
|
||
|
||
/* 에디션 배지 */
|
||
.edition-badge { display:inline-block; padding:4px 14px; border-radius:20px; font-size:13px; font-weight:700; letter-spacing:.5px; }
|
||
.edition-TRIAL { background:rgba(251,191,36,.2); color:#fbbf24; }
|
||
.edition-COMMUNITY { background:rgba(107,114,128,.2); color:#9ca3af; }
|
||
.edition-STANDARD { background:rgba(96,165,250,.15); color:#60a5fa; }
|
||
.edition-ENTERPRISE{ background:rgba(167,139,250,.15); color:#a78bfa; }
|
||
.trial-badge-inline { font-size:11px; background:rgba(251,191,36,.15); color:#fbbf24; padding:2px 8px; border-radius:8px; font-weight:600; vertical-align:middle; margin-left:6px; }
|
||
|
||
/* 만료 D-day */
|
||
.dday { font-size:28px; font-weight:800; color:#fbbf24; line-height:1; }
|
||
.dday.urgent { color:#f87171; }
|
||
.dday-label { font-size:11px; color:var(--text-muted); margin-top:2px; }
|
||
|
||
/* 정보 그리드 */
|
||
.info-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||
.info-item label { font-size:11px; color:var(--text-muted); font-weight:600; text-transform:uppercase; letter-spacing:.5px; display:block; margin-bottom:4px; }
|
||
.info-item span { font-size:14px; color:var(--text-primary); }
|
||
|
||
/* 사용량 프로그레스 */
|
||
.usage-grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px; }
|
||
.usage-item { background:var(--main-bg,#0f172a); border-radius:8px; padding:14px; }
|
||
.usage-label { font-size:11px; color:var(--text-muted); font-weight:600; text-transform:uppercase; margin-bottom:8px; }
|
||
.usage-bar-wrap { background:var(--border); border-radius:4px; height:6px; overflow:hidden; margin-bottom:6px; }
|
||
.usage-bar { height:100%; border-radius:4px; transition:width .4s; }
|
||
.usage-bar.ok { background:#34d399; }
|
||
.usage-bar.warn { background:#fbbf24; }
|
||
.usage-bar.full { background:#f87171; }
|
||
.usage-numbers { font-size:13px; color:var(--text-primary); font-weight:600; }
|
||
.usage-numbers small { font-weight:400; color:var(--text-muted); }
|
||
|
||
/* 기능 목록 */
|
||
.features-list { display:flex; flex-wrap:wrap; gap:8px; }
|
||
.feature-tag { padding:4px 12px; border-radius:6px; font-size:12px; font-weight:600; background:rgba(99,102,241,.12); color:#818cf8; }
|
||
|
||
/* 입력폼 */
|
||
.form-group { margin-bottom:14px; }
|
||
.form-label { font-size:12px; font-weight:600; color:var(--text-muted); display:block; margin-bottom:6px; }
|
||
.form-input { width:100%; box-sizing:border-box; background:var(--input-bg); border:1px solid var(--border); border-radius:8px; padding:10px 14px; color:var(--text-primary); font-size:14px; outline:none; font-family:monospace; }
|
||
.form-input:focus { border-color:var(--accent); }
|
||
textarea.form-input { resize:vertical; min-height:80px; font-size:12px; }
|
||
|
||
/* 버튼 */
|
||
.btn-primary { background:var(--accent); color:#fff; border:none; border-radius:8px; padding:9px 20px; font-size:13px; font-weight:600; cursor:pointer; transition:opacity .15s; }
|
||
.btn-primary:hover { opacity:.85; }
|
||
.btn-primary:disabled { opacity:.5; cursor:not-allowed; }
|
||
.btn-secondary { background:var(--sidebar-hover-bg); color:var(--text-primary); border:none; border-radius:8px; padding:9px 20px; font-size:13px; font-weight:600; cursor:pointer; transition:opacity .15s; }
|
||
.btn-secondary:hover { opacity:.85; }
|
||
.btn-danger { background:#ef4444; color:#fff; border:none; border-radius:8px; padding:9px 20px; font-size:13px; font-weight:600; cursor:pointer; transition:opacity .15s; }
|
||
.btn-danger:hover { opacity:.85; }
|
||
|
||
/* 이력 테이블 */
|
||
.tbl-wrap { overflow-x:auto; }
|
||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||
th { background:var(--main-bg,#0f172a); color:var(--text-muted); font-weight:600; padding:10px 12px; text-align:left; border-bottom:1px solid var(--border); }
|
||
td { padding:10px 12px; border-bottom:1px solid var(--border); color:var(--text-primary); }
|
||
tr:last-child td { border-bottom:none; }
|
||
.badge-active { background:rgba(52,211,153,.15); color:#34d399; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
|
||
.badge-inactive { background:rgba(107,114,128,.15); color:#9ca3af; padding:2px 8px; border-radius:4px; font-size:11px; }
|
||
.badge-trial { background:rgba(251,191,36,.15); color:#fbbf24; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; margin-left:4px; }
|
||
|
||
/* 알림 */
|
||
.alert { padding:10px 16px; border-radius:8px; font-size:13px; margin-top:12px; }
|
||
.alert-success { background:rgba(52,211,153,.1); border:1px solid rgba(52,211,153,.3); color:#34d399; }
|
||
.alert-error { background:rgba(248,113,113,.1); border:1px solid rgba(248,113,113,.3); color:#f87171; }
|
||
.hidden { display:none; }
|
||
|
||
/* 키 표시 박스 */
|
||
.key-box { background:var(--main-bg,#0f172a); border:1px solid var(--border); border-radius:8px; padding:12px 14px; font-family:monospace; font-size:12px; color:#34d399; word-break:break-all; margin-top:10px; position:relative; }
|
||
.btn-copy { position:absolute; top:8px; right:8px; background:var(--border); color:var(--text-muted); border:none; border-radius:5px; padding:3px 8px; font-size:11px; cursor:pointer; }
|
||
.btn-copy:hover { color:var(--text-primary); }
|
||
|
||
/* 체험 D-day 카운터 */
|
||
.trial-counter { display:flex; align-items:center; gap:20px; background:var(--main-bg,#0f172a); border-radius:10px; padding:16px 20px; margin-bottom:16px; border:1px solid rgba(251,191,36,.2); }
|
||
.trial-counter-days { text-align:center; }
|
||
|
||
@media (max-width:600px) {
|
||
.info-grid { grid-template-columns:1fr; }
|
||
.usage-grid { grid-template-columns:1fr; }
|
||
.trial-cta { flex-direction:column; }
|
||
}
|
||
</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>
|
||
<span style="color:var(--text-muted);font-size:13px;">/ 라이선스 관리</span>
|
||
<div class="topnav-right">
|
||
<span id="top-username"></span>
|
||
<button class="btn-primary" style="padding:5px 14px;font-size:12px;" onclick="logout()">로그아웃</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="page-content">
|
||
<div class="page-header">
|
||
<div class="page-title">🔏 라이선스 관리</div>
|
||
</div>
|
||
|
||
<!-- 상태 배너 -->
|
||
<div id="status-banner" class="status-banner none">
|
||
<span class="banner-icon">⏳</span>
|
||
<span id="banner-msg">라이선스 상태를 불러오는 중...</span>
|
||
</div>
|
||
|
||
<!-- ① 체험판 CTA (라이선스 없을 때만 표시) -->
|
||
<div id="trial-cta-card" class="trial-cta hidden">
|
||
<div class="trial-cta-left">
|
||
<div class="trial-cta-title">🎁 7일 무료 체험 시작</div>
|
||
<div class="trial-cta-desc">
|
||
<strong>라이선스 키 없이</strong> 지금 바로 시작하세요.<br>
|
||
체험 기간 동안 GUARDiA ITSM의 핵심 기능을 무료로 사용할 수 있습니다.
|
||
</div>
|
||
<div class="trial-badges">
|
||
<span class="trial-limit-tag">기관 1개</span>
|
||
<span class="trial-limit-tag">사용자 10명</span>
|
||
<span class="trial-limit-tag">서버 20대</span>
|
||
<span class="trial-limit-tag">MFA 포함</span>
|
||
<span class="trial-limit-tag">설치당 1회</span>
|
||
</div>
|
||
</div>
|
||
<button class="btn-trial" id="btn-trial-start" onclick="startTrial()">무료 체험 시작</button>
|
||
</div>
|
||
|
||
<!-- ② 현재 라이선스 정보 -->
|
||
<div class="card">
|
||
<div class="card-title">현재 라이선스</div>
|
||
<div id="no-license-msg" style="color:var(--text-muted);font-size:14px;padding:4px 0;">
|
||
활성 라이선스가 없습니다. 위의 무료 체험을 시작하거나 라이선스 키를 등록하세요.
|
||
</div>
|
||
<div id="license-info" class="hidden">
|
||
<!-- 체험판 D-day 카운터 -->
|
||
<div id="trial-counter" class="trial-counter hidden">
|
||
<div class="trial-counter-days">
|
||
<div class="dday" id="trial-dday">-</div>
|
||
<div class="dday-label">일 남음</div>
|
||
</div>
|
||
<div>
|
||
<div style="font-size:13px;font-weight:600;color:var(--text-primary);">7일 무료 체험 진행 중</div>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:3px;">만료 전 정식 라이선스를 구매하면 데이터가 유지됩니다.</div>
|
||
</div>
|
||
</div>
|
||
<!-- 에디션 + 만료 -->
|
||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
||
<span id="edition-badge" class="edition-badge">-</span>
|
||
<span id="days-remaining" style="font-size:13px;color:var(--text-muted);"></span>
|
||
</div>
|
||
<div class="info-grid">
|
||
<div class="info-item"><label>고객명</label><span id="info-customer">-</span></div>
|
||
<div class="info-item"><label>라이선스 ID</label><span id="info-lid" style="font-family:monospace;font-size:12px;">-</span></div>
|
||
<div class="info-item"><label>발급일</label><span id="info-issued">-</span></div>
|
||
<div class="info-item"><label>만료일</label><span id="info-expires">-</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ③ 사용량 현황 -->
|
||
<div class="card">
|
||
<div class="card-title">사용량 현황</div>
|
||
<div class="usage-grid">
|
||
<div class="usage-item">
|
||
<div class="usage-label">기관</div>
|
||
<div class="usage-bar-wrap"><div class="usage-bar" id="bar-inst" style="width:0%"></div></div>
|
||
<div class="usage-numbers"><span id="cnt-inst">-</span> <small>/ <span id="max-inst">-</span></small></div>
|
||
</div>
|
||
<div class="usage-item">
|
||
<div class="usage-label">사용자</div>
|
||
<div class="usage-bar-wrap"><div class="usage-bar" id="bar-user" style="width:0%"></div></div>
|
||
<div class="usage-numbers"><span id="cnt-user">-</span> <small>/ <span id="max-user">-</span></small></div>
|
||
</div>
|
||
<div class="usage-item">
|
||
<div class="usage-label">서버</div>
|
||
<div class="usage-bar-wrap"><div class="usage-bar" id="bar-srv" style="width:0%"></div></div>
|
||
<div class="usage-numbers"><span id="cnt-srv">-</span> <small>/ <span id="max-srv">-</span></small></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ④ 활성화된 기능 -->
|
||
<div class="card">
|
||
<div class="card-title">활성화된 기능</div>
|
||
<div class="features-list" id="features-list">
|
||
<span style="color:var(--text-muted);font-size:13px;">라이선스를 등록하면 기능 목록이 표시됩니다.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ⑤ 라이선스 키 등록 / 갱신 -->
|
||
<div class="card">
|
||
<div class="card-title">라이선스 키 등록 / 갱신</div>
|
||
<div class="form-group">
|
||
<label class="form-label">라이선스 키 (GRD-... 형식)</label>
|
||
<textarea class="form-input" id="input-key" placeholder="GRD-xxxxxxxx..." rows="3"></textarea>
|
||
</div>
|
||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||
<button class="btn-primary" id="btn-activate" onclick="activateLicense()">라이선스 등록</button>
|
||
<button class="btn-secondary" onclick="verifyLicense()">검증만 하기</button>
|
||
<button class="btn-danger" onclick="deactivateLicense()">라이선스 비활성화</button>
|
||
</div>
|
||
<div id="activate-alert" class="hidden"></div>
|
||
</div>
|
||
|
||
<!-- ⑥ 체험 라이선스 키 표시 (발급 직후) -->
|
||
<div class="card hidden" id="card-trial-key">
|
||
<div class="card-title">🎁 체험 라이선스 키 (보관용)</div>
|
||
<div style="font-size:13px;color:var(--text-muted);margin-bottom:8px;">
|
||
아래 키는 이미 자동 활성화되었습니다. 백업용으로 보관하세요.
|
||
</div>
|
||
<div class="key-box" id="trial-key-display">
|
||
<button class="btn-copy" onclick="copyKey()">복사</button>
|
||
<span id="trial-key-text"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ⑦ 등록 이력 -->
|
||
<div class="card">
|
||
<div class="card-title">등록 이력</div>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>라이선스 ID</th>
|
||
<th>에디션</th>
|
||
<th>고객명</th>
|
||
<th>만료일</th>
|
||
<th>상태</th>
|
||
<th>등록자</th>
|
||
<th>등록일시</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="history-tbody">
|
||
<tr><td colspan="7" style="color:var(--text-muted);text-align:center;">불러오는 중...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
const _token = localStorage.getItem("guardia_token");
|
||
const _userInfo = JSON.parse(localStorage.getItem("guardia_userinfo") || "{}");
|
||
if (!_token) { window.location.replace("/login"); }
|
||
document.getElementById("top-username").textContent = _userInfo.display_name || _userInfo.username || "";
|
||
|
||
function logout() {
|
||
localStorage.removeItem("guardia_token");
|
||
localStorage.removeItem("guardia_userinfo");
|
||
window.location.replace("/login");
|
||
}
|
||
|
||
async function authFetch(url, opts = {}) {
|
||
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
|
||
if (_token) headers["Authorization"] = `Bearer ${_token}`;
|
||
const res = await fetch(url, { ...opts, headers });
|
||
if (res.status === 401) { logout(); throw new Error("Unauthorized"); }
|
||
return res;
|
||
}
|
||
|
||
// ── 헬퍼 ──────────────────────────────────────────────────────────────────────
|
||
function fmtDate(iso) {
|
||
if (!iso) return "-";
|
||
return iso.replace("T", " ").substring(0, 16) + " UTC";
|
||
}
|
||
|
||
function setBar(barId, count, max) {
|
||
const bar = document.getElementById(barId);
|
||
if (!bar) return;
|
||
const pct = max < 0 ? 0 : Math.min(100, Math.round(count / max * 100));
|
||
bar.style.width = (max < 0 ? 0 : pct) + "%";
|
||
bar.className = "usage-bar " + (pct >= 100 ? "full" : pct >= 80 ? "warn" : "ok");
|
||
}
|
||
|
||
// ── 라이선스 상태 로드 ────────────────────────────────────────────────────────
|
||
async function loadStatus() {
|
||
const banner = document.getElementById("status-banner");
|
||
const bannerMsg = document.getElementById("banner-msg");
|
||
const trialCta = document.getElementById("trial-cta-card");
|
||
|
||
try {
|
||
const res = await authFetch("/api/license/status");
|
||
if (!res.ok) { bannerMsg.textContent = "라이선스 상태를 가져올 수 없습니다."; return; }
|
||
const s = await res.json();
|
||
|
||
// ── 배너 ──
|
||
if (!s.activated) {
|
||
banner.className = "status-banner none";
|
||
bannerMsg.textContent = "활성 라이선스가 없습니다. 무료 체험을 시작하거나 라이선스를 등록하세요.";
|
||
document.querySelector(".banner-icon").textContent = "ℹ️";
|
||
trialCta.classList.remove("hidden"); // CTA 표시
|
||
} else if (s.expired) {
|
||
banner.className = "status-banner expired";
|
||
bannerMsg.textContent = `라이선스가 만료되었습니다 (만료일: ${fmtDate(s.expires_at)}). 갱신이 필요합니다.`;
|
||
document.querySelector(".banner-icon").textContent = "❌";
|
||
trialCta.classList.add("hidden");
|
||
} else if (s.is_trial && s.days_remaining <= 2) {
|
||
banner.className = "status-banner expired";
|
||
bannerMsg.textContent = `⚠️ 체험판 D-${s.days_remaining} — 만료 임박! 정식 라이선스를 등록하세요.`;
|
||
document.querySelector(".banner-icon").textContent = "🔥";
|
||
trialCta.classList.add("hidden");
|
||
} else if (s.is_trial) {
|
||
banner.className = "status-banner trial";
|
||
bannerMsg.textContent = `🎁 7일 무료 체험 중 — D-${s.days_remaining} (${s.customer})`;
|
||
document.querySelector(".banner-icon").textContent = "🎁";
|
||
trialCta.classList.add("hidden");
|
||
} else if (s.expiry_warning) {
|
||
banner.className = "status-banner warn";
|
||
bannerMsg.textContent = `라이선스 만료 ${s.days_remaining}일 전입니다. 갱신을 준비하세요.`;
|
||
document.querySelector(".banner-icon").textContent = "⚠️";
|
||
trialCta.classList.add("hidden");
|
||
} else {
|
||
banner.className = "status-banner valid";
|
||
bannerMsg.textContent = `${s.edition} 라이선스 활성 — ${s.days_remaining}일 남음 (${s.customer})`;
|
||
document.querySelector(".banner-icon").textContent = "✅";
|
||
trialCta.classList.add("hidden");
|
||
}
|
||
|
||
// ── 라이선스 정보 카드 ──
|
||
if (s.activated && s.license_id) {
|
||
document.getElementById("no-license-msg").classList.add("hidden");
|
||
document.getElementById("license-info").classList.remove("hidden");
|
||
|
||
// 체험판 D-day 카운터
|
||
const counter = document.getElementById("trial-counter");
|
||
const ddayEl = document.getElementById("trial-dday");
|
||
if (s.is_trial && s.valid) {
|
||
counter.classList.remove("hidden");
|
||
ddayEl.textContent = s.days_remaining;
|
||
ddayEl.className = "dday" + (s.days_remaining <= 2 ? " urgent" : "");
|
||
} else {
|
||
counter.classList.add("hidden");
|
||
}
|
||
|
||
const badge = document.getElementById("edition-badge");
|
||
const trialSuffix = s.is_trial ? " [체험]" : "";
|
||
badge.textContent = (s.edition || "-") + trialSuffix;
|
||
badge.className = "edition-badge edition-" + (s.edition || "COMMUNITY");
|
||
|
||
document.getElementById("days-remaining").textContent =
|
||
s.expired ? "(만료됨)" : `(${s.days_remaining}일 남음)`;
|
||
document.getElementById("info-customer").textContent = s.customer || "-";
|
||
document.getElementById("info-lid").textContent = s.license_id || "-";
|
||
document.getElementById("info-issued").textContent = fmtDate(s.issued_at);
|
||
document.getElementById("info-expires").textContent = fmtDate(s.expires_at);
|
||
} else {
|
||
document.getElementById("no-license-msg").classList.remove("hidden");
|
||
document.getElementById("license-info").classList.add("hidden");
|
||
}
|
||
|
||
// ── 기능 목록 ──
|
||
const feats = (s.limits || {}).features || [];
|
||
const featWrap = document.getElementById("features-list");
|
||
if (feats.length) {
|
||
featWrap.innerHTML = feats.map(f => `<span class="feature-tag">${f}</span>`).join("");
|
||
}
|
||
|
||
// ── 사용량 ──
|
||
await loadUsage(s.limits || {});
|
||
} catch (e) {
|
||
bannerMsg.textContent = "상태 로드 오류: " + e.message;
|
||
}
|
||
}
|
||
|
||
async function loadUsage(limits) {
|
||
try {
|
||
const [instAll, srvAll] = await Promise.all([
|
||
authFetch("/api/institutions").then(r => r.ok ? r.json() : []),
|
||
authFetch("/api/cmdb/servers").then(r => r.ok ? r.json() : []),
|
||
]);
|
||
const userAll = await authFetch("/api/auth/admin/users")
|
||
.then(r => r.ok ? r.json() : []).catch(() => []);
|
||
|
||
const instCount = Array.isArray(instAll) ? instAll.length : 0;
|
||
const srvCount = Array.isArray(srvAll) ? srvAll.length : 0;
|
||
const userCount = Array.isArray(userAll) ? userAll.length : 0;
|
||
|
||
const maxInst = limits.max_institutions ?? 1;
|
||
const maxSrv = limits.max_servers ?? 20;
|
||
const maxUsr = limits.max_users ?? 10;
|
||
|
||
document.getElementById("cnt-inst").textContent = instCount;
|
||
document.getElementById("max-inst").textContent = maxInst < 0 ? "무제한" : maxInst;
|
||
document.getElementById("cnt-user").textContent = userCount;
|
||
document.getElementById("max-user").textContent = maxUsr < 0 ? "무제한" : maxUsr;
|
||
document.getElementById("cnt-srv").textContent = srvCount;
|
||
document.getElementById("max-srv").textContent = maxSrv < 0 ? "무제한" : maxSrv;
|
||
|
||
if (maxInst >= 0) setBar("bar-inst", instCount, maxInst);
|
||
if (maxUsr >= 0) setBar("bar-user", userCount, maxUsr);
|
||
if (maxSrv >= 0) setBar("bar-srv", srvCount, maxSrv);
|
||
} catch (e) {
|
||
console.warn("사용량 로드 실패:", e);
|
||
}
|
||
}
|
||
|
||
// ── 이력 로드 ──────────────────────────────────────────────────────────────────
|
||
async function loadHistory() {
|
||
const tbody = document.getElementById("history-tbody");
|
||
try {
|
||
const res = await authFetch("/api/license/history");
|
||
if (!res.ok) {
|
||
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--text-muted);text-align:center;">이력을 불러올 수 없습니다 (권한 필요).</td></tr>`;
|
||
return;
|
||
}
|
||
const rows = await res.json();
|
||
if (!rows.length) {
|
||
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--text-muted);text-align:center;">등록 이력이 없습니다.</td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = rows.map(r => `
|
||
<tr>
|
||
<td style="font-family:monospace;font-size:12px;">${r.license_id}</td>
|
||
<td>
|
||
<span class="edition-badge edition-${r.edition}" style="font-size:11px;padding:2px 8px;">${r.edition}</span>
|
||
${r.is_trial ? '<span class="badge-trial">체험</span>' : ''}
|
||
</td>
|
||
<td>${r.customer}</td>
|
||
<td>${fmtDate(r.expires_at)}</td>
|
||
<td>${r.is_active ? '<span class="badge-active">활성</span>' : '<span class="badge-inactive">비활성</span>'}</td>
|
||
<td>${r.activated_by || "-"}</td>
|
||
<td>${fmtDate(r.activated_at)}</td>
|
||
</tr>
|
||
`).join("");
|
||
} catch (e) {
|
||
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--text-muted);text-align:center;">오류: ${e.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
// ── 🎁 무료 체험 시작 ─────────────────────────────────────────────────────────
|
||
async function startTrial() {
|
||
if (!confirm("7일 무료 체험을 시작하시겠습니까?\n(설치당 1회만 가능합니다)")) return;
|
||
const btn = document.getElementById("btn-trial-start");
|
||
btn.disabled = true;
|
||
btn.textContent = "처리 중...";
|
||
|
||
try {
|
||
const res = await authFetch("/api/license/trial", {
|
||
method: "POST",
|
||
body: JSON.stringify({ customer: "GUARDiA 체험판" }),
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
alert("❌ 체험 시작 실패: " + (data.detail || res.statusText));
|
||
btn.disabled = false;
|
||
btn.textContent = "무료 체험 시작";
|
||
return;
|
||
}
|
||
|
||
// 체험 라이선스 키 표시
|
||
if (data.license_key) {
|
||
const keyCard = document.getElementById("card-trial-key");
|
||
keyCard.classList.remove("hidden");
|
||
document.getElementById("trial-key-text").textContent = data.license_key;
|
||
}
|
||
|
||
// 상태 갱신
|
||
await loadStatus();
|
||
await loadHistory();
|
||
} catch (e) {
|
||
alert("요청 실패: " + e.message);
|
||
btn.disabled = false;
|
||
btn.textContent = "무료 체험 시작";
|
||
}
|
||
}
|
||
|
||
// ── 라이선스 등록 ──────────────────────────────────────────────────────────────
|
||
async function activateLicense() {
|
||
const key = document.getElementById("input-key").value.trim();
|
||
if (!key) { showAlert("라이선스 키를 입력하세요.", "error"); return; }
|
||
const btn = document.getElementById("btn-activate");
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await authFetch("/api/license/activate", {
|
||
method: "POST", body: JSON.stringify({ license_key: key }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { showAlert("오류: " + (data.detail || res.statusText), "error"); return; }
|
||
showAlert(data.message || "라이선스가 등록되었습니다.", "success");
|
||
document.getElementById("input-key").value = "";
|
||
await loadStatus(); await loadHistory();
|
||
} catch (e) {
|
||
showAlert("요청 실패: " + e.message, "error");
|
||
} finally { btn.disabled = false; }
|
||
}
|
||
|
||
// ── 검증만 ─────────────────────────────────────────────────────────────────────
|
||
async function verifyLicense() {
|
||
const key = document.getElementById("input-key").value.trim();
|
||
if (!key) { showAlert("라이선스 키를 입력하세요.", "error"); return; }
|
||
try {
|
||
const res = await authFetch("/api/license/verify", {
|
||
method: "POST", body: JSON.stringify({ license_key: key }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { showAlert("검증 실패: " + (data.detail || res.statusText), "error"); return; }
|
||
const msg = `검증 성공 — ${data.edition} | ${data.customer} | 만료: ${fmtDate(data.expires_at)} | 남은 일수: ${data.days_remaining}일`;
|
||
showAlert(msg, "success");
|
||
} catch (e) { showAlert("요청 실패: " + e.message, "error"); }
|
||
}
|
||
|
||
// ── 비활성화 ───────────────────────────────────────────────────────────────────
|
||
async function deactivateLicense() {
|
||
if (!confirm("라이선스를 비활성화하면 제한 모드로 전환됩니다. 계속하시겠습니까?")) return;
|
||
try {
|
||
const res = await authFetch("/api/license/", { method: "DELETE" });
|
||
const data = await res.json();
|
||
if (!res.ok) { showAlert("오류: " + (data.detail || res.statusText), "error"); return; }
|
||
showAlert(data.message || "라이선스가 비활성화되었습니다.", "success");
|
||
await loadStatus(); await loadHistory();
|
||
} catch (e) { showAlert("요청 실패: " + e.message, "error"); }
|
||
}
|
||
|
||
// ── 키 복사 ────────────────────────────────────────────────────────────────────
|
||
async function copyKey() {
|
||
const txt = document.getElementById("trial-key-text").textContent;
|
||
try {
|
||
await navigator.clipboard.writeText(txt);
|
||
const btn = document.querySelector(".btn-copy");
|
||
btn.textContent = "✓ 복사됨";
|
||
setTimeout(() => btn.textContent = "복사", 2000);
|
||
} catch (e) { alert("복사 실패. 직접 선택하여 복사하세요."); }
|
||
}
|
||
|
||
// ── 알림 ───────────────────────────────────────────────────────────────────────
|
||
function showAlert(msg, type) {
|
||
const el = document.getElementById("activate-alert");
|
||
el.className = "alert alert-" + (type === "success" ? "success" : "error");
|
||
el.textContent = msg;
|
||
el.classList.remove("hidden");
|
||
setTimeout(() => el.classList.add("hidden"), 8000);
|
||
}
|
||
|
||
// ── 초기 로드 ──────────────────────────────────────────────────────────────────
|
||
(async () => {
|
||
await loadStatus();
|
||
await loadHistory();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|