1390 lines
59 KiB
HTML
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">×</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">×</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">×</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">×</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 %} |