guardia-itsm/static/license.html
DESKTOP-TKLFCPRython 64c27c3509 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

594 lines
30 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
.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>