Files
DosVault/templates/admin.html
th3r00t 7e4c194c1f Added new rom import system utilizing WAL to avoid locking the database and freezing the frontend
Also added new logging setup to hopefully stream the scrape process
2025-09-07 12:50:05 -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">
<!-- Game 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">Game Scanner</h3>
</div>
<p class="text-secondary text-sm mb-4">Scan directories for new Game files and add them to the database</p>
<button onclick="triggerGameScan()" 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 Game 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">Game 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/games">
</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 triggerGameScan() {
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/game-scan', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getCookie('auth_token')}`
}
});
const result = await response.json();
if (result.status === 'started') {
status.textContent = 'Game scan started...';
status.className = 'mt-2 text-sm text-accent';
// Poll for completion
pollTaskStatus('game_scan', status);
} else if (result.status === 'already_running') {
status.textContent = 'Game scan already in progress';
status.className = 'mt-2 text-sm text-warning-color';
}
} catch (error) {
status.textContent = 'Failed to start Game 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>Game Scan:</span>
<span class="${stats.running_tasks.game_scan ? 'text-accent' : 'text-secondary'}">${stats.running_tasks.game_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.config?.host || config.host || '';
document.getElementById('config-port').value = config.config?.port || config.port || '';
document.getElementById('config-rom-path').value = config.config?.rom_path || config.rom_path || '';
document.getElementById('config-images-path').value = config.config?.images_path || config.images_path || '';
document.getElementById('config-igdb-client-id').value = config.config?.igdb_client_id || config.igdb_client_id || '';
document.getElementById('config-igdb-secret').value = config.config?.igdb_api_key || config.igdb_api_key || '';
// Populate JSON editor
document.getElementById('config-json').value = JSON.stringify(config.config || 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 %}