stream-fusion-better/stream_fusion/static/admin/scheduled_tasks.html
LimeDrive e8b0976332 refactor(logging): switch info/debug logs to trace across modules
convert info/debug logs to trace across modules for deeper diagnostics
without changing behavior
2026-04-03 12:09:26 +02:00

345 lines
17 KiB
HTML

{% extends "base.html" %}
{% block title %}Tâches planifiées — StreamFusion Admin{% endblock %}
{% block page_title %}Tâches planifiées{% endblock %}
{% block extra_head %}
<style>
.sched-card {
border: 1px solid var(--bs-border-color);
border-radius: 10px;
background: var(--bs-secondary-bg);
overflow: hidden;
}
.sched-card-header {
padding: 13px 20px;
border-bottom: 1px solid var(--bs-border-color);
font-weight: 600;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 8px;
}
.result-toast {
position: fixed; bottom: 24px; right: 24px;
z-index: 9999; min-width: 300px; max-width: 420px;
}
.status-table td, .status-table th {
vertical-align: middle;
font-size: 0.85rem;
padding: 11px 16px;
}
.status-table th {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
white-space: nowrap;
}
.status-table tbody tr:last-child td { border-bottom: none; }
.job-name { font-weight: 500; }
.job-name i { font-size: 0.95rem; margin-right: 6px; color: var(--bs-secondary-color); }
.job-desc { font-size: 0.78rem; color: var(--bs-secondary-color); margin-top: 3px; font-weight: 400; }
.job-detail{ font-size: 0.73rem; color: var(--bs-tertiary-color, #8a8f98); margin-top: 2px; font-weight: 400; font-style: italic; }
.param-badge {
display: inline-block; font-size: 0.68rem; padding: 1px 6px; border-radius: 4px;
background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color);
color: var(--bs-secondary-color); font-family: monospace; font-style: normal;
vertical-align: middle; margin-left: 4px;
}
.badge-ok { background: rgba(25,135,84,0.15); color: #198754; border: 1px solid rgba(25,135,84,0.25); }
.badge-error { background: rgba(220,53,69,0.15); color: #dc3545; border: 1px solid rgba(220,53,69,0.25); }
.badge-never { background: var(--bs-tertiary-bg); color: var(--bs-secondary-color); border: 1px solid var(--bs-border-color); }
.result-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 0.75rem; font-weight: 500; white-space: nowrap;
}
.countdown { font-variant-numeric: tabular-nums; }
.refresh-bar {
height: 3px; background: var(--bs-tertiary-bg); border-radius: 2px; overflow: hidden;
margin-top: 2px;
}
.refresh-fill { height: 100%; background: #0d6efd; border-radius: 2px; transition: width 1s linear; }
.status-header-badges { display: flex; align-items: center; gap: 8px; margin-left: auto; }
</style>
{% endblock %}
{% block content %}
<!-- Toast notification -->
<div class="result-toast" id="resultToast" style="display:none;">
<div class="toast show align-items-center border-0" id="toastEl" role="alert">
<div class="d-flex">
<div class="toast-body" id="toastMsg"></div>
<button type="button" class="btn-close me-2 m-auto" id="toastClose"></button>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="sched-card">
<div class="sched-card-header">
<i class="bi bi-clock-history text-primary"></i>
Jobs de nettoyage automatique
<div class="status-header-badges" id="headerBadges">
<span class="spinner-border spinner-border-sm text-secondary"></span>
</div>
<button class="btn btn-link btn-sm p-0 text-secondary ms-2" id="refreshBtn" title="Rafraîchir maintenant">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<!-- Barre de progression auto-refresh -->
<div class="refresh-bar mx-3 mt-2 mb-1">
<div class="refresh-fill" id="refreshFill" style="width:100%"></div>
</div>
<div class="table-responsive">
<table class="table status-table mb-0">
<thead>
<tr>
<th>Tâche</th>
<th>Intervalle</th>
<th>Dernier run</th>
<th>Résultat</th>
<th>Durée</th>
<th>Prochain run</th>
<th></th>
</tr>
</thead>
<tbody id="jobsBody">
<tr>
<td colspan="7" class="text-center text-secondary py-4">
<span class="spinner-border spinner-border-sm me-2"></span>Chargement…
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
const REFRESH_INTERVAL = 30; // secondes
let refreshTimer = null;
let fillTimer = null;
let fillStart = null;
// ── Relative time helpers ──────────────────────────────────────────────────
function relativeTime(epochSeconds) {
if (!epochSeconds) return '<span class="text-secondary">—</span>';
const diffS = Math.round(Date.now() / 1000 - epochSeconds);
if (diffS < 5) return 'à l\'instant';
if (diffS < 60) return `il y a ${diffS}s`;
if (diffS < 3600)return `il y a ${Math.round(diffS/60)}min`;
if (diffS < 86400)return `il y a ${Math.round(diffS/3600)}h`;
return `il y a ${Math.round(diffS/86400)}j`;
}
function countdown(epochSeconds) {
if (!epochSeconds) return '<span class="text-secondary">—</span>';
const diffS = Math.round(epochSeconds - Date.now() / 1000);
if (diffS <= 0) return '<span class="text-warning">imminent</span>';
if (diffS < 60) return `<span class="countdown">dans ${diffS}s</span>`;
if (diffS < 3600)return `<span class="countdown">dans ${Math.round(diffS/60)}min</span>`;
return `<span class="countdown">dans ${Math.round(diffS/3600)}h${Math.round((diffS%3600)/60) ? ' '+Math.round((diffS%3600)/60)+'min' : ''}</span>`;
}
function fmtDuration(ms) {
if (!ms) return '<span class="text-secondary">—</span>';
if (ms < 1000) return `${ms}ms`;
return `${(ms/1000).toFixed(1)}s`;
}
function fmt(n) {
return typeof n === 'number' ? n.toLocaleString('fr-FR') : '—';
}
// ── Result badge ───────────────────────────────────────────────────────────
function resultBadge(job) {
if (!job.last_run_at) return '<span class="result-badge badge-never">Jamais exécuté</span>';
if (job.last_status === 'error')
return `<span class="result-badge badge-error" title="${job.last_error}"><i class="bi bi-x-circle me-1"></i>Erreur</span>`;
return `<span class="result-badge badge-ok"><i class="bi bi-check-circle me-1"></i>${fmt(job.last_rows_affected)} lignes</span>`;
}
// ── Job documentation ──────────────────────────────────────────────────────
const JOB_DOCS = {
debrid_cache_cleanup: {
desc: "Supprime les entrées <code>debrid_cache</code> dont <code>expires_at</code> est dépassé.",
detail: "Ces entrées s'accumulent après chaque vérification de disponibilité torrent (Real-Debrid, AllDebrid, TorBox, Premiumize). Sans ce job, la table grossit indéfiniment.",
param: null,
},
torrent_orphan_cleanup: {
desc: "Supprime les <code>torrent_items</code> sans <code>tmdb_id</code> plus vieux que N jours.",
detail: "Après ce délai, le job de matching automatique et les recherches utilisateurs ont eu suffisamment d'opportunités de les résoudre. S'ils restent orphelins, ils ne seront probablement jamais matchés. Les torrents avec un tmdb_id sont conservés indéfiniment.",
param: "SCHEDULER_TORRENT_ORPHAN_MAX_AGE_DAYS (défaut : 7j)",
},
torrent_dedup: {
desc: "Déduplique <code>torrent_items</code> par <code>(info_hash, indexer)</code>, conserve l'entrée avec le plus de seeders.",
detail: "Le même torrent peut être stocké plusieurs fois si un indexer renvoie un titre légèrement différent entre deux recherches (nouvelle ligne car l'ID est basé sur le titre). Ce job nettoie ces doublons.",
param: null,
},
torrent_group_hash: {
desc: "Crée ou étend des <code>torrent_groups</code> pour tous les <code>torrent_items</code> qui partagent exactement le même <code>info_hash</code>.",
detail: "Certitude 100% : même hash = même torrent. Liaison des doublons cross-indexeurs sans les supprimer. Propage également le <code>tmdb_id</code> aux membres du groupe qui n'en ont pas encore. Non destructif.",
param: null,
},
torrent_group_title_size: {
desc: "Crée ou étend des <code>torrent_groups</code> pour les <code>torrent_items</code> avec un titre normalisé identique et une taille similaire (±0,5%).",
detail: "Couvre le cas où des trackers recalculent le hash pour le même fichier. La normalisation de titre utilise le <code>TitleNormalizer</code> configuré dans l'admin (règles de titres). La tolérance de taille (~0,5%) couvre les fichiers NFO présents ou non. Non destructif.",
param: null,
},
api_keys_cleanup: {
desc: "Passe <code>is_active = false</code> sur les clés API dont <code>expiration_date</code> est dépassée.",
detail: "Non destructif : les clés restent en base et sont réactivables manuellement. Empêche uniquement leur utilisation.",
param: null,
},
peer_keys_cleanup: {
desc: "Passe <code>is_active = false</code> sur les peer keys dont <code>expires_at</code> est dépassé.",
detail: "Non destructif : les clés restent en base. Complément automatique de l'action manuelle disponible dans la page Maintenance.",
param: null,
},
tmdb_orphan_matching: {
desc: "Recherche un <code>tmdb_id</code> pour les <code>torrent_items</code> orphelins (sans TMDB ID) via l'API de recherche TMDB.",
detail: "Parse le titre brut, regroupe les items par titre normalisé, puis effectue une recherche TMDB par titre (+ année si présente). N'assigne que les matchs à haute confiance (exact ou sous-ensemble ordonné + validation d'année). L'historique détaillé est consultable dans la page TMDB Matcher.",
param: "SCHEDULER_TMDB_MATCH_BATCH_SIZE (défaut : 500 items/run)",
},
fix_type_inconsistencies: {
desc: "Corrige les <code>torrent_items</code> incorrectement marqués comme film alors qu'il s'agit de séries.",
detail: "Détecte les torrents avec <code>type = 'movie'</code> dont le titre contient un marqueur de saison (<code>S05</code>, <code>S05E01</code>…) ou un indicateur d'intégrale (<code>COMPLETE</code>, <code>INTEGRALE</code>). Met à jour le type en <code>series</code>. Couvre les packs complets sans numéro de saison explicite.",
param: null,
},
};
// ── Render ──────────────────────────────────────────────────────────────────
async function loadStatus() {
try {
const res = await fetch('/admin/scheduler/status');
const data = await res.json();
// Header badges
const running = data.scheduler_running;
const enabled = data.scheduler_enabled;
let badges = '';
if (!enabled) {
badges = '<span class="badge bg-secondary">Désactivé</span>';
} else if (running) {
badges = `<span class="badge bg-success"><i class="bi bi-play-fill me-1"></i>Actif</span>
<span class="badge bg-secondary">PID&nbsp;${data.leader_pid ?? '—'}</span>`;
} else {
badges = '<span class="badge bg-warning text-dark"><i class="bi bi-pause-fill me-1"></i>Pas leader</span>';
}
document.getElementById('headerBadges').innerHTML = badges;
// Jobs table
const rows = (data.jobs || []).map(job => {
const doc = JOB_DOCS[job.id] || {};
const paramBadge = doc.param
? `<span class="param-badge">${doc.param}</span>`
: '';
return `
<tr>
<td class="job-name">
<div><i class="bi ${job.icon}"></i>${job.name}</div>
${doc.desc ? `<div class="job-desc">${doc.desc} ${paramBadge}</div>` : ''}
${doc.detail ? `<div class="job-detail">${doc.detail}</div>` : ''}
</td>
<td class="text-secondary">Toutes les ${job.interval_hours}h</td>
<td>${relativeTime(job.last_run_at)}</td>
<td>${resultBadge(job)}</td>
<td class="text-secondary">${fmtDuration(job.last_duration_ms)}</td>
<td>${countdown(job.next_run_at)}</td>
<td>
<button class="btn btn-sm btn-outline-primary trigger-btn"
data-job-id="${job.id}"
data-job-name="${job.name}"
title="Déclencher maintenant">
<i class="bi bi-play-fill"></i>
</button>
</td>
</tr>`;
}).join('');
document.getElementById('jobsBody').innerHTML = rows || `
<tr><td colspan="7" class="text-center text-secondary py-4">Aucun job enregistré.</td></tr>`;
// Wire trigger buttons
document.querySelectorAll('.trigger-btn').forEach(btn => {
btn.addEventListener('click', () => triggerJob(btn.dataset.jobId, btn.dataset.jobName));
});
} catch (e) {
document.getElementById('jobsBody').innerHTML = `
<tr><td colspan="7" class="text-center text-danger py-3">Impossible de charger le statut.</td></tr>`;
}
}
// ── Trigger a job ──────────────────────────────────────────────────────────
async function triggerJob(jobId, jobName) {
const fd = new FormData();
fd.append('csrf_token', window.__ADMIN_CSRF__ || '');
try {
const res = await fetch(`/admin/scheduler/trigger/${jobId}`, { method: 'POST', body: fd });
const data = await res.json();
showToast(data.success, data.message || (data.success ? 'Déclenché' : 'Erreur'));
if (data.success) setTimeout(loadStatus, 2000); // re-check after 2s
} catch (e) {
showToast(false, 'Erreur réseau : ' + e.message);
}
}
// ── Toast ──────────────────────────────────────────────────────────────────
function showToast(success, message) {
const el = document.getElementById('toastEl');
const wrap = document.getElementById('resultToast');
el.className = 'toast show align-items-center border-0 text-bg-' + (success ? 'success' : 'danger');
document.getElementById('toastMsg').textContent = message;
wrap.style.display = 'block';
setTimeout(() => { wrap.style.display = 'none'; }, 5000);
}
document.getElementById('toastClose').addEventListener('click', () => {
document.getElementById('resultToast').style.display = 'none';
});
// ── Auto-refresh avec barre de progression ─────────────────────────────────
function startRefillBar() {
fillStart = Date.now();
const fill = document.getElementById('refreshFill');
clearInterval(fillTimer);
fillTimer = setInterval(() => {
const elapsed = (Date.now() - fillStart) / 1000;
const pct = Math.max(0, 100 - (elapsed / REFRESH_INTERVAL) * 100);
fill.style.width = pct + '%';
if (pct <= 0) clearInterval(fillTimer);
}, 500);
}
function scheduleRefresh() {
clearTimeout(refreshTimer);
startRefillBar();
refreshTimer = setTimeout(async () => {
await loadStatus();
scheduleRefresh();
}, REFRESH_INTERVAL * 1000);
}
document.getElementById('refreshBtn').addEventListener('click', async () => {
clearTimeout(refreshTimer);
clearInterval(fillTimer);
await loadStatus();
scheduleRefresh();
});
// Initial load
loadStatus();
scheduleRefresh();
</script>
{% endblock %}