seo-generator-server/public/batch-dashboard.html
StillHammer a2ffe7fec5 Refactor batch processing system with shared QueueProcessor base class
• Created QueueProcessor base class for shared queue management, retry logic, and persistence
• Refactored BatchProcessor to extend QueueProcessor (385→142 lines, 63% reduction)
• Created BatchController with comprehensive API endpoints for batch operations
• Added Digital Ocean templates integration with caching
• Integrated batch endpoints into ManualServer with proper routing
• Fixed infinite recursion bug in queue status calculations
• Eliminated ~400 lines of duplicate code across processors
• Maintained backward compatibility with existing test interfaces

Architecture benefits:
- Single source of truth for queue processing logic
- Simplified maintenance and bug fixes
- Clear separation between AutoProcessor (production) and BatchProcessor (R&D)
- Extensible design for future processor types

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 02:04:48 +08:00

1045 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard Batch Processing - SEO Generator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
min-height: 100vh;
color: #333;
}
.dashboard {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.header h1 {
font-size: 2.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 10px;
}
.header p {
color: #666;
font-size: 1.1rem;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 30px;
}
.grid-full {
grid-column: span 2;
}
.card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
border: 1px solid rgba(255, 255, 255, 0.18);
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
}
.card h2 {
font-size: 1.5rem;
margin-bottom: 20px;
color: #4f46e5;
display: flex;
align-items: center;
gap: 10px;
}
.card-icon {
font-size: 1.8rem;
}
/* Configuration Form */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-weight: 600;
margin-bottom: 8px;
color: #374151;
}
.form-group select,
.form-group input {
padding: 12px 15px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.form-group select:focus,
.form-group input:focus {
outline: none;
border-color: #4f46e5;
}
/* Buttons */
.btn {
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.btn-row {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
/* Status Display */
.status-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-idle { background: #e5e7eb; color: #6b7280; }
.status-running { background: #dbeafe; color: #1d4ed8; }
.status-paused { background: #fef3c7; color: #d97706; }
.status-completed { background: #d1fae5; color: #047857; }
.status-error { background: #fee2e2; color: #dc2626; }
/* Progress Bar */
.progress-container {
background: #f3f4f6;
border-radius: 10px;
height: 20px;
overflow: hidden;
margin: 15px 0;
}
.progress-bar {
background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%);
height: 100%;
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: 600;
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
}
.metric {
text-align: center;
padding: 20px;
background: rgba(79, 70, 229, 0.1);
border-radius: 15px;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: #4f46e5;
margin-bottom: 5px;
}
.metric-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
font-weight: 600;
}
/* Queue List */
.queue-list {
max-height: 400px;
overflow-y: auto;
margin: 20px 0;
}
.queue-item {
display: flex;
justify-content: between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.2s ease;
}
.queue-item:hover {
background: #f9fafb;
}
.queue-item:last-child {
border-bottom: none;
}
.queue-row {
font-weight: 600;
color: #4f46e5;
min-width: 60px;
}
.queue-status {
flex: 1;
margin-left: 15px;
}
.queue-time {
font-size: 0.9rem;
color: #6b7280;
}
/* Log Display */
.log-container {
background: #1f2937;
border-radius: 15px;
padding: 20px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
}
.log-entry {
margin-bottom: 8px;
padding: 5px 0;
}
.log-timestamp {
color: #9ca3af;
}
.log-level-info {
color: #60a5fa;
}
.log-level-warning {
color: #fbbf24;
}
.log-level-error {
color: #f87171;
}
.log-level-success {
color: #34d399;
}
.log-message {
color: #f9fafb;
}
/* Templates Section */
.template-list {
display: grid;
gap: 15px;
margin: 20px 0;
}
.template-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: rgba(79, 70, 229, 0.05);
border-radius: 10px;
border-left: 4px solid #4f46e5;
}
.template-info {
flex: 1;
}
.template-name {
font-weight: 600;
color: #374151;
margin-bottom: 5px;
}
.template-source {
font-size: 0.9rem;
color: #6b7280;
}
.template-actions {
display: flex;
gap: 10px;
}
/* Responsive */
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
.btn-row {
justify-content: center;
}
}
/* Animation */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 2s infinite;
}
/* Notification */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
background: #10b981;
color: white;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
z-index: 1000;
transform: translateX(400px);
transition: transform 0.3s ease;
}
.notification.show {
transform: translateX(0);
}
.notification.error {
background: #ef4444;
}
.notification.warning {
background: #f59e0b;
}
</style>
</head>
<body>
<div class="dashboard">
<!-- Header -->
<div class="header">
<h1>🚀 Dashboard Batch Processing</h1>
<p>Interface complète de traitement batch avec pipeline modulaire</p>
</div>
<!-- Main Grid -->
<div class="grid">
<!-- Configuration Panel -->
<div class="card">
<h2>⚙️ Configuration Pipeline</h2>
<div class="form-grid">
<div class="form-group">
<label for="selective">Selective Enhancement</label>
<select id="selective">
<option value="lightEnhancement">Light Enhancement</option>
<option value="standardEnhancement" selected>Standard Enhancement</option>
<option value="fullEnhancement">Full Enhancement</option>
<option value="personalityFocus">Personality Focus</option>
<option value="fluidityFocus">Fluidity Focus</option>
</select>
</div>
<div class="form-group">
<label for="adversarial">Adversarial Mode</label>
<select id="adversarial">
<option value="none">None</option>
<option value="light" selected>Light</option>
<option value="standard">Standard</option>
<option value="heavy">Heavy</option>
<option value="adaptive">Adaptive</option>
</select>
</div>
<div class="form-group">
<label for="humanSimulation">Human Simulation</label>
<select id="humanSimulation">
<option value="none" selected>None</option>
<option value="lightSimulation">Light Simulation</option>
<option value="personalityFocus">Personality Focus</option>
<option value="adaptive">Adaptive</option>
</select>
</div>
<div class="form-group">
<label for="patternBreaking">Pattern Breaking</label>
<select id="patternBreaking">
<option value="none" selected>None</option>
<option value="syntaxFocus">Syntax Focus</option>
<option value="connectorsFocus">Connectors Focus</option>
<option value="adaptive">Adaptive</option>
</select>
</div>
<div class="form-group">
<label for="intensity">Intensité</label>
<input type="range" id="intensity" min="0.5" max="1.5" step="0.1" value="1.0">
<span id="intensityValue">1.0</span>
</div>
<div class="form-group">
<label for="saveSteps">Sauvegarde Étapes</label>
<input type="checkbox" id="saveSteps">
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="rowStart">Ligne Début</label>
<input type="number" id="rowStart" value="2" min="1">
</div>
<div class="form-group">
<label for="rowEnd">Ligne Fin</label>
<input type="number" id="rowEnd" value="10" min="1">
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="saveConfig()">💾 Sauvegarder Config</button>
<button class="btn btn-secondary" onclick="loadConfig()">📋 Charger Config</button>
</div>
</div>
<!-- Control Panel -->
<div class="card">
<h2>🎮 Contrôles de Traitement</h2>
<div id="statusDisplay" class="metrics-grid">
<div class="metric">
<div class="metric-value" id="currentStatus">IDLE</div>
<div class="metric-label">Status</div>
</div>
<div class="metric">
<div class="metric-value" id="currentRow">-</div>
<div class="metric-label">Ligne Actuelle</div>
</div>
<div class="metric">
<div class="metric-value" id="progressPercent">0%</div>
<div class="metric-label">Progression</div>
</div>
<div class="metric">
<div class="metric-value" id="completedRows">0</div>
<div class="metric-label">Terminées</div>
</div>
</div>
<div class="progress-container">
<div class="progress-bar" id="progressBar" style="width: 0%">0%</div>
</div>
<div class="btn-row">
<button class="btn btn-success" id="startBtn" onclick="startBatch()">▶️ Démarrer</button>
<button class="btn btn-warning" id="pauseBtn" onclick="pauseBatch()" disabled>⏸️ Pause</button>
<button class="btn btn-primary" id="resumeBtn" onclick="resumeBatch()" disabled>⏯️ Reprendre</button>
<button class="btn btn-danger" id="stopBtn" onclick="stopBatch()" disabled>⏹️ Arrêter</button>
</div>
<div class="metrics-grid" style="margin-top: 20px;">
<div class="metric">
<div class="metric-value" id="elapsedTime">0s</div>
<div class="metric-label">Temps Écoulé</div>
</div>
<div class="metric">
<div class="metric-value" id="estimatedEnd">-</div>
<div class="metric-label">Fin Estimée</div>
</div>
<div class="metric">
<div class="metric-value" id="throughput">0</div>
<div class="metric-label">Lignes/min</div>
</div>
<div class="metric">
<div class="metric-value" id="errorCount">0</div>
<div class="metric-label">Erreurs</div>
</div>
</div>
</div>
</div>
<!-- Queue Status -->
<div class="card grid-full">
<h2>📋 État de la Queue</h2>
<div class="queue-list" id="queueList">
<!-- Queue items will be populated here -->
</div>
</div>
<!-- Templates & Logs Grid -->
<div class="grid">
<!-- Digital Ocean Templates -->
<div class="card">
<h2>🌊 Templates Digital Ocean</h2>
<div class="btn-row" style="margin-bottom: 20px;">
<button class="btn btn-primary" onclick="refreshTemplates()">🔄 Actualiser</button>
<button class="btn btn-secondary" onclick="clearTemplateCache()">🗑️ Vider Cache</button>
</div>
<div class="template-list" id="templateList">
<!-- Templates will be populated here -->
</div>
<div id="cacheStats" style="margin-top: 15px; padding: 15px; background: #f8fafc; border-radius: 10px; font-size: 0.9rem; color: #6b7280;">
Cache stats will appear here
</div>
</div>
<!-- Real-time Logs -->
<div class="card">
<h2>📝 Logs Temps Réel</h2>
<div class="btn-row" style="margin-bottom: 20px;">
<button class="btn btn-secondary" onclick="clearLogs()">🗑️ Vider Logs</button>
<button class="btn btn-primary" onclick="toggleAutoScroll()">📜 Auto-Scroll</button>
</div>
<div class="log-container" id="logContainer">
<!-- Logs will appear here -->
</div>
</div>
</div>
</div>
<!-- Notification -->
<div id="notification" class="notification"></div>
<script>
// Global state
let currentConfig = {};
let batchStatus = {};
let autoScroll = true;
let updateInterval;
// Initialize dashboard
document.addEventListener('DOMContentLoaded', function() {
loadConfig();
refreshTemplates();
startStatusUpdates();
setupEventListeners();
showNotification('Dashboard initialisé', 'success');
});
// Event Listeners
function setupEventListeners() {
// Intensity slider
document.getElementById('intensity').addEventListener('input', function() {
document.getElementById('intensityValue').textContent = this.value;
});
}
// Configuration Management
async function loadConfig() {
try {
const response = await fetch('/api/batch/config');
const data = await response.json();
if (data.success) {
currentConfig = data.config;
populateConfigForm(data.config);
showNotification('Configuration chargée', 'success');
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur chargement config: ' + error.message, 'error');
}
}
async function saveConfig() {
try {
const config = getConfigFromForm();
const response = await fetch('/api/batch/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
const data = await response.json();
if (data.success) {
currentConfig = data.config;
showNotification('Configuration sauvegardée', 'success');
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur sauvegarde: ' + error.message, 'error');
}
}
function populateConfigForm(config) {
document.getElementById('selective').value = config.selective || 'standardEnhancement';
document.getElementById('adversarial').value = config.adversarial || 'light';
document.getElementById('humanSimulation').value = config.humanSimulation || 'none';
document.getElementById('patternBreaking').value = config.patternBreaking || 'none';
document.getElementById('intensity').value = config.intensity || 1.0;
document.getElementById('intensityValue').textContent = config.intensity || '1.0';
document.getElementById('saveSteps').checked = config.saveIntermediateSteps || false;
document.getElementById('rowStart').value = config.rowRange?.start || 2;
document.getElementById('rowEnd').value = config.rowRange?.end || 10;
}
function getConfigFromForm() {
return {
selective: document.getElementById('selective').value,
adversarial: document.getElementById('adversarial').value,
humanSimulation: document.getElementById('humanSimulation').value,
patternBreaking: document.getElementById('patternBreaking').value,
intensity: parseFloat(document.getElementById('intensity').value),
saveIntermediateSteps: document.getElementById('saveSteps').checked,
rowRange: {
start: parseInt(document.getElementById('rowStart').value),
end: parseInt(document.getElementById('rowEnd').value)
}
};
}
// Batch Control
async function startBatch() {
try {
const response = await fetch('/api/batch/start', { method: 'POST' });
const data = await response.json();
if (data.success) {
showNotification('Traitement démarré', 'success');
updateControlButtons('running');
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur démarrage: ' + error.message, 'error');
}
}
async function pauseBatch() {
try {
const response = await fetch('/api/batch/pause', { method: 'POST' });
const data = await response.json();
if (data.success) {
showNotification('Traitement mis en pause', 'warning');
updateControlButtons('paused');
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur pause: ' + error.message, 'error');
}
}
async function resumeBatch() {
try {
const response = await fetch('/api/batch/resume', { method: 'POST' });
const data = await response.json();
if (data.success) {
showNotification('Traitement repris', 'success');
updateControlButtons('running');
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur reprise: ' + error.message, 'error');
}
}
async function stopBatch() {
try {
const response = await fetch('/api/batch/stop', { method: 'POST' });
const data = await response.json();
if (data.success) {
showNotification('Traitement arrêté', 'warning');
updateControlButtons('idle');
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur arrêt: ' + error.message, 'error');
}
}
// Status Updates
function startStatusUpdates() {
updateInterval = setInterval(async () => {
await updateBatchStatus();
await updateProgress();
}, 2000); // Update every 2 seconds
}
async function updateBatchStatus() {
try {
const response = await fetch('/api/batch/status');
const data = await response.json();
if (data.success) {
batchStatus = data.status;
displayBatchStatus(data.status);
updateQueueDisplay(data.status.queue);
}
} catch (error) {
console.error('Error updating status:', error);
}
}
async function updateProgress() {
try {
const response = await fetch('/api/batch/progress');
const data = await response.json();
if (data.success) {
displayProgress(data.progress);
}
} catch (error) {
console.error('Error updating progress:', error);
}
}
function displayBatchStatus(status) {
document.getElementById('currentStatus').textContent = status.status.toUpperCase();
document.getElementById('currentRow').textContent = status.currentRow || '-';
document.getElementById('progressPercent').textContent = status.progress + '%';
document.getElementById('completedRows').textContent = status.completedRows;
document.getElementById('errorCount').textContent = status.failedRows;
// Update progress bar
const progressBar = document.getElementById('progressBar');
progressBar.style.width = status.progress + '%';
progressBar.textContent = status.progress + '%';
// Update control buttons based on status
updateControlButtons(status.status);
}
function displayProgress(progress) {
if (progress.metrics) {
const elapsed = Math.round(progress.metrics.elapsedTime / 1000);
document.getElementById('elapsedTime').textContent = formatTime(elapsed);
const throughput = Math.round(progress.metrics.throughput || 0);
document.getElementById('throughput').textContent = throughput;
if (progress.estimatedEnd) {
const endTime = new Date(progress.estimatedEnd);
document.getElementById('estimatedEnd').textContent = endTime.toLocaleTimeString();
} else {
document.getElementById('estimatedEnd').textContent = '-';
}
}
}
function updateControlButtons(status) {
const startBtn = document.getElementById('startBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resumeBtn = document.getElementById('resumeBtn');
const stopBtn = document.getElementById('stopBtn');
// Reset all buttons
[startBtn, pauseBtn, resumeBtn, stopBtn].forEach(btn => {
btn.disabled = false;
btn.classList.remove('pulse');
});
switch (status) {
case 'idle':
pauseBtn.disabled = true;
resumeBtn.disabled = true;
stopBtn.disabled = true;
break;
case 'running':
startBtn.disabled = true;
resumeBtn.disabled = true;
startBtn.classList.add('pulse');
break;
case 'paused':
startBtn.disabled = true;
pauseBtn.disabled = true;
break;
case 'completed':
pauseBtn.disabled = true;
resumeBtn.disabled = true;
stopBtn.disabled = true;
break;
}
}
function updateQueueDisplay(queue) {
const queueList = document.getElementById('queueList');
if (!queue || queue.length === 0) {
queueList.innerHTML = '<div style="text-align: center; padding: 40px; color: #9ca3af;">Aucune tâche dans la queue</div>';
return;
}
queueList.innerHTML = queue.map(item => `
<div class="queue-item">
<div class="queue-row">Ligne ${item.rowNumber}</div>
<div class="queue-status">
<span class="status-badge status-${item.status}">${item.status}</span>
${item.attempts > 0 ? `<span style="margin-left: 10px; font-size: 0.9rem; color: #6b7280;">Tentative ${item.attempts}/${item.maxAttempts}</span>` : ''}
</div>
<div class="queue-time">
${item.startTime ? new Date(item.startTime).toLocaleTimeString() : ''}
</div>
</div>
`).join('');
}
// Templates Management
async function refreshTemplates() {
try {
const response = await fetch('/api/batch/templates');
const data = await response.json();
if (data.success) {
displayTemplates(data.templates);
displayCacheStats(data.cacheStats);
showNotification('Templates actualisés', 'success');
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur templates: ' + error.message, 'error');
}
}
async function clearTemplateCache() {
try {
const response = await fetch('/api/batch/cache', { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showNotification('Cache vidé', 'success');
await refreshTemplates();
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur vidage cache: ' + error.message, 'error');
}
}
function displayTemplates(templates) {
const templateList = document.getElementById('templateList');
if (!templates || templates.length === 0) {
templateList.innerHTML = '<div style="text-align: center; padding: 20px; color: #9ca3af;">Aucun template disponible</div>';
return;
}
templateList.innerHTML = templates.map(template => `
<div class="template-item">
<div class="template-info">
<div class="template-name">${template.name}</div>
<div class="template-source">Source: ${template.source}</div>
</div>
<div class="template-actions">
<button class="btn btn-secondary" onclick="viewTemplate('${template.name}')" style="padding: 6px 12px; font-size: 12px;">👁️ Voir</button>
</div>
</div>
`).join('');
}
function displayCacheStats(stats) {
const cacheStatsDiv = document.getElementById('cacheStats');
cacheStatsDiv.innerHTML = `
<strong>Cache:</strong> ${stats.memoryCache.size} en mémoire |
<strong>Endpoint:</strong> ${stats.config.endpoint}/${stats.config.bucket} |
<strong>Templates par défaut:</strong> ${stats.defaultTemplates}
`;
}
async function viewTemplate(filename) {
try {
const response = await fetch(`/api/batch/templates/${filename}`);
const data = await response.json();
if (data.success) {
// Create a modal or new window to display template
const newWindow = window.open('', '_blank', 'width=800,height=600');
newWindow.document.write(`
<html>
<head><title>Template: ${filename}</title></head>
<body style="font-family: monospace; padding: 20px; background: #f8fafc;">
<h2>Template: ${filename}</h2>
<pre style="background: white; padding: 20px; border-radius: 10px; border: 1px solid #e5e7eb;">${data.template}</pre>
</body>
</html>
`);
} else {
throw new Error(data.error);
}
} catch (error) {
showNotification('Erreur lecture template: ' + error.message, 'error');
}
}
// Logs Management
function clearLogs() {
document.getElementById('logContainer').innerHTML = '';
showNotification('Logs vidés', 'success');
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
const btn = event.target;
btn.textContent = autoScroll ? '📜 Auto-Scroll' : '📜 Manuel';
btn.classList.toggle('btn-primary');
btn.classList.toggle('btn-secondary');
showNotification(`Auto-scroll ${autoScroll ? 'activé' : 'désactivé'}`, 'success');
}
function addLogEntry(level, message) {
const logContainer = document.getElementById('logContainer');
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
logEntry.innerHTML = `
<span class="log-timestamp">[${timestamp}]</span>
<span class="log-level-${level}">[${level.toUpperCase()}]</span>
<span class="log-message">${message}</span>
`;
logContainer.appendChild(logEntry);
if (autoScroll) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
// Utility Functions
function formatTime(seconds) {
if (seconds < 60) return seconds + 's';
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}m ${secs}s`;
}
function showNotification(message, type = 'success') {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = `notification ${type}`;
notification.classList.add('show');
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
// Cleanup
window.addEventListener('beforeunload', function() {
if (updateInterval) {
clearInterval(updateInterval);
}
});
</script>
</body>
</html>