feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
This commit is contained in:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
730
apps/api-server/wwwroot/admin/index.html
Normal file
730
apps/api-server/wwwroot/admin/index.html
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>D3RO Voice — Admin BackOffice</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-gradient: radial-gradient(circle at 50% 0%, #171b26 0%, #0b0d13 100%);
|
||||
--card-bg: rgba(22, 27, 38, 0.7);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--accent-glow: rgba(99, 102, 241, 0.35);
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg-gradient);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Layout Header */
|
||||
header {
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(11, 13, 19, 0.8);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.brand-badge {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-btn:hover, .nav-btn.active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.nav-btn.active {
|
||||
border: 1px solid var(--accent-glow);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Main Container */
|
||||
main {
|
||||
flex: 1;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.page-section { display: none; }
|
||||
.page-section.active { display: block; animation: fadeIn 0.3s ease-out; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Metric Cards Grid */
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Data Tables */
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: rgba(16, 185, 129, 0.15); color: var(--success); }
|
||||
.badge-danger { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
|
||||
.badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||
.badge-primary { background: rgba(99, 102, 241, 0.15); color: var(--accent); }
|
||||
|
||||
/* Action Buttons */
|
||||
.btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn:hover { background: var(--accent-hover); box-shadow: 0 0 16px var(--accent-glow); }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.2); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.4); }
|
||||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
/* Section Headers */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Modal Form */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-box {
|
||||
background: #131722;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--card-border);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<i data-lucide="shield-check" style="color: var(--accent);"></i>
|
||||
<span>D3RO VOICE</span>
|
||||
<span class="brand-badge">BackOffice API Server</span>
|
||||
</div>
|
||||
<nav>
|
||||
<button class="nav-btn active" onclick="showTab('dashboard')"><i data-lucide="layout-dashboard"></i> 대시보드</button>
|
||||
<button class="nav-btn" onclick="showTab('users')"><i data-lucide="users"></i> 사용자 관리</button>
|
||||
<button class="nav-btn" onclick="showTab('endpoints')"><i data-lucide="cpu"></i> 서비스 모델 리스트</button>
|
||||
<button class="nav-btn" onclick="showTab('usage')"><i data-lucide="bar-chart-3"></i> 상세 Usage & 비용</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- 1. DASHBOARD -->
|
||||
<section id="dashboard" class="page-section active">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">백엔드 서버 대시보드</h2>
|
||||
<button class="btn btn-sm" onclick="loadStats()"><i data-lucide="refresh-cw"></i> 새로고침</button>
|
||||
</div>
|
||||
|
||||
<div class="grid-4">
|
||||
<div class="card">
|
||||
<div class="card-title">서버 상태 & 업타임 <i data-lucide="activity" style="color: var(--success);"></i></div>
|
||||
<div class="card-value" id="val-uptime">0s</div>
|
||||
<div class="card-sub" id="val-health"><span class="badge badge-success">정상 작동 중</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">총 사용자 수 <i data-lucide="user-check" style="color: var(--accent);"></i></div>
|
||||
<div class="card-value" id="val-users">0</div>
|
||||
<div class="card-sub" id="val-active-today">오늘 접속: 0명</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">API 요청 처리량 <i data-lucide="zap" style="color: var(--warning);"></i></div>
|
||||
<div class="card-value" id="val-requests">0</div>
|
||||
<div class="card-sub">누적 AI API 호출 수</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">총 사용량 & 예상 비용 <i data-lucide="dollar-sign" style="color: var(--success);"></i></div>
|
||||
<div class="card-value" id="val-cost">$0.0000</div>
|
||||
<div class="card-sub">실시간 토큰 비용 계산</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">운영 에러 & 서비스 로그</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>에러 유형</th>
|
||||
<th>메시지</th>
|
||||
<th>엔드포인트</th>
|
||||
<th>발생 일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-errors">
|
||||
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">로그 데이터를 불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. USERS -->
|
||||
<section id="users" class="page-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">가입 사용자 목록</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>이메일</th>
|
||||
<th>권한</th>
|
||||
<th>상태</th>
|
||||
<th>가입 일시</th>
|
||||
<th>최근 로그인</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-users">
|
||||
<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">사용자 목록을 불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. MODEL ENDPOINTS -->
|
||||
<section id="endpoints" class="page-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">서비스 제공 모델 & 엔드포인트 설정</h2>
|
||||
<button class="btn" onclick="openModelModal()"><i data-lucide="plus"></i> 모델 엔드포인트 추가</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>모델 ID</th>
|
||||
<th>모델 이름</th>
|
||||
<th>제공자</th>
|
||||
<th>엔드포인트 URL</th>
|
||||
<th>입력 토큰 비용 ($/1k)</th>
|
||||
<th>출력 토큰 비용 ($/1k)</th>
|
||||
<th>상태</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-endpoints">
|
||||
<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">모델 엔드포인트 데이터를 불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. USAGE & COST -->
|
||||
<section id="usage" class="page-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">상세 Usage & 비용 분석 (BE Home)</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid-4" style="margin-bottom: 24px;">
|
||||
<div class="card">
|
||||
<div class="card-title">총 프롬프트 토큰</div>
|
||||
<div class="card-value" id="val-p-tokens">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">총 완성 토큰</div>
|
||||
<div class="card-value" id="val-c-tokens">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">총 토큰 합계</div>
|
||||
<div class="card-value" id="val-t-tokens">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">누적 과금 금액</div>
|
||||
<div class="card-value" id="val-total-cost">$0.0000</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 24px;">
|
||||
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">사용자별 사용량 및 비용</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자 ID</th>
|
||||
<th>이메일</th>
|
||||
<th>호출 횟수</th>
|
||||
<th>사용 토큰</th>
|
||||
<th>계산 비용 ($)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-user-usage">
|
||||
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용량 리포트를 불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">모델 엔드포인트별 사용량</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>모델 ID</th>
|
||||
<th>모델 이름</th>
|
||||
<th>호출 횟수</th>
|
||||
<th>총 토큰</th>
|
||||
<th>계산 비용 ($)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-model-usage">
|
||||
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용량 리포트를 불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ADD / EDIT ENDPOINT MODAL -->
|
||||
<div class="modal-overlay" id="modelModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<h3 style="font-family: 'Outfit', sans-serif; font-size: 20px;">서비스 모델 엔드포인트 추가</h3>
|
||||
<button onclick="closeModelModal()" style="background:none; border:none; color:var(--text-muted); cursor:pointer;"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<form id="endpointForm" onsubmit="handleSaveEndpoint(event)">
|
||||
<div class="form-group">
|
||||
<label>모델 ID (영문 식별자)</label>
|
||||
<input type="text" id="m-id" class="form-control" placeholder="예: d3ro-custom-model" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>모델 Display 이름</label>
|
||||
<input type="text" id="m-name" class="form-control" placeholder="예: D3RO Premium Custom LLM" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>제공자 (Provider)</label>
|
||||
<input type="text" id="m-provider" class="form-control" placeholder="OpenAI / Custom" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key (선택)</label>
|
||||
<input type="password" id="m-key" class="form-control" placeholder="sk-...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>엔드포인트 URL</label>
|
||||
<input type="url" id="m-url" class="form-control" placeholder="https://api.openai.com/v1/chat/completions" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>입력 토큰 비용 ($/1k)</label>
|
||||
<input type="number" step="0.000001" id="m-cost-p" class="form-control" value="0.000150" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>출력 토큰 비용 ($/1k)</label>
|
||||
<input type="number" step="0.000001" id="m-cost-c" class="form-control" value="0.000600" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
|
||||
<button type="button" class="btn" style="background: transparent; border: 1px solid var(--card-border);" onclick="closeModelModal()">취소</button>
|
||||
<button type="submit" class="btn"><i data-lucide="check"></i> 저장하기</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
function showTab(tabId) {
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.page-section').forEach(sec => sec.classList.remove('active'));
|
||||
|
||||
event.currentTarget.classList.add('active');
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
|
||||
if (tabId === 'dashboard') loadStats();
|
||||
if (tabId === 'users') loadUsers();
|
||||
if (tabId === 'endpoints') loadEndpoints();
|
||||
if (tabId === 'usage') loadUsageReport();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/stats');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('val-uptime').innerText = Math.floor(data.serverUptimeSeconds) + '초';
|
||||
document.getElementById('val-users').innerText = data.totalUsers;
|
||||
document.getElementById('val-active-today').innerText = '오늘 접속: ' + data.activeUsersToday + '명';
|
||||
document.getElementById('val-requests').innerText = data.totalRequests;
|
||||
document.getElementById('val-cost').innerText = '$' + data.totalCost.toFixed(4);
|
||||
|
||||
const tbody = document.getElementById('tbody-errors');
|
||||
if (data.recentErrors.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--success);">에러 로그 없음 (시스템 정상)</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = data.recentErrors.map(e => `
|
||||
<tr>
|
||||
<td>${e.id}</td>
|
||||
<td><span class="badge badge-danger">${e.errorType}</span></td>
|
||||
<td>${e.message}</td>
|
||||
<td>${e.endpoint || '-'}</td>
|
||||
<td>${new Date(e.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users');
|
||||
if (!res.ok) return;
|
||||
const users = await res.json();
|
||||
|
||||
const tbody = document.getElementById('tbody-users');
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">가입된 사용자가 없습니다.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td style="font-weight: 600;">${u.email}</td>
|
||||
<td><span class="badge ${u.role === 'Admin' ? 'badge-primary' : 'badge-warning'}">${u.role}</span></td>
|
||||
<td><span class="badge ${u.isActive ? 'badge-success' : 'badge-danger'}">${u.isActive ? '활성' : '비활성'}</span></td>
|
||||
<td>${new Date(u.createdAt).toLocaleString()}</td>
|
||||
<td>${u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '미접속'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEndpoints() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/endpoints');
|
||||
if (!res.ok) return;
|
||||
const endpoints = await res.json();
|
||||
|
||||
const tbody = document.getElementById('tbody-endpoints');
|
||||
if (endpoints.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">등록된 서비스 모델 엔드포인트가 없습니다.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = endpoints.map(e => `
|
||||
<tr>
|
||||
<td><code>${e.modelId}</code></td>
|
||||
<td style="font-weight:600;">${e.modelName}</td>
|
||||
<td><span class="badge badge-primary">${e.provider}</span></td>
|
||||
<td style="font-size:12px; color:var(--text-muted);">${e.endpointUrl}</td>
|
||||
<td>$${e.costPer1kPromptTokens.toFixed(6)}</td>
|
||||
<td>$${e.costPer1kCompletionTokens.toFixed(6)}</td>
|
||||
<td><span class="badge ${e.isActive ? 'badge-success' : 'badge-danger'}">${e.isActive ? '제공중' : '중지'}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteEndpoint(${e.id})">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsageReport() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/usage');
|
||||
if (!res.ok) return;
|
||||
const report = await res.json();
|
||||
|
||||
document.getElementById('val-p-tokens').innerText = report.totalPromptTokens.toLocaleString();
|
||||
document.getElementById('val-c-tokens').innerText = report.totalCompletionTokens.toLocaleString();
|
||||
document.getElementById('val-t-tokens').innerText = (report.totalPromptTokens + report.totalCompletionTokens).toLocaleString();
|
||||
document.getElementById('val-total-cost').innerText = '$' + report.totalCost.toFixed(4);
|
||||
|
||||
const tbodyUser = document.getElementById('tbody-user-usage');
|
||||
if (report.userSummaries.length === 0) {
|
||||
tbodyUser.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용 이력이 없습니다.</td></tr>';
|
||||
} else {
|
||||
tbodyUser.innerHTML = report.userSummaries.map(u => `
|
||||
<tr>
|
||||
<td>${u.userId}</td>
|
||||
<td>${u.email}</td>
|
||||
<td>${u.totalRequests}회</td>
|
||||
<td>${u.totalTokens.toLocaleString()}</td>
|
||||
<td style="font-weight:700; color:var(--success);">$${u.totalCost.toFixed(6)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const tbodyModel = document.getElementById('tbody-model-usage');
|
||||
if (report.modelSummaries.length === 0) {
|
||||
tbodyModel.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">모델 사용 이력이 없습니다.</td></tr>';
|
||||
} else {
|
||||
tbodyModel.innerHTML = report.modelSummaries.map(m => `
|
||||
<tr>
|
||||
<td><code>${m.modelId}</code></td>
|
||||
<td>${m.modelName}</td>
|
||||
<td>${m.totalRequests}회</td>
|
||||
<td>${m.totalTokens.toLocaleString()}</td>
|
||||
<td style="font-weight:700; color:var(--success);">$${m.totalCost.toFixed(6)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function openModelModal() {
|
||||
document.getElementById('modelModal').classList.add('active');
|
||||
}
|
||||
function closeModelModal() {
|
||||
document.getElementById('modelModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function handleSaveEndpoint(e) {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
modelId: document.getElementById('m-id').value,
|
||||
modelName: document.getElementById('m-name').value,
|
||||
provider: document.getElementById('m-provider').value,
|
||||
apiKey: document.getElementById('m-key').value,
|
||||
endpointUrl: document.getElementById('m-url').value,
|
||||
costPer1kPromptTokens: parseFloat(document.getElementById('m-cost-p').value),
|
||||
costPer1kCompletionTokens: parseFloat(document.getElementById('m-cost-c').value)
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/endpoints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
closeModelModal();
|
||||
loadEndpoints();
|
||||
document.getElementById('endpointForm').reset();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.message || '저장 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('에러 발생: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEndpoint(id) {
|
||||
if (!confirm('이 서비스 모델 엔드포인트를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/endpoints/' + id, { method: 'DELETE' });
|
||||
if (res.ok) loadEndpoints();
|
||||
} catch (err) {
|
||||
alert('삭제 실패: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
loadStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue