Files
DosVault/templates/admin.html
2025-09-06 18:51:10 -04:00

1390 lines
59 KiB
HTML

{% extends "base.html" %}
{% block title %}Admin Dashboard - DosVault{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 text-primary">Admin Dashboard</h1>
<p class="text-secondary">System overview and management</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center">
<div class="text-3xl text-accent mr-4">🎮</div>
<div>
<p class="text-2xl font-bold text-primary">{{ total_games }}</p>
<p class="text-secondary text-sm">Total Games</p>
</div>
</div>
</div>
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center">
<div class="text-3xl text-accent mr-4">👥</div>
<div>
<p class="text-2xl font-bold text-primary">{{ total_users }}</p>
<p class="text-secondary text-sm">Total Users</p>
</div>
</div>
</div>
<div class="bg-secondary rounded-lg p-6 border border-theme">
<a href="/admin/users" class="flex items-center hover:bg-tertiary rounded transition-colors">
<div class="text-3xl text-accent mr-4">⚙️</div>
<div>
<p class="text-lg font-semibold text-primary">Manage Users</p>
<p class="text-secondary text-sm">Create & Edit</p>
</div>
</a>
</div>
<div class="bg-secondary rounded-lg p-6 border border-theme">
<a href="/" class="flex items-center hover:bg-tertiary rounded transition-colors">
<div class="text-3xl text-accent mr-4">📚</div>
<div>
<p class="text-lg font-semibold text-primary">Browse Games</p>
<p class="text-secondary text-sm">View Library</p>
</div>
</a>
</div>
</div>
<!-- System Management Controls -->
<div class="mb-8">
<h2 class="text-2xl font-bold mb-6">System Management</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<!-- ROM Scanning -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<h3 class="text-lg font-semibold">ROM Scanner</h3>
</div>
<p class="text-secondary text-sm mb-4">Scan directories for new ROM files and add them to the database</p>
<button onclick="triggerRomScan()" class="w-full bg-accent hover:bg-accent px-4 py-2 rounded font-medium transition-colors" id="scan-btn">
<span class="scan-text">Start ROM Scan</span>
<span class="scan-loading hidden">Scanning...</span>
</button>
<div id="scan-status" class="mt-2 text-sm text-secondary"></div>
</div>
<!-- Metadata Refresh -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<h3 class="text-lg font-semibold">Metadata Refresh</h3>
</div>
<p class="text-secondary text-sm mb-4">Update game metadata and refresh image information from IGDB</p>
<button onclick="triggerMetadataRefresh()" class="w-full bg-accent hover:bg-accent px-4 py-2 rounded font-medium transition-colors" id="metadata-btn">
<span class="metadata-text">Refresh Metadata</span>
<span class="metadata-loading hidden">Refreshing...</span>
</button>
<div id="metadata-status" class="mt-2 text-sm text-secondary"></div>
</div>
<!-- Image Downloads -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<h3 class="text-lg font-semibold">Image Sync</h3>
</div>
<p class="text-secondary text-sm mb-4">Download missing cover art and screenshots locally</p>
<button onclick="triggerImageSync()" class="w-full bg-accent hover:bg-accent px-4 py-2 rounded font-medium transition-colors" id="images-btn">
<span class="images-text">Sync Images</span>
<span class="images-loading hidden">Syncing...</span>
</button>
<div id="images-status" class="mt-2 text-sm text-secondary"></div>
</div>
<!-- Database Cleanup -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
<h3 class="text-lg font-semibold">Database Cleanup</h3>
</div>
<p class="text-secondary text-sm mb-4">Remove orphaned records and cleanup missing files</p>
<button onclick="triggerDatabaseCleanup()" class="w-full bg-warning-color hover:opacity-80 px-4 py-2 rounded font-medium transition-colors" id="cleanup-btn">
<span class="cleanup-text">Cleanup Database</span>
<span class="cleanup-loading hidden">Cleaning...</span>
</button>
<div id="cleanup-status" class="mt-2 text-sm text-secondary"></div>
</div>
<!-- System Statistics -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<h3 class="text-lg font-semibold">System Stats</h3>
</div>
<p class="text-secondary text-sm mb-4">View detailed statistics and system health</p>
<button onclick="showSystemStats()" class="w-full bg-tertiary hover:bg-opacity-80 px-4 py-2 rounded font-medium transition-colors">
View Statistics
</button>
</div>
<!-- Configuration Editor -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"/>
</svg>
<h3 class="text-lg font-semibold">Configuration</h3>
</div>
<p class="text-secondary text-sm mb-4">Edit system configuration settings</p>
<button onclick="showConfigEditor()" class="w-full bg-info-color hover:opacity-80 px-4 py-2 rounded font-medium transition-colors">
Edit Configuration
</button>
</div>
<!-- Cache Management -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
<h3 class="text-lg font-semibold">Cache Control</h3>
</div>
<p class="text-secondary text-sm mb-4">Clear application caches and temporary files</p>
<button onclick="triggerCacheClear()" class="w-full bg-danger-color hover:opacity-80 px-4 py-2 rounded font-medium transition-colors" id="cache-btn">
<span class="cache-text">Clear Caches</span>
<span class="cache-loading hidden">Clearing...</span>
</button>
<div id="cache-status" class="mt-2 text-sm text-secondary"></div>
</div>
<!-- System Logs -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3 class="text-lg font-semibold">System Logs</h3>
</div>
<p class="text-secondary text-sm mb-4">View application logs and system events</p>
<button onclick="showSystemLogs()" class="w-full bg-info-color hover:opacity-80 px-4 py-2 rounded font-medium transition-colors">
View System Logs
</button>
</div>
<!-- Log Files -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<h3 class="text-lg font-semibold">Log Management</h3>
</div>
<p class="text-secondary text-sm mb-4">Download and manage log files</p>
<div class="space-y-2">
<button onclick="downloadLogs('application')" class="w-full bg-tertiary hover:bg-opacity-80 px-4 py-2 rounded font-medium transition-colors">
Download Application Logs
</button>
<button onclick="clearLogs()" class="w-full bg-warning-color hover:opacity-80 px-4 py-2 rounded font-medium transition-colors">
Clear Old Logs
</button>
</div>
</div>
<!-- Live Log Stream -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<h3 class="text-lg font-semibold">Live Log Stream</h3>
</div>
<p class="text-secondary text-sm mb-4">Monitor real-time application logs</p>
<div class="space-y-2">
<button onclick="toggleLogStream()" class="w-full bg-accent hover:bg-opacity-80 px-4 py-2 rounded font-medium transition-colors" id="stream-btn">
<span id="stream-text">Start Log Stream</span>
</button>
<select id="stream-level-filter" onchange="updateStreamFilter()" class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-sm">
<option value="">All Levels</option>
<option value="ERROR">Error Only</option>
<option value="WARNING">Warning & Error</option>
<option value="INFO">Info & Above</option>
</select>
</div>
</div>
</div>
</div>
<!-- System Statistics Modal -->
<div id="stats-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4">
<div class="bg-secondary rounded-lg p-6 max-w-4xl w-full max-h-screen overflow-y-auto border border-theme">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold">System Statistics</h3>
<button onclick="hideSystemStats()" class="text-secondary hover:text-primary">&times;</button>
</div>
<div id="stats-content" class="space-y-4">
<p class="text-secondary">Loading statistics...</p>
</div>
</div>
</div>
<!-- System Logs Modal -->
<div id="logs-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4">
<div class="bg-secondary rounded-lg p-6 max-w-6xl w-full max-h-screen overflow-y-auto border border-theme">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold">System Logs</h3>
<div class="flex space-x-2">
<select id="log-level-filter" onchange="filterLogs()" class="px-3 py-1 bg-tertiary border border-theme rounded text-sm">
<option value="">All Levels</option>
<option value="ERROR">Error</option>
<option value="WARNING">Warning</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
</select>
<button onclick="refreshLogs()" class="px-3 py-1 bg-accent hover:bg-opacity-80 rounded text-sm font-medium transition-colors">
Refresh
</button>
<button onclick="hideSystemLogs()" class="text-secondary hover:text-primary text-xl">&times;</button>
</div>
</div>
<div id="logs-content" class="bg-tertiary rounded p-4 font-mono text-sm max-h-96 overflow-y-auto border border-theme">
<p class="text-secondary">Loading logs...</p>
</div>
</div>
</div>
<!-- Live Log Stream Modal -->
<div id="stream-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4">
<div class="bg-secondary rounded-lg p-6 max-w-6xl w-full max-h-screen flex flex-col border border-theme">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h3 class="text-xl font-bold">Live Log Stream</h3>
<div class="flex space-x-2 items-center">
<span id="stream-status" class="text-sm px-2 py-1 rounded bg-accent text-secondary">Connected</span>
<button onclick="clearStreamLogs()" class="px-3 py-1 bg-warning-color hover:bg-opacity-80 rounded text-sm font-medium transition-colors">
Clear
</button>
<button onclick="toggleStreamPause()" class="px-3 py-1 bg-tertiary hover:bg-opacity-80 rounded text-sm font-medium transition-colors" id="pause-btn">
Pause
</button>
<button onclick="hideLogStream()" class="text-secondary hover:text-primary text-xl">&times;</button>
</div>
</div>
<div id="stream-content" class="bg-tertiary rounded p-4 font-mono text-sm flex-grow overflow-y-auto border border-theme min-h-96">
<p class="text-secondary">Starting log stream...</p>
</div>
</div>
</div>
<!-- Configuration Editor Modal -->
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4">
<div class="bg-secondary rounded-lg p-6 max-w-4xl w-full max-h-screen flex flex-col border border-theme">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h3 class="text-xl font-bold">Configuration Editor</h3>
<div class="flex space-x-2 items-center">
<button onclick="resetConfig()" class="px-3 py-1 bg-warning-color hover:bg-opacity-80 rounded text-sm font-medium transition-colors">
Reset
</button>
<button onclick="saveConfig()" class="px-3 py-1 bg-accent hover:bg-opacity-80 rounded text-sm font-medium transition-colors">
Save
</button>
<button onclick="hideConfigEditor()" class="text-secondary hover:text-primary text-xl">&times;</button>
</div>
</div>
<div class="flex-grow flex flex-col space-y-4 overflow-hidden">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 flex-shrink-0">
<!-- Server Settings -->
<div class="space-y-4">
<h4 class="text-lg font-semibold text-accent">Server Settings</h4>
<div>
<label class="block text-sm font-medium text-secondary mb-1">Host</label>
<input type="text" id="config-host" class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary" placeholder="0.0.0.0">
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1">Port</label>
<input type="number" id="config-port" class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary" placeholder="8080">
</div>
</div>
<!-- Paths -->
<div class="space-y-4">
<h4 class="text-lg font-semibold text-accent">Paths</h4>
<div>
<label class="block text-sm font-medium text-secondary mb-1">ROM Directory</label>
<input type="text" id="config-rom-path" class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary" placeholder="/path/to/roms">
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1">Images Directory</label>
<input type="text" id="config-images-path" class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary" placeholder="/path/to/images">
</div>
</div>
</div>
<!-- IGDB API Settings -->
<div class="space-y-4 flex-shrink-0">
<h4 class="text-lg font-semibold text-accent">IGDB API Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-secondary mb-1">Client ID</label>
<input type="text" id="config-igdb-client-id" class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary" placeholder="Your Twitch Client ID">
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1">Client Secret</label>
<input type="password" id="config-igdb-secret" class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary" placeholder="Your Twitch Client Secret">
</div>
</div>
</div>
<!-- JSON Editor -->
<div class="flex-grow flex flex-col min-h-0">
<div class="flex justify-between items-center mb-2">
<h4 class="text-lg font-semibold text-accent">Raw Configuration (JSON)</h4>
<button onclick="formatJSON()" class="px-2 py-1 bg-tertiary hover:bg-opacity-80 rounded text-xs transition-colors">
Format
</button>
</div>
<textarea id="config-json" class="flex-grow bg-tertiary border border-theme rounded p-3 font-mono text-sm text-primary resize-none" placeholder="Loading configuration..."></textarea>
</div>
</div>
<div id="config-status" class="mt-4 text-sm"></div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-secondary rounded-lg p-6 border border-theme">
<h2 class="text-xl font-bold mb-4 text-primary">Recent Games</h2>
{% if recent_games %}
<div class="space-y-3">
{% for game in recent_games %}
<div class="flex justify-between items-center py-2 border-b border-theme last:border-b-0">
<div>
<p class="font-medium text-primary">{{ game.metadata_obj.title or game.title }}</p>
<p class="text-sm text-secondary">{{ game.path.name }}</p>
</div>
<div class="flex space-x-2">
<button onclick="showGameDetail({{ game.id }})" class="text-accent hover:text-accent text-sm transition-colors">View</button>
<a href="/admin/games/{{ game.id }}/edit" class="text-warning-color hover:opacity-80 text-sm transition-colors">Edit</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-secondary">No games found</p>
{% endif %}
</div>
<div class="bg-secondary rounded-lg p-6 border border-theme">
<h2 class="text-xl font-bold mb-4 text-primary">Recent Users</h2>
{% if recent_users %}
<div class="space-y-3">
{% for user in recent_users %}
<div class="flex justify-between items-center py-2 border-b border-theme last:border-b-0">
<div>
<p class="font-medium text-primary">{{ user.username }}</p>
<p class="text-sm text-secondary">{{ user.email }}</p>
</div>
<div class="flex items-center space-x-2">
<span class="px-2 py-1 rounded text-xs text-white
{% if user.role == 'super' %}bg-danger-color
{% elif user.role == 'normal' %}bg-accent
{% else %}bg-warning-color{% endif %}">
{{ user.role.upper() }}
</span>
{% if not user.is_active %}
<span class="px-2 py-1 rounded text-xs bg-tertiary text-secondary">INACTIVE</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-secondary">No users found</p>
{% endif %}
</div>
</div>
<script>
async function triggerRomScan() {
const btn = document.getElementById('scan-btn');
const status = document.getElementById('scan-status');
const scanText = btn.querySelector('.scan-text');
const scanLoading = btn.querySelector('.scan-loading');
btn.disabled = true;
scanText.classList.add('hidden');
scanLoading.classList.remove('hidden');
try {
const response = await fetch('/api/admin/rom-scan', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const result = await response.json();
if (result.status === 'started') {
status.textContent = 'ROM scan started...';
status.className = 'mt-2 text-sm text-accent';
// Poll for completion
pollTaskStatus('rom_scan', status);
} else if (result.status === 'already_running') {
status.textContent = 'ROM scan already in progress';
status.className = 'mt-2 text-sm text-warning-color';
}
} catch (error) {
status.textContent = 'Failed to start ROM scan';
status.className = 'mt-2 text-sm text-danger-color';
} finally {
btn.disabled = false;
scanText.classList.remove('hidden');
scanLoading.classList.add('hidden');
}
}
async function triggerMetadataRefresh() {
const btn = document.getElementById('metadata-btn');
const status = document.getElementById('metadata-status');
const metadataText = btn.querySelector('.metadata-text');
const metadataLoading = btn.querySelector('.metadata-loading');
btn.disabled = true;
metadataText.classList.add('hidden');
metadataLoading.classList.remove('hidden');
try {
const response = await fetch('/api/admin/metadata-refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const result = await response.json();
if (result.status === 'started') {
status.textContent = 'Metadata refresh started...';
status.className = 'mt-2 text-sm text-accent';
pollTaskStatus('metadata_refresh', status);
} else if (result.status === 'already_running') {
status.textContent = 'Metadata refresh already in progress';
status.className = 'mt-2 text-sm text-warning-color';
}
} catch (error) {
status.textContent = 'Failed to start metadata refresh';
status.className = 'mt-2 text-sm text-danger-color';
} finally {
btn.disabled = false;
metadataText.classList.remove('hidden');
metadataLoading.classList.add('hidden');
}
}
async function triggerImageSync() {
const btn = document.getElementById('images-btn');
const status = document.getElementById('images-status');
const imagesText = btn.querySelector('.images-text');
const imagesLoading = btn.querySelector('.images-loading');
btn.disabled = true;
imagesText.classList.add('hidden');
imagesLoading.classList.remove('hidden');
try {
const response = await fetch('/api/admin/image-sync', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const result = await response.json();
if (result.status === 'started') {
status.textContent = 'Image sync started...';
status.className = 'mt-2 text-sm text-accent';
pollTaskStatus('image_sync', status);
} else if (result.status === 'already_running') {
status.textContent = 'Image sync already in progress';
status.className = 'mt-2 text-sm text-warning-color';
}
} catch (error) {
status.textContent = 'Failed to start image sync';
status.className = 'mt-2 text-sm text-danger-color';
} finally {
btn.disabled = false;
imagesText.classList.remove('hidden');
imagesLoading.classList.add('hidden');
}
}
async function triggerDatabaseCleanup() {
const btn = document.getElementById('cleanup-btn');
const status = document.getElementById('cleanup-status');
const cleanupText = btn.querySelector('.cleanup-text');
const cleanupLoading = btn.querySelector('.cleanup-loading');
if (!confirm('This will remove orphaned records and missing files. Continue?')) {
return;
}
btn.disabled = true;
cleanupText.classList.add('hidden');
cleanupLoading.classList.remove('hidden');
try {
const response = await fetch('/api/admin/database-cleanup', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const result = await response.json();
if (result.status === 'completed') {
status.textContent = result.message;
status.className = 'mt-2 text-sm text-accent';
} else {
status.textContent = result.message || 'Cleanup failed';
status.className = 'mt-2 text-sm text-danger-color';
}
} catch (error) {
status.textContent = 'Failed to perform database cleanup';
status.className = 'mt-2 text-sm text-danger-color';
} finally {
btn.disabled = false;
cleanupText.classList.remove('hidden');
cleanupLoading.classList.add('hidden');
}
}
async function triggerCacheClear() {
const btn = document.getElementById('cache-btn');
const status = document.getElementById('cache-status');
const cacheText = btn.querySelector('.cache-text');
const cacheLoading = btn.querySelector('.cache-loading');
btn.disabled = true;
cacheText.classList.add('hidden');
cacheLoading.classList.remove('hidden');
try {
const response = await fetch('/api/admin/cache-clear', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const result = await response.json();
if (result.status === 'completed') {
status.textContent = result.message;
status.className = 'mt-2 text-sm text-accent';
} else {
status.textContent = result.message || 'Cache clear failed';
status.className = 'mt-2 text-sm text-danger-color';
}
} catch (error) {
status.textContent = 'Failed to clear cache';
status.className = 'mt-2 text-sm text-danger-color';
} finally {
btn.disabled = false;
cacheText.classList.remove('hidden');
cacheLoading.classList.add('hidden');
}
}
async function showSystemStats() {
const modal = document.getElementById('stats-modal');
const content = document.getElementById('stats-content');
modal.classList.remove('hidden');
try {
const response = await fetch('/api/admin/system-stats', {
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const stats = await response.json();
content.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Games</h4>
<p class="text-2xl font-bold text-primary">${stats.games}</p>
</div>
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Users</h4>
<p class="text-2xl font-bold text-primary">${stats.users}</p>
</div>
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Metadata</h4>
<p class="text-2xl font-bold text-primary">${stats.metadata}</p>
</div>
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Tags</h4>
<p class="text-2xl font-bold text-primary">${stats.tags}</p>
</div>
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Genres</h4>
<p class="text-2xl font-bold text-primary">${stats.genres}</p>
</div>
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">New Users (30d)</h4>
<p class="text-2xl font-bold text-primary">${stats.recent_users}</p>
</div>
</div>
${stats.disk_usage.total ? `
<div class="mb-6">
<h4 class="text-lg font-semibold mb-3 text-primary">Disk Usage</h4>
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<div class="flex justify-between mb-2 text-secondary">
<span>Used: ${formatBytes(stats.disk_usage.used)}</span>
<span>Free: ${formatBytes(stats.disk_usage.free)}</span>
</div>
<div class="w-full bg-secondary rounded-full h-3">
<div class="bg-accent h-3 rounded-full" style="width: ${stats.disk_usage.percent_used.toFixed(1)}%"></div>
</div>
<p class="text-sm text-secondary mt-1">${stats.disk_usage.percent_used.toFixed(1)}% of ${formatBytes(stats.disk_usage.total)} used</p>
</div>
</div>
` : ''}
<div>
<h4 class="text-lg font-semibold mb-3 text-primary">Running Tasks</h4>
<div class="space-y-2 text-secondary">
<div class="flex justify-between">
<span>ROM Scan:</span>
<span class="${stats.running_tasks.rom_scan ? 'text-accent' : 'text-secondary'}">${stats.running_tasks.rom_scan ? 'Running' : 'Idle'}</span>
</div>
<div class="flex justify-between">
<span>Metadata Refresh:</span>
<span class="${stats.running_tasks.metadata_refresh ? 'text-accent' : 'text-secondary'}">${stats.running_tasks.metadata_refresh ? 'Running' : 'Idle'}</span>
</div>
<div class="flex justify-between">
<span>Image Sync:</span>
<span class="${stats.running_tasks.image_sync ? 'text-accent' : 'text-secondary'}">${stats.running_tasks.image_sync ? 'Running' : 'Idle'}</span>
</div>
</div>
</div>
`;
} catch (error) {
content.innerHTML = '<p class="text-danger-color">Failed to load system statistics</p>';
}
}
function hideSystemStats() {
document.getElementById('stats-modal').classList.add('hidden');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function pollTaskStatus(taskName, statusElement) {
const interval = setInterval(async () => {
try {
const response = await fetch('/api/admin/system-stats', {
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const stats = await response.json();
if (!stats.running_tasks[taskName]) {
statusElement.textContent = `${taskName.replace('_', ' ')} completed`;
statusElement.className = 'mt-2 text-sm text-accent';
clearInterval(interval);
}
} catch (error) {
clearInterval(interval);
}
}, 2000); // Poll every 2 seconds
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Enhanced token getter with server fallback
async function getAuthToken() {
// Try the expected cookie name
let token = getCookie('auth_token');
if (token) {
return token;
}
// Try common alternative names
const alternativeNames = ['access_token', 'token', 'jwt', 'authToken', 'session'];
for (const name of alternativeNames) {
token = getCookie(name);
if (token) {
return token;
}
}
// If no token in cookies (httponly case), try to get it from server
try {
const response = await fetch('/api/auth/token', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
console.log('Got token from server for user:', data.username);
return data.token;
}
} catch (error) {
console.error('Failed to get token from server:', error);
}
console.log('No authentication token available');
return null;
}
// Check if user is still authenticated
async function checkAuthentication() {
try {
const token = getCookie('auth_token');
if (!token) {
console.log('No auth token found');
return false;
}
// Test the token with the debug endpoint
const response = await fetch('/api/admin/auth-test', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
console.log('Auth check result:', data);
return data.authenticated && data.is_super;
}
console.log('Auth check failed with status:', response.status);
return false;
} catch (error) {
console.error('Auth check error:', error);
return false;
}
}
// Logging Functions
let allLogs = [];
async function showSystemLogs() {
const modal = document.getElementById('logs-modal');
const content = document.getElementById('logs-content');
modal.classList.remove('hidden');
await refreshLogs();
}
async function refreshLogs() {
const content = document.getElementById('logs-content');
content.innerHTML = '<p class="text-secondary">Loading logs...</p>';
try {
const response = await fetch('/api/admin/system-logs', {
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const logs = await response.json();
allLogs = logs.logs || [];
displayLogs(allLogs);
} catch (error) {
content.innerHTML = '<p class="text-danger-color">Failed to load system logs</p>';
}
}
function displayLogs(logs) {
const content = document.getElementById('logs-content');
if (logs.length === 0) {
content.innerHTML = '<p class="text-secondary">No logs found</p>';
return;
}
const logsHtml = logs.map(log => {
const levelColor = {
'ERROR': 'text-danger-color',
'WARNING': 'text-warning-color',
'INFO': 'text-info-color',
'DEBUG': 'text-secondary'
}[log.level] || 'text-primary';
return `<div class="mb-2 p-2 border-l-2 border-theme">
<div class="flex justify-between items-start mb-1">
<span class="${levelColor} font-semibold">[${log.level}]</span>
<span class="text-xs text-secondary">${log.timestamp}</span>
</div>
<div class="text-primary">${log.module || 'system'}: ${log.message}</div>
${log.traceback ? `<pre class="text-xs text-secondary mt-1 overflow-x-auto">${log.traceback}</pre>` : ''}
</div>`;
}).join('');
content.innerHTML = logsHtml;
content.scrollTop = content.scrollHeight;
}
function filterLogs() {
const filter = document.getElementById('log-level-filter').value;
const filteredLogs = filter ? allLogs.filter(log => log.level === filter) : allLogs;
displayLogs(filteredLogs);
}
function hideSystemLogs() {
document.getElementById('logs-modal').classList.add('hidden');
}
async function downloadLogs(logType) {
try {
// Use direct link with token for better browser compatibility
const token = getCookie('auth_token');
const downloadUrl = `/api/admin/download-logs?type=${logType}&token=${encodeURIComponent(token)}`;
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
a.download = `dosvault_${logType}_logs_${new Date().toISOString().split('T')[0]}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (error) {
alert('Failed to download logs');
}
}
async function clearLogs() {
if (!confirm('This will delete old log files. Continue?')) {
return;
}
try {
const response = await fetch('/api/admin/clear-logs', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const result = await response.json();
alert(result.message || 'Logs cleared successfully');
} catch (error) {
alert('Failed to clear logs');
}
}
// Live Log Streaming
let streamInterval = null;
let lastLogTimestamp = null;
let streamPaused = false;
let streamLevelFilter = '';
function toggleLogStream() {
const modal = document.getElementById('stream-modal');
const btn = document.getElementById('stream-btn');
const text = document.getElementById('stream-text');
if (streamInterval) {
// Stop streaming
clearInterval(streamInterval);
streamInterval = null;
modal.classList.add('hidden');
text.textContent = 'Start Log Stream';
btn.className = 'w-full bg-accent hover:bg-opacity-80 px-4 py-2 rounded font-medium transition-colors';
} else {
// Start streaming
modal.classList.remove('hidden');
text.textContent = 'Stop Log Stream';
btn.className = 'w-full bg-danger-color hover:bg-opacity-80 px-4 py-2 rounded font-medium transition-colors';
startLogStream();
}
}
async function startLogStream() {
const content = document.getElementById('stream-content');
const status = document.getElementById('stream-status');
content.innerHTML = '<p class="text-secondary">Starting log stream...</p>';
status.textContent = 'Connecting...';
status.className = 'text-sm px-2 py-1 rounded bg-warning-color text-secondary';
// Test token retrieval first
try {
const token = await getAuthToken();
if (!token) {
content.innerHTML = '<p class="text-danger-color">No authentication token found. Try refreshing the page.</p>';
status.textContent = 'No Token';
status.className = 'text-sm px-2 py-1 rounded bg-danger-color text-secondary';
return;
}
console.log('Token retrieved successfully, starting stream...');
content.innerHTML = '<p class="text-secondary">Token found, connecting to log stream...</p>';
} catch (error) {
content.innerHTML = `<p class="text-danger-color">Token error: ${error.message}</p>`;
status.textContent = 'Token Error';
status.className = 'text-sm px-2 py-1 rounded bg-danger-color text-secondary';
return;
}
// Reset timestamp to get recent logs
lastLogTimestamp = new Date(Date.now() - 30000).toISOString(); // Start from 30 seconds ago
// Try the first fetch
await fetchStreamLogs();
// Only start the interval if the first fetch succeeded
if (!content.innerHTML.includes('[STREAM ERROR]')) {
streamInterval = setInterval(async () => {
if (!streamPaused) {
await fetchStreamLogs();
}
}, 2000);
status.textContent = 'Connected';
status.className = 'text-sm px-2 py-1 rounded bg-accent text-secondary';
}
}
async function fetchStreamLogs() {
try {
const token = await getAuthToken();
if (!token) {
throw new Error('No authentication token found');
}
let url = '/api/admin/system-logs?limit=50';
if (lastLogTimestamp) {
url += `&since=${encodeURIComponent(lastLogTimestamp)}`;
}
console.log('Making request to:', url);
console.log('Using token:', token.substring(0, 20) + '...');
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
credentials: 'include'
});
console.log('Response status:', response.status);
console.log('Response headers:', Object.fromEntries(response.headers.entries()));
if (response.status === 401) {
// Let's see what the server says about this token
console.log('401 error, testing token with auth-test endpoint...');
try {
const testResponse = await fetch('/api/admin/auth-test', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
credentials: 'include'
});
const testData = await testResponse.json();
console.log('Auth test result:', testData);
} catch (e) {
console.log('Auth test failed:', e);
}
throw new Error('Authentication expired - please refresh the page');
}
if (!response.ok) {
const errorText = await response.text();
console.log('Error response:', errorText);
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const logs = data.logs || [];
if (logs.length > 0) {
appendStreamLogs(logs);
// Update last timestamp to the newest log
const timestamps = logs.map(log => new Date(log.timestamp));
lastLogTimestamp = new Date(Math.max(...timestamps)).toISOString();
// Update status to show activity
const status = document.getElementById('stream-status');
status.textContent = 'Active';
status.className = 'text-sm px-2 py-1 rounded bg-accent text-secondary';
}
} catch (error) {
console.error('Error fetching stream logs:', error);
const status = document.getElementById('stream-status');
const content = document.getElementById('stream-content');
status.textContent = 'Error';
status.className = 'text-sm px-2 py-1 rounded bg-danger-color text-secondary';
// Add error message to the log content
const errorElement = document.createElement('div');
errorElement.className = 'mb-2 p-2 border-l-2 border-danger-color text-sm bg-danger-color bg-opacity-10';
errorElement.innerHTML = `
<div class="text-danger-color font-semibold">[STREAM ERROR]</div>
<div class="text-primary">${error.message}</div>
<div class="text-secondary text-xs mt-1">${new Date().toLocaleTimeString()}</div>
`;
content.appendChild(errorElement);
content.scrollTop = content.scrollHeight;
// If it's an auth error, stop the stream
if (error.message.includes('Authentication') || error.message.includes('401')) {
if (streamInterval) {
clearInterval(streamInterval);
streamInterval = null;
document.getElementById('stream-text').textContent = 'Start Log Stream';
document.getElementById('stream-btn').className = 'w-full bg-accent hover:bg-opacity-80 px-4 py-2 rounded font-medium transition-colors';
}
}
}
}
function appendStreamLogs(logs) {
const content = document.getElementById('stream-content');
// Clear "connecting" message if it's the first logs
if (content.children.length === 1 && content.children[0].textContent.includes('Connecting')) {
content.innerHTML = '';
}
logs.forEach(log => {
// Apply level filter
if (streamLevelFilter && !shouldShowLogLevel(log.level, streamLevelFilter)) {
return;
}
const levelColor = {
'ERROR': 'text-danger-color',
'WARNING': 'text-warning-color',
'INFO': 'text-info-color',
'DEBUG': 'text-secondary'
}[log.level] || 'text-primary';
const logElement = document.createElement('div');
logElement.className = 'mb-1 p-1 border-l-2 border-theme text-xs';
logElement.innerHTML = `
<div class="flex justify-between items-start">
<span class="${levelColor} font-semibold">[${log.level}]</span>
<span class="text-xs text-secondary">${new Date(log.timestamp).toLocaleTimeString()}</span>
</div>
<div class="text-primary">${log.module || 'system'}: ${log.message}</div>
${log.traceback ? `<pre class="text-xs text-secondary mt-1 overflow-x-auto">${log.traceback}</pre>` : ''}
`;
content.appendChild(logElement);
});
// Auto-scroll to bottom
content.scrollTop = content.scrollHeight;
// Limit the number of log entries to prevent memory issues
while (content.children.length > 500) {
content.removeChild(content.firstChild);
}
}
function shouldShowLogLevel(logLevel, filter) {
const levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
const logIndex = levels.indexOf(logLevel);
switch (filter) {
case 'ERROR':
return logLevel === 'ERROR';
case 'WARNING':
return logIndex >= 2; // WARNING and ERROR
case 'INFO':
return logIndex >= 1; // INFO, WARNING, and ERROR
default:
return true; // Show all
}
}
function updateStreamFilter() {
streamLevelFilter = document.getElementById('stream-level-filter').value;
// Re-filter existing logs
if (streamInterval) {
const content = document.getElementById('stream-content');
const logs = Array.from(content.children);
logs.forEach(logElement => {
const levelSpan = logElement.querySelector('[class*="text-"][class*="-color"]');
if (levelSpan) {
const level = levelSpan.textContent.replace('[', '').replace(']', '');
logElement.style.display = shouldShowLogLevel(level, streamLevelFilter) ? 'block' : 'none';
}
});
}
}
function toggleStreamPause() {
const btn = document.getElementById('pause-btn');
streamPaused = !streamPaused;
btn.textContent = streamPaused ? 'Resume' : 'Pause';
btn.className = streamPaused
? 'px-3 py-1 bg-accent hover:bg-opacity-80 rounded text-sm font-medium transition-colors'
: 'px-3 py-1 bg-tertiary hover:bg-opacity-80 rounded text-sm font-medium transition-colors';
}
function clearStreamLogs() {
document.getElementById('stream-content').innerHTML = '<p class="text-secondary">Log stream cleared...</p>';
}
function hideLogStream() {
if (streamInterval) {
clearInterval(streamInterval);
streamInterval = null;
}
document.getElementById('stream-modal').classList.add('hidden');
document.getElementById('stream-text').textContent = 'Start Log Stream';
document.getElementById('stream-btn').className = 'w-full bg-accent hover:bg-opacity-80 px-4 py-2 rounded font-medium transition-colors';
}
// Manual authentication test function
async function tryManualAuth() {
const content = document.getElementById('stream-content');
const token = getCookie('auth_token');
content.innerHTML = '<p class="text-secondary">Testing authentication manually...</p>';
console.log('Manual auth test - Token:', token);
try {
// Try both approaches: with Authorization header and without
const tests = [
{
name: 'With Authorization Header',
options: {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
credentials: 'include'
}
},
{
name: 'Cookie Only',
options: {
credentials: 'include'
}
}
];
let results = '<div class="text-sm space-y-2">';
for (const test of tests) {
try {
console.log(`Testing: ${test.name}`);
const response = await fetch('/api/admin/auth-test', test.options);
const data = await response.json();
console.log(`${test.name} result:`, data);
results += `
<div class="p-2 border border-theme rounded">
<div class="font-semibold">${test.name}:</div>
<div class="text-xs">Status: ${response.status}</div>
<div class="text-xs">Authenticated: ${data.authenticated || 'false'}</div>
<div class="text-xs">Is Super: ${data.is_super || 'false'}</div>
<div class="text-xs">Username: ${data.username || 'none'}</div>
<div class="text-xs">Role: ${data.role || 'none'}</div>
<div class="text-xs">Auth Header Present: ${data.auth_header_present || 'false'}</div>
<div class="text-xs">Cookie Present: ${data.cookie_present || 'false'}</div>
</div>
`;
} catch (error) {
console.error(`${test.name} error:`, error);
results += `
<div class="p-2 border border-danger-color rounded">
<div class="font-semibold text-danger-color">${test.name}: Error</div>
<div class="text-xs">${error.message}</div>
</div>
`;
}
}
results += '</div>';
content.innerHTML = results;
} catch (error) {
console.error('Manual auth test error:', error);
content.innerHTML = `<p class="text-danger-color">Manual test failed: ${error.message}</p>`;
}
}
// Configuration Editor Functions
let currentConfig = null;
async function showConfigEditor() {
const modal = document.getElementById('config-modal');
modal.classList.remove('hidden');
await loadConfiguration();
}
function hideConfigEditor() {
document.getElementById('config-modal').classList.add('hidden');
}
async function loadConfiguration() {
const status = document.getElementById('config-status');
status.innerHTML = '<span class="text-secondary">Loading configuration...</span>';
try {
const token = await getAuthToken();
const response = await fetch('/api/admin/config', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
currentConfig = await response.json();
populateConfigForm(currentConfig);
status.innerHTML = '<span class="text-accent">Configuration loaded successfully</span>';
} catch (error) {
status.innerHTML = `<span class="text-danger-color">Failed to load configuration: ${error.message}</span>`;
}
}
function populateConfigForm(config) {
// Populate form fields
document.getElementById('config-host').value = config.host || '';
document.getElementById('config-port').value = config.port || '';
document.getElementById('config-rom-path').value = config.rom_path || '';
document.getElementById('config-images-path').value = config.images_path || '';
document.getElementById('config-igdb-client-id').value = config.igdb_client_id || '';
document.getElementById('config-igdb-secret').value = config.igdb_api_key || '';
// Populate JSON editor
document.getElementById('config-json').value = JSON.stringify(config, null, 2);
}
function collectConfigFromForm() {
const config = {
host: document.getElementById('config-host').value,
port: parseInt(document.getElementById('config-port').value) || 8080,
rom_path: document.getElementById('config-rom-path').value,
images_path: document.getElementById('config-images-path').value,
igdb_client_id: document.getElementById('config-igdb-client-id').value,
igdb_api_key: document.getElementById('config-igdb-secret').value
};
// Try to merge with JSON editor content
try {
const jsonConfig = JSON.parse(document.getElementById('config-json').value);
return { ...jsonConfig, ...config };
} catch (error) {
return config;
}
}
async function saveConfig() {
const status = document.getElementById('config-status');
status.innerHTML = '<span class="text-warning-color">Saving configuration...</span>';
try {
const config = collectConfigFromForm();
const token = await getAuthToken();
const response = await fetch('/api/admin/config', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(config)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const result = await response.json();
status.innerHTML = '<span class="text-accent">Configuration saved successfully! Restart required for changes to take effect.</span>';
// Refresh the configuration display
await loadConfiguration();
} catch (error) {
status.innerHTML = `<span class="text-danger-color">Failed to save configuration: ${error.message}</span>`;
}
}
function resetConfig() {
if (currentConfig) {
populateConfigForm(currentConfig);
document.getElementById('config-status').innerHTML = '<span class="text-secondary">Configuration reset to last saved values</span>';
}
}
function formatJSON() {
try {
const jsonText = document.getElementById('config-json').value;
const parsed = JSON.parse(jsonText);
document.getElementById('config-json').value = JSON.stringify(parsed, null, 2);
document.getElementById('config-status').innerHTML = '<span class="text-accent">JSON formatted successfully</span>';
} catch (error) {
document.getElementById('config-status').innerHTML = '<span class="text-danger-color">Invalid JSON: ' + error.message + '</span>';
}
}
</script>
{% endblock %}