feat(ui): états catalogue + logs + badges erreurs downloads
- catalog: badge ORGANISÉ/PARTIEL/EN COURS sur les affiches (download_state) - catalog: filtres Téléchargés et En cours - catalog: icône logs dans le header - catalog: polling notifications organisation (toast auto) - api: /api/series enrichi avec download_state par série - api: /api/logs + /api/logs/clear avec filtre level/source/limit - api: /api/downloads enrichi avec errorFiles depuis recentOrganized - logger: module centralisé logs.jsonl, rotation 2000 lignes, debug masqué en prod - downloads: badge erreurs avec tooltip fichiers en erreur au hover - downloads: badge organisation NON ORGANISÉ/EN COURS/ORGANISÉ avec compteur - logs: page /logs avec table, filtres niveau/source, choix 100/500/tout, taille fichier, clear - organize: callback onResult expose errorFiles - organize-worker: logs candidats absents passés en debug
This commit is contained in:
1 parent
7f3f8656b3
commit
b88e020fb5
7 files changed
+477
-24
No files matched your search
@@ -25,6 +25,10 @@ const router = createRouter({
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
component: () => import('@/views/LogsView.vue')
|
||||
}
|
||||
],
|
||||
scrollBehavior(to, _from, savedPosition) {
|
||||
|
||||
@@ -8,7 +8,18 @@
|
||||
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Icône downloads avec badge -->
|
||||
<!-- Icône logs -->
|
||||
<router-link to="/logs"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
</router-link>
|
||||
<router-link to="/downloads"
|
||||
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
>
|
||||
@@ -126,6 +137,27 @@
|
||||
{{ serie.torrent_count }}
|
||||
</div>
|
||||
|
||||
<!-- Badge état téléchargement -->
|
||||
<div v-if="serie.download_state !== 'none'"
|
||||
class="absolute bottom-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold leading-none backdrop-blur-sm"
|
||||
:class="serie.download_state === 'complete' ? 'bg-green-500/90 text-white' :
|
||||
serie.download_state === 'partial' ? 'bg-orange-500/90 text-white' :
|
||||
serie.download_state === 'downloading' ? 'bg-blue-500/90 text-white' : ''">
|
||||
<!-- Icône -->
|
||||
<svg v-if="serie.download_state === 'complete'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<svg v-else-if="serie.download_state === 'downloading'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" class="animate-pulse">
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<svg v-else width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{{ serie.download_state === 'complete' ? 'ORGANISÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
|
||||
</div>
|
||||
|
||||
<!-- Overlay hover -->
|
||||
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
|
||||
@@ -170,15 +202,19 @@ const activeFilter = ref('all')
|
||||
const activeDownloads = ref(0)
|
||||
|
||||
const filters = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Téléchargés', value: 'complete' },
|
||||
{ label: 'En cours', value: 'downloading' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = store.series
|
||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||
@@ -188,7 +224,7 @@ const filtered = computed(() => {
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await router.push('/auth')
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
// ── Polling downloads actifs + notifications organisation ──────
|
||||
|
||||
@@ -105,7 +105,25 @@
|
||||
|
||||
<!-- Badge organisation -->
|
||||
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
||||
<!-- Badge statut -->
|
||||
|
||||
<!-- Badge erreurs avec tooltip -->
|
||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
|
||||
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
|
||||
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
|
||||
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
|
||||
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-white truncate">{{ e.file }}</p>
|
||||
<p class="text-[#5a7a94]">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge statut organisation -->
|
||||
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
@@ -114,6 +132,7 @@
|
||||
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
'NON ORGANISÉ' }}
|
||||
</span>
|
||||
|
||||
<!-- Bouton organiser si pas encore fait -->
|
||||
<button v-if="t.organizeState !== 'done'"
|
||||
@click="organize(t)"
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-8 py-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
← CATALOGUE
|
||||
</button>
|
||||
<h1 class="text-xl font-black text-white font-sans">Logs</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ formatSize(fileSize) }}</span>
|
||||
<button @click="confirmClear"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-red-500 hover:text-red-500 transition-colors cursor-pointer">
|
||||
EFFACER
|
||||
</button>
|
||||
<button @click="load"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
↺ RAFRAÎCHIR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
|
||||
<!-- Niveau -->
|
||||
<div class="flex gap-1">
|
||||
<button v-for="l in levels" :key="l.value" @click="filterLevel = l.value; load()"
|
||||
class="px-3 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
|
||||
:class="filterLevel === l.value
|
||||
? 'border-[#e8513a] text-[#e8513a]'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
{{ l.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<select v-model="filterSource" @change="load()"
|
||||
class="bg-[#0d1219] border border-[#1e2d3d] text-[#5a7a94] px-3 py-1 text-[10px] tracking-widest outline-none cursor-pointer">
|
||||
<option value="">Toutes les sources</option>
|
||||
<option value="organize">organize</option>
|
||||
<option value="api">api</option>
|
||||
<option value="auth">auth</option>
|
||||
</select>
|
||||
|
||||
<!-- Limite / tout afficher -->
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">AFFICHER</span>
|
||||
<button v-for="n in [100, 500, 2000]" :key="n" @click="limit = n; load()"
|
||||
class="px-2 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
|
||||
:class="limit === n
|
||||
? 'border-[#e8513a] text-[#e8513a]'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
{{ n === 2000 ? 'TOUT' : n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-3 min-h-48 text-[#5a7a94]">
|
||||
<div class="w-4 h-4 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
|
||||
<span class="text-sm">Chargement...</span>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="entries.length === 0" class="flex items-center justify-center min-h-48 text-[#5a7a94] text-sm">
|
||||
Aucun log
|
||||
</div>
|
||||
|
||||
<!-- Table logs -->
|
||||
<div v-else class="bg-[#0d1219] border border-[#1e2d3d] overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr class="border-b border-[#1e2d3d]">
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-4 py-2.5 w-40">HORODATAGE</th>
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-16">NIVEAU</th>
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-24">SOURCE</th>
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5">MESSAGE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, i) in entries" :key="i"
|
||||
class="border-b border-[#1e2d3d]/50 hover:bg-white/[0.02] transition-colors"
|
||||
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''">
|
||||
<td class="px-4 py-2 text-[#5a7a94] whitespace-nowrap">{{ formatDate(entry.at) }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="text-[10px] tracking-widest px-1.5 py-0.5 rounded"
|
||||
:class="levelClass(entry.level)">
|
||||
{{ entry.level.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-[#5a7a94] whitespace-nowrap">{{ entry.source }}</td>
|
||||
<td class="px-3 py-2 text-[#c8d8e8] break-all">
|
||||
{{ entry.msg }}
|
||||
<span v-if="entry.meta" class="text-[#5a7a94] ml-2">
|
||||
{{ JSON.stringify(entry.meta) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer count -->
|
||||
<div class="px-4 py-2.5 border-t border-[#1e2d3d] flex items-center justify-between">
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ entries.length }} entrées affichées</span>
|
||||
<span class="text-[10px] text-[#5a7a94]">{{ formatSize(fileSize) }} sur disque</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm clear -->
|
||||
<div v-if="showConfirm" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 max-w-sm w-full">
|
||||
<h3 class="text-sm font-bold text-white mb-2">Effacer les logs ?</h3>
|
||||
<p class="text-[11px] text-[#5a7a94] mb-6">Cette action est irréversible.</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="doClear"
|
||||
class="bg-red-500 text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
|
||||
EFFACER
|
||||
</button>
|
||||
<button @click="showConfirm = false"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-5 py-2 text-xs tracking-widest hover:border-white/30 transition-colors cursor-pointer">
|
||||
ANNULER
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const entries = ref<any[]>([])
|
||||
const fileSize = ref(0)
|
||||
const loading = ref(true)
|
||||
const showConfirm = ref(false)
|
||||
const filterLevel = ref('all')
|
||||
const filterSource = ref('')
|
||||
const limit = ref(100)
|
||||
|
||||
const levels = [
|
||||
{ label: 'TOUS', value: 'all' },
|
||||
{ label: 'INFO', value: 'info' },
|
||||
{ label: 'WARN', value: 'warn' },
|
||||
{ label: 'ERROR', value: 'error' },
|
||||
{ label: 'DEBUG', value: 'debug' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit : String(limit.value),
|
||||
level : filterLevel.value,
|
||||
...(filterSource.value ? { source: filterSource.value } : {})
|
||||
})
|
||||
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
entries.value = data.entries
|
||||
fileSize.value = data.size
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClear() { showConfirm.value = true }
|
||||
|
||||
async function doClear() {
|
||||
showConfirm.value = false
|
||||
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
|
||||
toast('Logs effacés', 'success')
|
||||
await load()
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
|
||||
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function levelClass(level: string): string {
|
||||
return {
|
||||
debug: 'bg-[#1e2d3d] text-[#5a7a94]',
|
||||
info : 'bg-blue-500/10 text-blue-400',
|
||||
warn : 'bg-yellow-500/10 text-yellow-400',
|
||||
error: 'bg-red-500/10 text-red-400',
|
||||
}[level] ?? 'bg-[#1e2d3d] text-[#5a7a94]'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in new issue
Block a user