Initial clean commit

This commit is contained in:
jlimolina 2026-01-13 13:39:51 +01:00
commit 6784d81c2c
141 changed files with 25219 additions and 0 deletions

729
templates/stats.html Normal file
View file

@ -0,0 +1,729 @@
{% extends "base.html" %}
{% block title %}Estadísticas de Actividad{% endblock %}
{% block content %}
<div class="stats-header">
<h1><i class="fas fa-chart-line"></i> Estadísticas</h1>
<p>Actividad de noticias y distribución por categorías</p>
</div>
<!-- Live Stats Banner (Moved from Config) -->
<div class="stats-banner">
<div class="stat-item">
<div class="stat-value">{{ translations_per_min }}</div>
<div class="stat-label">trad/min</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value {% if pending_count > 100 %}stat-warning{% endif %}">{{ pending_count }}</div>
<div class="stat-label">pendientes</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value stat-processing">{{ processing_count }}</div>
<div class="stat-label">procesando</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value">{{ traducciones_count }}</div>
<div class="stat-label">completadas</div>
</div>
{% if error_count > 0 %}
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value stat-error">{{ error_count }}</div>
<div class="stat-label">errores</div>
</div>
{% endif %}
</div>
<!-- Big Stats Section (Moved from Config) -->
<div class="big-stats">
<div class="big-stat">
<div class="big-number">{{ noticias_count|default(0) }}</div>
<div class="big-label">Noticias</div>
</div>
<div class="big-stat">
<div class="big-number">{{ traducciones_count|default(0) }}</div>
<div class="big-label">Traducidas</div>
</div>
<div class="big-stat highlight">
{% set percent = ((traducciones_count / noticias_count * 100) if noticias_count > 0 else 0)|round(1) %}
<div class="big-number">{{ percent }}%</div>
<div class="big-label">Completado</div>
</div>
<div class="big-stat">
<div class="big-number">{{ noticias_hoy|default(0) }}</div>
<div class="big-label">Noticias Hoy</div>
</div>
<div class="big-stat">
<div class="big-number">{{ noticias_ultima_hora|default(0) }}</div>
<div class="big-label">Última Hora</div>
</div>
</div>
<div class="stats-grid">
<div class="card stats-card full-width">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<h3 style="margin:0;">Actividad</h3>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('1h', this)">1h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('8h', this)">8h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('24h', this)">24h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('7d', this)">7d</button>
<button type="button" class="btn btn-outline-primary btn-sm active"
onclick="updateActivityChart('30d', this)">30d</button>
</div>
</div>
<div class="chart-container">
<canvas id="activityChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Noticias por Categoría</h3>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Noticias por País</h3>
<div class="chart-container">
<canvas id="countryChart"></canvas>
</div>
</div>
<!-- Translations Section -->
<div class="card stats-card full-width">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<h3 style="margin:0;">Ritmo de Traducción (TPM)</h3>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary btn-sm active"
onclick="updateRateChart('1h', this)">1h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateRateChart('8h', this)">8h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateRateChart('24h', this)">24h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateRateChart('7d', this)">7d</button>
</div>
</div>
<div class="chart-container">
<canvas id="rateChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Traducciones Diarias</h3>
<div class="chart-container">
<canvas id="translationActivityChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Idiomas de Origen</h3>
<div class="chart-container">
<canvas id="langChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Información del Sistema</h3>
<ul class="system-stats" id="system-info-list">
<li><strong>CPU:</strong> <span id="cpu-load">Cargando...</span></li>
<li><strong>Núcleos:</strong> <span id="cpu-cores">-</span></li>
<li><strong>GPU:</strong> <span id="gpu-name">-</span></li>
<li><strong>GPU Temp:</strong> <span id="gpu-temp">-</span></li>
<li><strong>GPU Util:</strong> <span id="gpu-util">-</span></li>
<li><strong>GPU Mem:</strong> <span id="gpu-mem">-</span></li>
<li><strong>Uptime:</strong> <span id="system-uptime">-</span></li>
<li class="last-update">Actualizado: <span id="last-update-time">-</span></li>
</ul>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Activity Chart
let activityChart = null;
window.updateActivityChart = function (range, btn) {
// Update active button state
if (btn) {
// Find buttons specifically for this chart
const parent = btn.closest('.btn-group');
if (parent) {
parent.querySelectorAll('button').forEach(b => b.classList.remove('active'));
}
btn.classList.add('active');
}
fetch(`/stats/api/activity?range=${range}`)
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('activityChart').getContext('2d');
if (activityChart) {
activityChart.destroy();
}
// Dynamic title
let titleText = "Noticias Recientes";
if (range === '1h') titleText = "Última Hora";
if (range === '8h') titleText = "Últimas 8 Horas";
if (range === '24h') titleText = "Últimas 24 Horas";
if (range === '7d') titleText = "Últimos 7 Días";
if (range === '30d') titleText = "Últimos 30 Días";
// Create gradient
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(52, 152, 219, 0.5)'); // Start color
gradient.addColorStop(1, 'rgba(52, 152, 219, 0.0)'); // End color
activityChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Noticias Ingestadas',
data: data.data,
borderColor: '#3498db',
backgroundColor: gradient,
borderWidth: 3,
tension: 0.4, // Smooth curves
fill: true,
pointRadius: 0,
pointHoverRadius: 6,
pointHitRadius: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
title: {
display: true,
text: titleText,
font: { size: 16, weight: 'normal' },
padding: { bottom: 20 }
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
padding: 10,
displayColors: false
}
},
scales: {
x: {
grid: { display: false }
},
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
},
title: {
display: true,
text: 'Miles de noticias'
}
}
}
}
});
});
};
// Initial load (30d)
updateActivityChart('30d');
// Category Chart
fetch('/stats/api/categories')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('categoryChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.labels,
datasets: [{
data: data.data,
backgroundColor: [
'#4facfe', '#00f2fe', '#43e97b', '#38f9d7', '#fa709a',
'#fee140', '#667eea', '#764ba2', '#ff9a9e', '#a18cd1'
],
borderWidth: 0,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '75%', // Thinner ring
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 20,
font: { size: 12 }
}
}
}
}
});
});
// Country Chart
fetch('/stats/api/countries')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('countryChart').getContext('2d');
// Gradient for bars
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, '#1abc9c');
gradient.addColorStop(1, '#16a085');
new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Noticias',
data: data.data,
backgroundColor: gradient,
borderRadius: 4, // Rounded bars
borderSkipped: false,
barPercentage: 0.6,
categoryPercentage: 0.8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
displayColors: false
}
},
scales: {
x: {
grid: { display: false }
},
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
}
}
}
}
});
});
// --- NEW TRANSLATION CHARTS ---
// Translation Rate (TPM)
let rateChart = null;
window.updateRateChart = function (range, btn) {
// Update active button state
if (btn) {
document.querySelectorAll('.btn-group button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
fetch(`/stats/api/translations/rate?range=${range}`)
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('rateChart').getContext('2d');
if (rateChart) {
rateChart.destroy();
}
// Gradient for rate chart
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(230, 126, 34, 0.5)');
gradient.addColorStop(1, 'rgba(230, 126, 34, 0.0)');
rateChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Traducciones',
data: data.data,
borderColor: '#e67e22',
backgroundColor: gradient,
borderWidth: 3,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 6,
pointHitRadius: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
title: {
display: true,
text: `Traducciones - Última(s) ${range}`,
font: { size: 16, weight: 'normal' },
padding: { bottom: 20 }
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
padding: 10,
displayColors: false
}
},
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
}
}
}
}
});
});
};
// Initial load (1h)
updateRateChart('1h');
// Translation Activity
fetch('/stats/api/translations/activity')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('translationActivityChart').getContext('2d');
// Gradient
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, '#9b59b6');
gradient.addColorStop(1, '#8e44ad');
new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Traducciones Diarias',
data: data.data,
backgroundColor: gradient,
borderRadius: 4,
barPercentage: 0.6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
displayColors: false
}
},
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
}
}
}
}
});
});
// Translation Languages
fetch('/stats/api/translations/languages')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('langChart').getContext('2d');
new Chart(ctx, {
type: 'pie',
data: {
labels: data.labels,
datasets: [{
data: data.data,
backgroundColor: [
'#34495e', '#16a085', '#27ae60', '#2980b9', '#8e44ad',
'#2c3e50', '#f1c40f', '#e67e22', '#e74c3c', '#ecf0f1'
],
borderWidth: 0,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 20,
font: { size: 12 }
}
},
title: {
display: true,
text: 'Idioma de Origen (Top 10)',
padding: { bottom: 10 }
}
}
}
});
});
// System Info Polling
function updateSystemInfo() {
fetch('/stats/api/system/info')
.then(res => res.json())
.then(data => {
document.getElementById('cpu-load').textContent = data.cpu ? data.cpu.load : 'N/A';
document.getElementById('cpu-cores').textContent = data.cpu ? data.cpu.cores : '-';
document.getElementById('system-uptime').textContent = data.uptime;
document.getElementById('last-update-time').textContent = data.timestamp;
if (data.gpu) {
document.getElementById('gpu-name').textContent = data.gpu.name;
document.getElementById('gpu-temp').textContent = data.gpu.temp;
document.getElementById('gpu-util').textContent = data.gpu.util;
document.getElementById('gpu-mem').textContent = data.gpu.mem;
} else {
document.getElementById('gpu-name').textContent = 'No detectada';
}
})
.catch(err => console.error("Error polling system info", err));
}
updateSystemInfo();
setInterval(updateSystemInfo, 10000);
});
</script>
<style>
.stats-header {
text-align: center;
margin-bottom: 30px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.full-width {
grid-column: 1 / -1;
}
.stats-card {
padding: 20px;
min-height: 300px;
display: flex;
flex-direction: column;
}
.chart-container {
flex: 1;
position: relative;
min-height: 250px;
}
.system-stats {
list-style: none;
padding: 0;
}
.system-stats li {
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
}
.system-stats li:last-child {
border-bottom: none;
}
/* Stats Banner Styles */
.stats-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 1rem 1.5rem;
background: #111;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #fff;
font-family: 'Poppins', sans-serif;
line-height: 1;
}
.stat-value.stat-warning {
color: #ffc107;
}
.stat-value.stat-processing {
color: #17a2b8;
}
.stat-value.stat-error {
color: #dc3545;
}
.stat-label {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
.stat-divider {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.15);
}
.last-update {
font-size: 0.75rem;
color: #888;
font-style: italic;
margin-top: auto;
padding-top: 10px;
border-top: 1px dashed var(--border-color);
}
.dark-mode .stats-banner {
background: #000;
border: 1px solid #333;
}
@media (max-width: 600px) {
.stats-banner {
flex-wrap: wrap;
gap: 1rem;
}
.stat-divider {
display: none;
}
.stat-item {
flex: 0 0 45%;
}
}
/* Big Stats */
.big-stats {
display: flex;
gap: 2rem;
margin: 1rem 0 2rem 0;
padding: 1rem 0;
border-top: 1px solid var(--border-color, #eee);
border-bottom: 1px solid var(--border-color, #eee);
}
.big-stat {
text-align: center;
flex: 1;
}
.big-stat .big-number {
font-size: 2.5rem;
font-weight: 800;
color: #111;
font-family: 'Poppins', sans-serif;
line-height: 1;
}
.big-stat .big-label {
font-size: 0.75rem;
color: var(--text-muted, #666);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.35rem;
}
.big-stat.highlight .big-number {
color: #28a745;
}
.dark-mode .big-stat .big-number {
color: #fff;
}
@media (max-width: 600px) {
.big-stats {
flex-direction: column;
gap: 1rem;
}
}
</style>
{% endblock %}
```