stream-fusion-better/stream_fusion/static/admin/maintenance.html
LimeDrive acef8f2516 feat(torrent-group): add initial grouping framework for torrents
introduce initial torrent grouping: add torrent_groups table and
group_id on torrent_items. include migrations, ORM models, and DAOs.
expose admin/web endpoints to group by info_hash and by title/size.
propagate tmdb_id across grouped items. non-destructive and backwards
compatible with existing data
2026-04-02 16:13:09 +02:00

786 lines
39 KiB
HTML

{% extends "base.html" %}
{% block title %}Maintenance — StreamFusion Admin{% endblock %}
{% block page_title %}Maintenance{% endblock %}
{% block extra_head %}
<style>
.maint-card {
border: 1px solid var(--bs-border-color);
border-radius: 10px;
background: var(--bs-secondary-bg);
overflow: hidden;
}
.maint-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;
}
.maint-group-label {
padding: 7px 20px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.maint-action {
padding: 14px 20px;
border-bottom: 1px solid var(--bs-border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.maint-action:last-child { border-bottom: none; }
.maint-action-info { flex: 1; min-width: 0; }
.maint-action-title { font-size: 0.875rem; font-weight: 500; margin-bottom: 2px; }
.maint-action-desc { font-size: 0.78rem; color: var(--bs-secondary-color); }
.result-toast {
position: fixed; bottom: 24px; right: 24px;
z-index: 9999; min-width: 300px; max-width: 420px;
}
.days-input {
width: 72px; padding: 3px 8px; font-size: 0.85rem;
border-radius: 6px; border: 1px solid var(--bs-border-color);
background: var(--bs-body-bg); color: var(--bs-body-color); text-align: center;
}
.days-input:focus { outline: none; border-color: #0d6efd; box-shadow: 0 0 0 2px rgba(13,110,253,0.2); }
/* Stats section */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 16px 20px;
}
.stat-pill {
border: 1px solid var(--bs-border-color);
border-radius: 8px;
padding: 10px 14px;
text-align: center;
}
.stat-pill-value { font-size: 1.3rem; font-weight: 700; line-height: 1.1; }
.stat-pill-label { font-size: 0.72rem; color: var(--bs-secondary-color); margin-top: 3px; }
.stat-pill-sub { font-size: 0.7rem; color: var(--bs-secondary-color); margin-top: 2px; }
.key-count-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
color: var(--bs-secondary-color);
display: inline-block;
min-width: 28px;
text-align: center;
}
.expired-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: rgba(220,53,69,0.1);
border: 1px solid rgba(220,53,69,0.3);
color: #dc3545;
display: inline-block;
min-width: 28px;
text-align: center;
}
.stats-indexers { padding: 0 20px 14px; }
.indexer-bar {
display: flex; align-items: center; gap: 8px;
font-size: 0.78rem; margin-bottom: 5px;
}
.indexer-bar-name { min-width: 90px; color: var(--bs-secondary-color); }
.indexer-bar-track { flex: 1; height: 6px; border-radius: 3px; background: var(--bs-tertiary-bg); overflow: hidden; }
.indexer-bar-fill { height: 100%; border-radius: 3px; background: var(--bs-info); }
.indexer-bar-count { font-size: 0.72rem; color: var(--bs-secondary-color); min-width: 50px; text-align: right; }
.danger-zone {
border-top: 2px solid rgba(220,53,69,0.3);
background: rgba(220,53,69,0.03);
}
</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>
<!-- Confirmation modal -->
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h6 class="modal-title fw-semibold" id="confirmTitle">Confirmer l'action</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2" id="confirmBody"></div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-danger btn-sm" id="confirmBtn">Confirmer</button>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- ── Stats snapshot ── -->
<div class="col-12">
<div class="maint-card">
<div class="maint-card-header d-flex justify-content-between">
<span><i class="bi bi-bar-chart-line text-secondary me-2"></i>Aperçu instantané</span>
<button class="btn btn-link btn-sm p-0 text-secondary" id="refreshStats" title="Rafraîchir">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div id="statsContent">
<div class="text-center text-secondary py-3" style="font-size:0.85rem;" id="statsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Chargement…
</div>
</div>
</div>
</div>
<!-- ── PostgreSQL ── -->
<div class="col-12 col-xl-6">
<div class="maint-card">
<div class="maint-card-header">
<i class="bi bi-database text-primary"></i> PostgreSQL
</div>
<!-- Clés API -->
<div class="maint-group-label">Clés API</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Désactiver les clés API expirées
<span class="expired-badge ms-1" id="expiredApiCount"></span>
</div>
<div class="maint-action-desc">Passe <code>is_active = false</code> sur les clés dont la date d'expiration est dépassée. Non destructif — les clés restent en base.</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="disable-expired-api-keys"
data-confirm-title="Désactiver les clés API expirées ?"
data-confirm-body="Les clés expirées encore actives seront désactivées (is_active = false). Opération réversible.">
<i class="bi bi-toggle-off me-1"></i>Désactiver
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Supprimer les clés API expirées et inactives</div>
<div class="maint-action-desc">
Supprime définitivement les clés inactives non utilisées depuis plus de
<input type="number" class="days-input" id="apiKeyDays" value="30" min="1" max="365"> jours.
</div>
</div>
<button class="btn btn-sm btn-outline-danger flex-shrink-0 maint-btn"
data-action="clean-expired-api-keys"
data-confirm-title="Supprimer les clés API expirées ?"
data-confirm-body="Les clés non utilisées depuis le nombre de jours spécifié seront définitivement supprimées."
data-extra-field="apiKeyDays"
data-extra-param="days">
<i class="bi bi-trash3 me-1"></i>Supprimer
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Réinitialiser les compteurs des clés API</div>
<div class="maint-action-desc">Remet à zéro <code>total_queries</code> et <code>latest_query_date</code> pour toutes les clés.</div>
</div>
<button class="btn btn-sm btn-outline-secondary flex-shrink-0 maint-btn"
data-action="reset-api-key-counters"
data-confirm-title="Réinitialiser les compteurs ?"
data-confirm-body="Les compteurs de requêtes de toutes les clés API seront remis à zéro.">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
</button>
</div>
<!-- Peer Keys -->
<div class="maint-group-label">Peer Keys</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Désactiver les peer keys expirées
<span class="expired-badge ms-1" id="expiredPeerCount"></span>
</div>
<div class="maint-action-desc">Passe <code>is_active = false</code> sur les peer keys dont <code>expires_at</code> est dépassé. Non destructif.</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="disable-expired-peer-keys"
data-confirm-title="Désactiver les peer keys expirées ?"
data-confirm-body="Les peer keys expirées encore actives seront désactivées. Opération réversible.">
<i class="bi bi-toggle-off me-1"></i>Désactiver
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Réinitialiser les compteurs des peer keys</div>
<div class="maint-action-desc">Remet à zéro <code>total_queries</code> et <code>last_used_at</code> pour toutes les peer keys.</div>
</div>
<button class="btn btn-sm btn-outline-secondary flex-shrink-0 maint-btn"
data-action="reset-peer-key-counters"
data-confirm-title="Réinitialiser les compteurs peer ?"
data-confirm-body="Les compteurs de toutes les peer keys seront remis à zéro.">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
</button>
</div>
<!-- Cache Debrid PG -->
<div class="maint-group-label">Cache Debrid (PostgreSQL)</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Nettoyer le cache debrid expiré
<span class="expired-badge ms-1" id="expiredDebridCount"></span>
</div>
<div class="maint-action-desc">Supprime les entrées dont <code>expires_at</code> est dépassé.</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="clean-expired-debrid-cache"
data-confirm-title="Nettoyer le cache debrid expiré ?"
data-confirm-body="Les entrées dont expires_at est dépassé seront supprimées.">
<i class="bi bi-trash3 me-1"></i>Nettoyer
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Vider tout le cache debrid</div>
<div class="maint-action-desc text-danger-emphasis">
Supprime <strong>toutes</strong> les entrées de <code>debrid_cache</code>.
</div>
</div>
<button class="btn btn-sm btn-outline-danger flex-shrink-0 maint-btn"
data-action="purge-debrid-cache"
data-confirm-title="Vider tout le cache debrid ?"
data-confirm-body="TOUTES les entrées de debrid_cache seront supprimées. Les prochaines requêtes seront plus lentes.">
<i class="bi bi-lightning me-1"></i>Tout vider
</button>
</div>
<!-- Torrents -->
<div class="maint-group-label">Torrents</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Nettoyer les torrents anciens</div>
<div class="maint-action-desc">
Supprime les <code>torrent_items</code> non mis à jour depuis plus de
<input type="number" class="days-input" id="torrentDays" value="90" min="1" max="730"> jours.
</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="clean-old-torrents"
data-confirm-title="Supprimer les anciens torrents ?"
data-confirm-body="Les entrées torrent_items non mises à jour depuis le nombre de jours spécifié seront supprimées."
data-extra-field="torrentDays"
data-extra-param="days">
<i class="bi bi-trash3 me-1"></i>Nettoyer
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Nettoyer les torrents orphelins
<span class="expired-badge ms-1" id="orphanTorrentCount"></span>
</div>
<div class="maint-action-desc">
Supprime les <code>torrent_items</code> sans <code>tmdb_id</code> créés il y a plus de
<input type="number" class="days-input" id="orphanDays" value="30" min="7" max="365"> jours.
</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="clean-orphan-torrents"
data-confirm-title="Supprimer les torrents orphelins ?"
data-confirm-body="Les torrents sans tmdb_id (non résolus) plus vieux que le nombre de jours spécifié seront supprimés."
data-extra-field="orphanDays"
data-extra-param="days">
<i class="bi bi-trash3 me-1"></i>Nettoyer
</button>
</div>
<!-- Groupement de torrents -->
<div class="maint-group-label mt-3">Groupement de torrents (déduplication)</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Grouper par info_hash identique
<span class="badge bg-secondary ms-1" id="groupedItemsBadge"></span>
</div>
<div class="maint-action-desc">
Relie les <code>torrent_items</code> qui partagent exactement le même <code>info_hash</code>
(certitude 100% — même torrent repris par plusieurs indexeurs). Propagation automatique du TMDB ID.
</div>
</div>
<button class="btn btn-sm btn-outline-info flex-shrink-0 maint-btn"
data-action="group-by-infohash"
data-confirm-title="Grouper par info_hash ?"
data-confirm-body="Les torrents partageant le même info_hash seront reliés dans la table torrent_groups. Aucune suppression.">
<i class="bi bi-diagram-3 me-1"></i>Grouper
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Grouper par titre normalisé + taille similaire</div>
<div class="maint-action-desc">
Détecte les torrents identiques publiés sous des titres légèrement différents (normalisation)
et avec une taille quasi-identique (±0,5 %). Couvre les cas où des trackers recalculent l'hash.
</div>
</div>
<button class="btn btn-sm btn-outline-info flex-shrink-0 maint-btn"
data-action="group-by-title-size"
data-confirm-title="Grouper par titre + taille ?"
data-confirm-body="Les torrents avec le même titre normalisé et une taille similaire (±0,5 %) seront groupés. Aucune suppression.">
<i class="bi bi-diagram-2 me-1"></i>Grouper
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Propager les TMDB IDs dans les groupes</div>
<div class="maint-action-desc">
Pour chaque groupe existant, propage le <code>tmdb_id</code> consensuel
aux membres qui n'en ont pas encore un.
</div>
</div>
<button class="btn btn-sm btn-outline-success flex-shrink-0 maint-btn"
data-action="propagate-tmdb-groups"
data-confirm-title="Propager les TMDB IDs ?"
data-confirm-body="Le tmdb_id le plus fréquent au sein de chaque groupe sera assigné aux membres qui n'en ont pas. Les mismatches connus sont respectés.">
<i class="bi bi-arrow-repeat me-1"></i>Propager
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">Nettoyer les groupes vides</div>
<div class="maint-action-desc">
Supprime les entrées <code>torrent_groups</code> qui n'ont plus aucun membre.
</div>
</div>
<button class="btn btn-sm btn-outline-secondary flex-shrink-0 maint-btn"
data-action="cleanup-empty-groups"
data-confirm-title="Supprimer les groupes vides ?"
data-confirm-body="Les groupes sans aucun torrent_item associé seront supprimés définitivement.">
<i class="bi bi-trash3 me-1"></i>Nettoyer
</button>
</div>
</div>
</div>
<!-- ── Redis ── -->
<div class="col-12 col-xl-6">
<div class="maint-card">
<div class="maint-card-header">
<i class="bi bi-hdd-stack text-danger"></i> Redis Cache
</div>
<!-- Cache Recherche -->
<div class="maint-group-label">Cache Recherche</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Cache films &amp; séries
<span class="key-count-badge ms-1" data-key-cat="searchcache"></span>
</div>
<div class="maint-action-desc">
<code>search:*</code> — Résultats de recherche de torrents. TTL&nbsp;: 7 jours.
</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="searchcache"
data-confirm-title="Vider le cache de recherche ?"
data-confirm-body="Les résultats de recherche en cache (films et séries) seront supprimés. Les prochaines recherches interrogeront directement les indexers.">
<i class="bi bi-trash3 me-1"></i>Vider
</button>
</div>
<!-- Cache Debrid Redis -->
<div class="maint-group-label">Cache Disponibilité Debrid</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Tous les services
<span class="key-count-badge ms-1" data-key-cat="debrid_avail"></span>
</div>
<div class="maint-action-desc">
<code>debrid_avail:*</code> — TTL&nbsp;: 10 jours (en cache) / 3 h (non dispo).
</div>
</div>
<button class="btn btn-sm btn-outline-danger flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="debrid_avail"
data-confirm-title="Vider tout le cache debrid Redis ?"
data-confirm-body="Toutes les entrées debrid_avail:* seront supprimées (tous services). Les prochaines requêtes repasseront par l'API debrid.">
<i class="bi bi-lightning me-1"></i>Tout vider
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Real-Debrid
<span class="key-count-badge ms-1" data-key-cat="debrid_avail_rd"></span>
</div>
<div class="maint-action-desc"><code>debrid_avail:realdebrid:*</code></div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="debrid_avail_rd"
data-confirm-title="Vider le cache Real-Debrid ?"
data-confirm-body="Toutes les entrées debrid_avail:realdebrid:* seront supprimées.">
<i class="bi bi-trash3 me-1"></i>Vider
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
AllDebrid
<span class="key-count-badge ms-1" data-key-cat="debrid_avail_ad"></span>
</div>
<div class="maint-action-desc"><code>debrid_avail:alldebrid:*</code></div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="debrid_avail_ad"
data-confirm-title="Vider le cache AllDebrid ?"
data-confirm-body="Toutes les entrées debrid_avail:alldebrid:* seront supprimées.">
<i class="bi bi-trash3 me-1"></i>Vider
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
TorBox
<span class="key-count-badge ms-1" data-key-cat="debrid_avail_tb"></span>
</div>
<div class="maint-action-desc"><code>debrid_avail:torbox:*</code></div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="debrid_avail_tb"
data-confirm-title="Vider le cache TorBox ?"
data-confirm-body="Toutes les entrées debrid_avail:torbox:* seront supprimées.">
<i class="bi bi-trash3 me-1"></i>Vider
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Premiumize
<span class="key-count-badge ms-1" data-key-cat="debrid_avail_pm"></span>
</div>
<div class="maint-action-desc"><code>debrid_avail:premiumize:*</code></div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="debrid_avail_pm"
data-confirm-title="Vider le cache Premiumize ?"
data-confirm-body="Toutes les entrées debrid_avail:premiumize:* seront supprimées.">
<i class="bi bi-trash3 me-1"></i>Vider
</button>
</div>
<!-- Règles & Auth -->
<div class="maint-group-label">Règles &amp; Auth</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Règles de matching de titres
<span class="key-count-badge ms-1" data-key-cat="title_matching"></span>
</div>
<div class="maint-action-desc">
<code>title_matching:*</code> — Règles de normalisation et de langue. Pas de TTL.
</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="title_matching"
data-confirm-title="Vider le cache des règles de matching ?"
data-confirm-body="Les règles de normalisation et de langue en cache seront supprimées. Elles seront rechargées depuis la base au prochain appel.">
<i class="bi bi-trash3 me-1"></i>Vider
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Tokens Real-Debrid OAuth
<span class="key-count-badge ms-1" data-key-cat="rd_tokens"></span>
</div>
<div class="maint-action-desc">
<code>rd_access_token:*</code> — Tokens d'accès en cache. TTL&nbsp;: 12 h.
</div>
</div>
<button class="btn btn-sm btn-outline-warning flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="rd_tokens"
data-confirm-title="Supprimer les tokens Real-Debrid ?"
data-confirm-body="Les tokens d'accès Real-Debrid en cache seront supprimés. Ils seront renouvelés automatiquement au prochain appel.">
<i class="bi bi-trash3 me-1"></i>Vider
</button>
</div>
<!-- Jobs & Limites -->
<div class="maint-group-label">Jobs &amp; Limites</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Rate limits peers
<span class="key-count-badge ms-1" data-key-cat="ratelimit_peer"></span>
</div>
<div class="maint-action-desc">
<code>ratelimit:peer:*</code> — Compteurs de limitation de débit. TTL dynamique.
</div>
</div>
<button class="btn btn-sm btn-outline-secondary flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="ratelimit_peer"
data-confirm-title="Réinitialiser les rate limits peers ?"
data-confirm-body="Tous les compteurs de limitation de débit peers seront remis à zéro.">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
</button>
</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title">
Verrous de rafraîchissement background
<span class="key-count-badge ms-1" data-key-cat="bg_refresh"></span>
</div>
<div class="maint-action-desc">
<code>bg_refresh:*</code> — Verrous distribués par indexer/média. TTL&nbsp;: 6 h.
</div>
</div>
<button class="btn btn-sm btn-outline-secondary flex-shrink-0 maint-btn"
data-action="flush-redis-pattern"
data-pattern="bg_refresh"
data-confirm-title="Supprimer les verrous background ?"
data-confirm-body="Les verrous de rafraîchissement background seront supprimés. Utile si un verrou est bloqué.">
<i class="bi bi-unlock me-1"></i>Débloquer
</button>
</div>
<!-- Zone dangereuse -->
<div class="danger-zone">
<div class="maint-group-label" style="color:rgba(220,53,69,0.7);background:rgba(220,53,69,0.05);">Zone dangereuse</div>
<div class="maint-action">
<div class="maint-action-info">
<div class="maint-action-title text-danger">Flush complet Redis (FLUSHDB)</div>
<div class="maint-action-desc text-danger-emphasis">
Vide <strong>toute</strong> la base Redis — inclut les sessions actives et tous les caches.
</div>
</div>
<button class="btn btn-sm btn-danger flex-shrink-0 maint-btn"
data-action="flush-redis-all"
data-confirm-title="Vider tout Redis ?"
data-confirm-body="FLUSHDB sera exécuté. Toutes les clés Redis seront supprimées, y compris les sessions admin actives. Vous serez déconnecté.">
<i class="bi bi-lightning me-1"></i>FLUSHDB
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
const modal = new bootstrap.Modal(document.getElementById('confirmModal'));
let pendingAction = null;
// Wire up all maintenance buttons
document.querySelectorAll('.maint-btn').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
const title = btn.dataset.confirmTitle;
const body = btn.dataset.confirmBody;
const pattern = btn.dataset.pattern || null;
const extraField = btn.dataset.extraField || null;
const extraParam = btn.dataset.extraParam || null;
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmBody').textContent = body;
pendingAction = () => {
const fd = new FormData();
if (pattern) fd.append('pattern', pattern);
if (extraField) fd.append(extraParam, document.getElementById(extraField).value);
runAction('/admin/maintenance/' + action, fd);
};
modal.show();
});
});
document.getElementById('confirmBtn').addEventListener('click', () => {
modal.hide();
if (pendingAction) { pendingAction(); pendingAction = null; }
});
document.getElementById('toastClose').addEventListener('click', hideToast);
async function runAction(url, fd) {
fd.append('csrf_token', window.__ADMIN_CSRF__ || '');
try {
const res = await fetch(url, { method: 'POST', body: fd });
const data = await res.json();
showToast(data.success, data.message || (data.success ? 'Opération réussie' : 'Erreur'));
if (data.success) loadStats();
} catch (e) {
showToast(false, 'Erreur réseau : ' + e.message);
}
}
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(hideToast, 5000);
}
function hideToast() { document.getElementById('resultToast').style.display = 'none'; }
// ── Stats ──
async function loadStats() {
try {
const res = await fetch('/admin/maintenance/stats');
const data = await res.json();
const pg = data.pg || {};
const rd = data.redis || {};
const fmt = n => (typeof n === 'number') ? n.toLocaleString('fr-FR') : (n ?? '?');
const apiK = pg.api_keys || {};
const peerK = pg.peer_keys || {};
const dCache= pg.debrid_cache|| {};
const tItems= pg.torrent_items || {};
const sizes = pg.table_sizes || {};
// Update expired badges in the action list
if (typeof apiK.expired_active === 'number')
document.getElementById('expiredApiCount').textContent = fmt(apiK.expired_active);
if (typeof peerK.expired_active === 'number')
document.getElementById('expiredPeerCount').textContent = fmt(peerK.expired_active);
if (typeof dCache.expired === 'number')
document.getElementById('expiredDebridCount').textContent = fmt(dCache.expired);
if (typeof tItems.orphans === 'number')
document.getElementById('orphanTorrentCount').textContent = fmt(tItems.orphans);
const tGroups = pg.torrent_groups || {};
if (typeof tGroups.grouped_items === 'number')
document.getElementById('groupedItemsBadge').textContent = fmt(tGroups.grouped_items) + ' groupé(s)';
// Update Redis key count badges
const keyCounts = rd.key_counts || {};
document.querySelectorAll('[data-key-cat]').forEach(el => {
const cat = el.dataset.keyCat;
el.textContent = cat in keyCounts ? fmt(keyCounts[cat]) : '—';
});
// Build PostgreSQL pills
const pgItems = [
{ label: 'Clés API (total)', value: fmt(apiK.total), color: 'text-primary',
sub: apiK.expired_active > 0 ? `${fmt(apiK.expired_active)} expirée(s) active(s)` : null },
{ label: 'Peer keys (total)', value: fmt(peerK.total), color: 'text-success',
sub: peerK.expired_active > 0 ? `${fmt(peerK.expired_active)} expirée(s) active(s)` : null },
{ label: 'Cache debrid', value: fmt(dCache.total), color: 'text-warning',
sub: dCache.expired > 0 ? `${fmt(dCache.expired)} expirée(s)` : null },
{ label: 'Torrents indexés', value: fmt(tItems.total), color: 'text-info',
sub: [
tItems.orphans > 0 ? `${fmt(tItems.orphans)} orphelins` : null,
tItems.old_90d > 0 ? `${fmt(tItems.old_90d)} >90j` : null,
].filter(Boolean).join(' · ') || null },
{ label: 'Groupes torrents', value: fmt(tGroups.total_groups), color: 'text-info',
sub: [
tGroups.grouped_items > 0 ? `${fmt(tGroups.grouped_items)} groupé(s)` : null,
tGroups.ungrouped_items > 0 ? `${fmt(tGroups.ungrouped_items)} non groupé(s)` : null,
tGroups.groups_with_tmdb > 0 ? `${fmt(tGroups.groups_with_tmdb)} avec TMDB` : null,
].filter(Boolean).join(' · ') || null },
{ label: 'Mappings méta', value: fmt(pg.metadata_mappings),color: 'text-secondary', sub: null },
];
if (sizes.torrent_items)
pgItems[3].sub = (pgItems[3].sub ? pgItems[3].sub + ' · ' : '') + sizes.torrent_items;
// Build Redis pills
const rdItems = [
{ label: 'Clés Redis (total)', value: fmt(rd.total_keys), color: 'text-danger', sub: rd.used_memory_human || null },
{ label: 'Cache recherche', value: fmt(keyCounts.searchcache), color: 'text-info', sub: 'search:*' },
{ label: 'Debrid avail', value: fmt(keyCounts.debrid_avail),color: 'text-warning',sub: 'debrid_avail:*' },
{ label: 'Rate limits', value: fmt(keyCounts.ratelimit_peer), color: 'text-secondary', sub: 'ratelimit:peer:*' },
{ label: 'BG refresh locks', value: fmt(keyCounts.bg_refresh), color: 'text-secondary', sub: 'bg_refresh:*' },
];
const makePills = items => items.map(it => `
<div class="stat-pill">
<div class="stat-pill-value ${it.color}">${it.value}</div>
<div class="stat-pill-label">${it.label}</div>
${it.sub ? `<div class="stat-pill-sub">${it.sub}</div>` : ''}
</div>`).join('');
// Top indexers bar chart
const topIdx = pg.top_indexers || [];
let indexerHtml = '';
if (topIdx.length) {
const maxCount = topIdx[0].count || 1;
indexerHtml = `
<div style="padding:4px 20px 0;font-size:0.72rem;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--bs-secondary-color);border-top:1px solid var(--bs-border-color);">Top indexers (torrents)</div>
<div class="stats-indexers">${topIdx.map(row => `
<div class="indexer-bar">
<div class="indexer-bar-name">${row.indexer || '(null)'}</div>
<div class="indexer-bar-track"><div class="indexer-bar-fill" style="width:${Math.round(row.count/maxCount*100)}%"></div></div>
<div class="indexer-bar-count">${fmt(row.count)}</div>
</div>`).join('')}
</div>`;
}
document.getElementById('statsContent').innerHTML = `
<div style="padding:10px 20px 4px;font-size:0.72rem;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--bs-secondary-color);">PostgreSQL</div>
<div class="stats-grid" style="padding-top:8px;">${makePills(pgItems)}</div>
${indexerHtml}
<div style="padding:4px 20px 4px;font-size:0.72rem;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--bs-secondary-color);border-top:1px solid var(--bs-border-color);">Redis</div>
<div class="stats-grid" style="padding-top:8px;">${makePills(rdItems)}</div>`;
} catch (e) {
document.getElementById('statsContent').innerHTML =
'<div class="text-secondary text-center py-3" style="font-size:0.85rem;">Impossible de charger les statistiques.</div>';
}
}
document.getElementById('refreshStats').addEventListener('click', loadStats);
loadStats();
</script>
{% endblock %}