stream-fusion-better/stream_fusion/static/admin/peer_keys.html
LimeDrive 8468bae024 feat(admin): add metadata mappings table and complete admin UI redesign
Introduce a metadata_mappings table with DAO/model layers for admin-managed
IMDB→TMDB/title overrides, and overhaul the admin panel with a modern
Bootstrap 5 sidebar UI.

- Add metadata_mappings table with migrations (search_titles, year_override)
- Add MetadataMappingDAO with standalone session helpers for use outside FastAPI
- Add new admin pages: dashboard, mappings CRUD, maintenance, config, hash/TMDB search
- Integrate DB mapping lookups into Cinemeta and TMDB metadata providers
- Update catalog views to use DB mappings before hitting external APIs
- Rewrite all admin templates with Bootstrap 5, sidebar navigation, French labels
- Add session-based auth for admin routes with Redis session store
2026-03-29 04:46:51 +02:00

239 lines
12 KiB
HTML

{% extends "base.html" %}
{% block title %}Peer Keys — StreamFusion Admin{% endblock %}
{% block page_title %}Peer Keys{% endblock %}
{% block extra_head %}
<style>
.key-uuid { font-family: var(--bs-font-monospace); font-size: 0.78rem; color: #67d7f0; letter-spacing: 0.01em; }
.date-abs { font-size: 0.8rem; }
.date-rel { font-size: 0.72rem; color: var(--bs-secondary-color); }
</style>
{% endblock %}
{% block content %}
{% if new_key %}
<div class="alert border mb-4" style="background:rgba(255,184,108,0.1);border-color:rgba(255,184,108,0.4)!important;border-radius:10px;">
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-exclamation-triangle-fill text-warning fs-5"></i>
<strong class="text-warning">Sauvegardez ces identifiants maintenant — le secret ne sera plus affiché.</strong>
</div>
<div class="mb-2">
<label class="form-label mb-1" style="font-size:0.75rem;color:var(--bs-secondary-color);">KEY ID</label>
<div class="d-flex align-items-center gap-2">
<code class="text-info" style="font-size:0.85rem;word-break:break-all;">{{ new_key.key_id }}</code>
<button class="btn btn-sm btn-outline-secondary copy-btn flex-shrink-0" data-value="{{ new_key.key_id }}">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div>
<label class="form-label mb-1" style="font-size:0.75rem;color:var(--bs-secondary-color);">SECRET</label>
<div class="d-flex align-items-center gap-2">
<code class="text-success" style="font-size:0.85rem;word-break:break-all;">{{ new_key.secret }}</code>
<button class="btn btn-sm btn-outline-secondary copy-btn flex-shrink-0" data-value="{{ new_key.secret }}">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</div>
{% endif %}
<!-- Controls -->
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="searchInput" class="form-control form-control-sm"
style="width:200px;" placeholder="Rechercher par nom…">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary filter-btn active" data-filter="all">Toutes <span class="badge bg-secondary ms-1">{{ keys|length }}</span></button>
<button class="btn btn-outline-success filter-btn" data-filter="active">Actives <span class="badge bg-success ms-1">{{ keys | selectattr('is_active') | list | length }}</span></button>
<button class="btn btn-outline-danger filter-btn" data-filter="revoked">Révoquées <span class="badge bg-danger ms-1">{{ keys | rejectattr('is_active') | list | length }}</span></button>
</div>
</div>
<a href="{{ url_for('create_peer_key_page')(request) }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Nouvelle peer key
</a>
</div>
<!-- Table -->
<div class="table-responsive">
<table class="table table-hover align-middle" id="peerTable">
<thead>
<tr style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--bs-secondary-color);">
<th class="sortable" data-col="0" style="cursor:pointer;">Key ID <i class="bi bi-chevron-expand ms-1 sort-icon"></i></th>
<th class="sortable" data-col="1" style="cursor:pointer;">Nom <i class="bi bi-chevron-expand ms-1 sort-icon"></i></th>
<th>Rate Limit</th>
<th class="sortable" data-col="3" style="cursor:pointer;">Statut <i class="bi bi-chevron-expand ms-1 sort-icon"></i></th>
<th class="sortable" data-col="4" style="cursor:pointer;">Créé le <i class="bi bi-chevron-expand ms-1 sort-icon"></i></th>
<th class="sortable" data-col="5" style="cursor:pointer;">Dernière utilisation <i class="bi bi-chevron-expand ms-1 sort-icon"></i></th>
<th class="sortable" data-col="6" style="cursor:pointer; text-align:right;">Requêtes <i class="bi bi-chevron-expand ms-1 sort-icon"></i></th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tableBody">
{% for key in keys %}
<tr data-status="{% if key.is_active %}active{% else %}revoked{% endif %}"
data-search="{{ key.name }} {{ key.key_id }}">
<td>
<span class="key-uuid">{{ key.key_id }}</span>
<button class="btn btn-link btn-sm p-0 ms-1 copy-btn text-secondary" title="Copier"
data-value="{{ key.key_id }}">
<i class="bi bi-clipboard"></i>
</button>
</td>
<td style="font-size:0.875rem;">{{ key.name }}</td>
<td style="font-size:0.8rem;color:var(--bs-secondary-color);">
{{ key.rate_limit }} req / {{ key.rate_window }}s
</td>
<td>
{% if key.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle">Actif</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">Révoqué</span>
{% endif %}
</td>
<td>
<span class="date-abs">{{ key.created_at | fmt_date }}</span>
</td>
<td>
<div class="date-abs">{{ key.last_used_at | fmt_relative }}</div>
{% if key.last_used_at %}
<div class="date-rel">{{ key.last_used_at | fmt_date }}</div>
{% endif %}
</td>
<td style="text-align:right;font-size:0.875rem;font-variant-numeric:tabular-nums;">
{{ "{:,}".format(key.total_queries) }}
</td>
<td>
<div class="d-flex gap-1">
{% if key.is_active %}
<form action="{{ url_for('revoke_peer_key')(request) }}" method="post" class="d-inline">
<input type="hidden" name="key_id" value="{{ key.key_id }}">
<button type="submit" class="btn btn-sm btn-outline-warning" title="Révoquer">
<i class="bi bi-pause-fill"></i>
</button>
</form>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-danger delete-btn" title="Supprimer"
data-key-id="{{ key.key_id }}" data-name="{{ key.name }}">
<i class="bi bi-trash3"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="emptyMsg" class="text-center text-secondary py-4 d-none">
<i class="bi bi-search fs-4 d-block mb-2"></i>Aucune peer key ne correspond au filtre.
</div>
</div>
<!-- Delete confirm modal -->
<div class="modal fade" id="deleteModal" 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">Supprimer la peer key</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2">
Supprimer définitivement la peer key <strong id="deleteKeyName" class="text-info"></strong> ?
<div class="text-secondary mt-1" style="font-size:0.8rem;">Cette action est irréversible.</div>
</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>
<form id="deleteForm" action="{{ url_for('delete_peer_key')(request) }}" method="post" class="d-inline">
<input type="hidden" name="key_id" id="deleteKeyInput">
<button type="submit" class="btn btn-danger btn-sm"><i class="bi bi-trash3 me-1"></i>Supprimer</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
let activeFilter = 'all';
let searchQuery = '';
// ── Copy buttons ──
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText(btn.dataset.value).then(() => {
const icon = btn.querySelector('i');
icon.className = 'bi bi-clipboard-check text-success';
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 1500);
});
});
});
// ── Delete modal ──
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('deleteKeyName').textContent = btn.dataset.name;
document.getElementById('deleteKeyInput').value = btn.dataset.keyId;
deleteModal.show();
});
});
// ── Filter tabs ──
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeFilter = btn.dataset.filter;
applyFilters();
});
});
// ── Search ──
document.getElementById('searchInput').addEventListener('input', function () {
searchQuery = this.value.toLowerCase();
applyFilters();
});
function applyFilters() {
let visible = 0;
document.querySelectorAll('#tableBody tr').forEach(row => {
const matchFilter =
activeFilter === 'all' ||
(activeFilter === 'active' && row.dataset.status === 'active') ||
(activeFilter === 'revoked' && row.dataset.status === 'revoked');
const matchSearch = !searchQuery || row.dataset.search.toLowerCase().includes(searchQuery);
const show = matchFilter && matchSearch;
row.style.display = show ? '' : 'none';
if (show) visible++;
});
document.getElementById('emptyMsg').classList.toggle('d-none', visible > 0);
}
// ── Sort ──
const tbody = document.getElementById('tableBody');
let sortState = { col: null, dir: 'asc' };
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = parseInt(th.dataset.col);
const dir = sortState.col === col && sortState.dir === 'asc' ? 'desc' : 'asc';
sortState = { col, dir };
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const av = a.querySelectorAll('td')[col]?.textContent.trim() || '';
const bv = b.querySelectorAll('td')[col]?.textContent.trim() || '';
const isNum = col === 6;
const cmp = isNum ? (+av.replace(/,/g,'')) - (+bv.replace(/,/g,'')) : av.localeCompare(bv);
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(r => tbody.appendChild(r));
document.querySelectorAll('th.sortable .sort-icon').forEach(i => { i.className = 'bi bi-chevron-expand ms-1 sort-icon'; });
th.querySelector('.sort-icon').className = `bi bi-chevron-${dir === 'asc' ? 'up' : 'down'} ms-1 sort-icon`;
});
});
</script>
{% endblock %}