soyailabs / templates /admin_tokens.html
GitHub Actions
Auto-deploy from GitHub Actions - 2025-12-11 09:26:27
c7e9f96
raw
history blame
24.6 kB
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ํ†ต๊ณ„ - SOY NV AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
color: #202124;
}
.header {
background: white;
border-bottom: 1px solid #dadce0;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.header-title {
font-size: 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.menu-toggle {
display: none;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 8px;
color: #202124;
}
.mobile-menu {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.mobile-menu.active {
display: block;
}
.mobile-menu-content {
position: fixed;
top: 0;
right: -100%;
width: 280px;
max-width: 80%;
height: 100%;
background: white;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
transition: right 0.3s ease;
overflow-y: auto;
z-index: 1001;
}
.mobile-menu.active .mobile-menu-content {
right: 0;
}
.mobile-menu-header {
padding: 16px 20px;
border-bottom: 1px solid #dadce0;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
position: sticky;
top: 0;
z-index: 10;
}
.mobile-menu-title {
font-size: 18px;
font-weight: 500;
}
.mobile-menu-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #202124;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-menu-user {
padding: 12px 20px;
background: #f8f9fa;
border-bottom: 1px solid #dadce0;
font-size: 14px;
color: #5f6368;
}
.mobile-menu-items {
padding: 8px 0;
}
.mobile-menu-item {
display: block;
padding: 12px 20px;
color: #202124;
text-decoration: none;
font-size: 14px;
transition: background 0.2s;
}
.mobile-menu-item:hover {
background: #f8f9fa;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-primary {
background: #1a73e8;
color: white;
}
.btn-primary:hover {
background: #1557b0;
}
.btn-secondary {
background: #f1f3f4;
color: #202124;
}
.btn-secondary:hover {
background: #e8eaed;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.page-header p {
color: #5f6368;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
padding: 24px;
margin-bottom: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-title {
font-size: 18px;
font-weight: 500;
}
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-group label {
font-size: 12px;
color: #5f6368;
font-weight: 500;
}
.filter-group input,
.filter-group select {
padding: 8px 12px;
border: 1px solid #dadce0;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
color: #5f6368;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #202124;
}
.chart-container {
position: relative;
height: 400px;
margin-bottom: 24px;
}
.alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.alert.error {
background: #fce8e6;
color: #c5221f;
}
.alert.success {
background: #e8f5e9;
color: #137333;
}
@media (max-width: 768px) {
.header {
padding: 12px 16px;
}
.header-title {
font-size: 18px;
}
.menu-toggle {
display: block;
}
.header-actions {
display: none;
}
.container {
padding: 16px;
}
.filters {
flex-direction: column;
}
.filter-group {
width: 100%;
}
.chart-container {
height: 300px;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">
<span>๐Ÿ“Š</span>
<span>ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ํ†ต๊ณ„</span>
</div>
<button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
<div class="header-actions">
<span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
<a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
<a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
<a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
<a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
<a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
<a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
</div>
</div>
<!-- ๋ชจ๋ฐ”์ผ ๋ฉ”๋‰ด -->
<div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
<div class="mobile-menu-content" onclick="event.stopPropagation()">
<div class="mobile-menu-header">
<div class="mobile-menu-title">๋ฉ”๋‰ด</div>
<button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ๋‹ซ๊ธฐ">&times;</button>
</div>
<div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
<div class="mobile-menu-items">
<a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
<a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
<a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
<a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
<a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
<a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
<a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
</div>
</div>
</div>
<div class="container">
<div class="page-header">
<h1>ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ํ†ต๊ณ„</h1>
<p>AI ๋ชจ๋ธ๋ณ„ ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰์„ ์‹œ๊ฐํ™”ํ•˜์—ฌ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
</div>
<div id="alertContainer"></div>
<!-- ํ•„ํ„ฐ -->
<div class="card">
<div class="card-header">
<div class="card-title">ํ•„ํ„ฐ</div>
</div>
<div class="filters">
<div class="filter-group">
<label for="startDate">์‹œ์ž‘ ๋‚ ์งœ</label>
<input type="date" id="startDate">
</div>
<div class="filter-group">
<label for="endDate">์ข…๋ฃŒ ๋‚ ์งœ</label>
<input type="date" id="endDate">
</div>
<div class="filter-group">
<label for="modelFilter">AI ๋ชจ๋ธ</label>
<select id="modelFilter">
<option value="">์ „์ฒด</option>
</select>
</div>
<div class="filter-group">
<label for="groupBy">๊ทธ๋ฃนํ™”</label>
<select id="groupBy">
<option value="day">์ผ๋ณ„</option>
<option value="model">๋ชจ๋ธ๋ณ„</option>
</select>
</div>
<div class="filter-group" style="justify-content: flex-end;">
<label>&nbsp;</label>
<button class="btn btn-primary" onclick="loadTokenUsage()">์กฐํšŒ</button>
</div>
</div>
</div>
<!-- ํ†ต๊ณ„ ์š”์•ฝ -->
<div class="stats-grid" id="statsGrid">
<!-- ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋จ -->
</div>
<!-- ๊ทธ๋ž˜ํ”„ -->
<div class="card">
<div class="card-header">
<div class="card-title">ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ๊ทธ๋ž˜ํ”„</div>
</div>
<div class="chart-container">
<canvas id="tokenChart"></canvas>
</div>
</div>
</div>
<script>
let tokenChart = null;
function toggleMobileMenu() {
const menu = document.getElementById('mobileMenu');
menu.classList.toggle('active');
document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
}
function closeMobileMenu() {
const menu = document.getElementById('mobileMenu');
menu.classList.remove('active');
document.body.style.overflow = '';
}
function closeMobileMenuOnBackdrop(event) {
if (event.target.id === 'mobileMenu') {
closeMobileMenu();
}
}
function showAlert(message, type = 'success') {
const container = document.getElementById('alertContainer');
container.innerHTML = `<div class="alert ${type}">${message}</div>`;
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
// ๋‚ ์งœ ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • (์ตœ๊ทผ 30์ผ)
function setDefaultDates() {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
document.getElementById('endDate').value = endDate.toISOString().split('T')[0];
document.getElementById('startDate').value = startDate.toISOString().split('T')[0];
}
// ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์กฐํšŒ
async function loadTokenUsage() {
try {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const modelName = document.getElementById('modelFilter').value;
const groupBy = document.getElementById('groupBy').value;
if (!startDate || !endDate) {
showAlert('์‹œ์ž‘ ๋‚ ์งœ์™€ ์ข…๋ฃŒ ๋‚ ์งœ๋ฅผ ๋ชจ๋‘ ์„ ํƒํ•ด์ฃผ์„ธ์š”.', 'error');
return;
}
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
group_by: groupBy
});
if (modelName) {
params.append('model_name', modelName);
}
console.log('[ํ† ํฐ ํ†ต๊ณ„] ์š”์ฒญ URL:', `/api/admin/token-usage?${params}`);
const response = await fetch(`/api/admin/token-usage?${params}`, {
method: 'GET',
credentials: 'include'
});
const data = await response.json();
console.log('[ํ† ํฐ ํ†ต๊ณ„] ์‘๋‹ต ๋ฐ์ดํ„ฐ:', data);
if (response.ok && data.success) {
if (data.stats && data.stats.length > 0) {
updateStats(data.stats, data.total_messages, data.user_usage, data.system_usage);
updateChart(data.stats, groupBy);
} else {
showAlert('์„ ํƒํ•œ ๊ธฐ๊ฐ„์— ํ† ํฐ ์ •๋ณด๊ฐ€ ์žˆ๋Š” ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.', 'error');
// ๋นˆ ํ†ต๊ณ„ ํ‘œ์‹œ
updateStats([], 0, null, null);
updateChart([], groupBy);
}
} else {
console.error('[ํ† ํฐ ํ†ต๊ณ„] API ์˜ค๋ฅ˜:', data);
showAlert(data.error || 'ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
}
} catch (error) {
console.error('ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์กฐํšŒ ์˜ค๋ฅ˜:', error);
showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
}
}
// ํ†ต๊ณ„ ์š”์•ฝ ์—…๋ฐ์ดํŠธ
function updateStats(stats, totalMessages, userUsage, systemUsage) {
const statsGrid = document.getElementById('statsGrid');
let totalInput = 0;
let totalOutput = 0;
let totalTokens = 0;
if (stats && stats.length > 0) {
stats.forEach(stat => {
totalInput += stat.input_tokens || 0;
totalOutput += stat.output_tokens || 0;
totalTokens += stat.total_tokens || 0;
});
}
// ์‹œ์Šคํ…œ ์‚ฌ์šฉ ํ†ต๊ณ„
let systemInput = 0;
let systemOutput = 0;
let systemTotal = 0;
if (systemUsage) {
systemInput = systemUsage.input_tokens || 0;
systemOutput = systemUsage.output_tokens || 0;
systemTotal = systemUsage.total_tokens || 0;
}
statsGrid.innerHTML = `
<div class="stat-card">
<div class="stat-label">์ด ์ž…๋ ฅ ํ† ํฐ</div>
<div class="stat-value">${totalInput.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">์ด ์ถœ๋ ฅ ํ† ํฐ</div>
<div class="stat-value">${totalOutput.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">์ด ํ† ํฐ</div>
<div class="stat-value">${totalTokens.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">์ด ๋ฉ”์‹œ์ง€ ์ˆ˜</div>
<div class="stat-value">${totalMessages.toLocaleString()}</div>
</div>
<div class="stat-card" style="border-left: 4px solid #34a853;">
<div class="stat-label">์‹œ์Šคํ…œ ์‚ฌ์šฉ (์ž…๋ ฅ)</div>
<div class="stat-value">${systemInput.toLocaleString()}</div>
</div>
<div class="stat-card" style="border-left: 4px solid #34a853;">
<div class="stat-label">์‹œ์Šคํ…œ ์‚ฌ์šฉ (์ถœ๋ ฅ)</div>
<div class="stat-value">${systemOutput.toLocaleString()}</div>
</div>
<div class="stat-card" style="border-left: 4px solid #34a853;">
<div class="stat-label">์‹œ์Šคํ…œ ์‚ฌ์šฉ (์ด)</div>
<div class="stat-value">${systemTotal.toLocaleString()}</div>
</div>
`;
}
// ๊ทธ๋ž˜ํ”„ ์—…๋ฐ์ดํŠธ
function updateChart(stats, groupBy) {
const ctx = document.getElementById('tokenChart').getContext('2d');
if (tokenChart) {
tokenChart.destroy();
}
let labels, inputData, outputData;
if (!stats || stats.length === 0) {
// ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ๋•Œ ๋นˆ ๊ทธ๋ž˜ํ”„ ํ‘œ์‹œ
labels = ['๋ฐ์ดํ„ฐ ์—†์Œ'];
inputData = [0];
outputData = [0];
} else if (groupBy === 'day') {
labels = stats.map(s => s.date);
inputData = stats.map(s => s.input_tokens || 0);
outputData = stats.map(s => s.output_tokens || 0);
} else if (groupBy === 'model') {
labels = stats.map(s => s.model || 'Unknown');
inputData = stats.map(s => s.input_tokens || 0);
outputData = stats.map(s => s.output_tokens || 0);
} else {
labels = ['์ „์ฒด'];
inputData = [stats[0]?.input_tokens || 0];
outputData = [stats[0]?.output_tokens || 0];
}
tokenChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '์ž…๋ ฅ ํ† ํฐ',
data: inputData,
borderColor: 'rgb(26, 115, 232)',
backgroundColor: 'rgba(26, 115, 232, 0.1)',
tension: 0.4
},
{
label: '์ถœ๋ ฅ ํ† ํฐ',
data: outputData,
borderColor: 'rgb(234, 67, 53)',
backgroundColor: 'rgba(234, 67, 53, 0.1)',
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: 'ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์ด'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value.toLocaleString();
}
}
}
}
}
});
}
// ๋ชจ๋ธ ๋ชฉ๋ก ๋กœ๋“œ
async function loadModels() {
try {
// ๋‚ ์งœ ๋ฒ”์œ„๋ฅผ ๋„“๊ฒŒ ์„ค์ •ํ•˜์—ฌ ๋ชจ๋“  ๋ชจ๋ธ ์กฐํšŒ
const endDate = new Date();
const startDate = new Date();
startDate.setFullYear(startDate.getFullYear() - 1); // 1๋…„ ์ „๋ถ€ํ„ฐ
const params = new URLSearchParams({
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
group_by: 'model'
});
const response = await fetch(`/api/admin/token-usage?${params}`, {
method: 'GET',
credentials: 'include'
});
const data = await response.json();
if (response.ok && data.success && data.models) {
const modelFilter = document.getElementById('modelFilter');
// ๊ธฐ์กด ์˜ต์…˜ ์œ ์ง€ (์ „์ฒด ์˜ต์…˜)
const existingOptions = Array.from(modelFilter.options).map(opt => opt.value);
data.models.forEach(model => {
if (!existingOptions.includes(model)) {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
modelFilter.appendChild(option);
}
});
}
} catch (error) {
console.error('๋ชจ๋ธ ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
}
}
// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
document.addEventListener('DOMContentLoaded', function() {
setDefaultDates();
loadModels();
loadTokenUsage();
});
</script>
</body>
</html>