846 lines
26 KiB
TypeScript
846 lines
26 KiB
TypeScript
// frontend\src\components\ui\FinishedReportModal.tsx
|
||
|
||
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||
import {
|
||
ArchiveBoxIcon,
|
||
ArrowPathIcon,
|
||
CalendarDaysIcon,
|
||
ChartBarIcon,
|
||
ClockIcon,
|
||
FireIcon,
|
||
StarIcon,
|
||
} from '@heroicons/react/24/outline'
|
||
|
||
import type {
|
||
FinishedReportDailyItem,
|
||
FinishedReportMode,
|
||
FinishedReportStats,
|
||
FinishedReportTopItem,
|
||
RecordJob,
|
||
} from '../../types'
|
||
import Modal from './Modal'
|
||
import Button from './Button'
|
||
import ButtonGroup from './ButtonGroup'
|
||
import LoadingSpinner from './LoadingSpinner'
|
||
import { autoTagsForJob, ratingStarsForJob } from './autoTags'
|
||
import { SegmentLabelIcon } from './Icons'
|
||
|
||
const REPORT_PAGE_SIZE = 1000
|
||
|
||
const nf = new Intl.NumberFormat('de-DE')
|
||
|
||
function fmtInt(value: number | undefined | null) {
|
||
if (value == null || !Number.isFinite(value)) return '—'
|
||
return nf.format(value)
|
||
}
|
||
|
||
function fmtBytes(value: number | undefined | null) {
|
||
if (value == null || !Number.isFinite(value)) return '—'
|
||
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
let v = value
|
||
let i = 0
|
||
|
||
while (v >= 1024 && i < units.length - 1) {
|
||
v /= 1024
|
||
i++
|
||
}
|
||
|
||
const digits = i <= 1 ? 0 : 1
|
||
return `${v.toFixed(digits)} ${units[i]}`
|
||
}
|
||
|
||
function fmtDuration(totalSeconds: number | undefined | null) {
|
||
if (totalSeconds == null || !Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
||
return '—'
|
||
}
|
||
|
||
const s = Math.floor(totalSeconds)
|
||
const h = Math.floor(s / 3600)
|
||
const m = Math.floor((s % 3600) / 60)
|
||
const sec = s % 60
|
||
|
||
if (h > 0) {
|
||
return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||
}
|
||
|
||
return `${m}:${String(sec).padStart(2, '0')}`
|
||
}
|
||
|
||
function parseMeta(metaRaw: unknown): Record<string, any> | null {
|
||
if (!metaRaw) return null
|
||
|
||
if (typeof metaRaw === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(metaRaw)
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
? parsed as Record<string, any>
|
||
: null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
return typeof metaRaw === 'object' && !Array.isArray(metaRaw)
|
||
? metaRaw as Record<string, any>
|
||
: null
|
||
}
|
||
|
||
function readNumberLike(...values: unknown[]): number | null {
|
||
for (const value of values) {
|
||
const n = Number(value)
|
||
if (Number.isFinite(n) && n > 0) return n
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function readJobDurationSeconds(job: RecordJob): number | null {
|
||
const meta = parseMeta((job as any)?.meta)
|
||
|
||
return readNumberLike(
|
||
(job as any)?.durationSeconds,
|
||
(job as any)?.durationSec,
|
||
meta?.media?.durationSeconds,
|
||
meta?.media?.durationSec,
|
||
meta?.file?.durationSeconds,
|
||
meta?.file?.durationSec,
|
||
meta?.durationSeconds,
|
||
meta?.durationSec
|
||
)
|
||
}
|
||
|
||
function readJobSizeBytes(job: RecordJob): number | null {
|
||
const meta = parseMeta((job as any)?.meta)
|
||
|
||
return readNumberLike(
|
||
(job as any)?.sizeBytes,
|
||
(job as any)?.fileSizeBytes,
|
||
(job as any)?.bytes,
|
||
(job as any)?.size,
|
||
meta?.file?.sizeBytes,
|
||
meta?.file?.bytes,
|
||
meta?.sizeBytes,
|
||
meta?.bytes,
|
||
meta?.size
|
||
)
|
||
}
|
||
|
||
function baseName(path: string) {
|
||
return String(path || '').split(/[\\/]/).pop() || ''
|
||
}
|
||
|
||
function stripHotPrefix(name: string) {
|
||
return String(name || '').startsWith('HOT ')
|
||
? String(name || '').slice(4)
|
||
: String(name || '')
|
||
}
|
||
|
||
function isHotOutput(output: string) {
|
||
return baseName(output).startsWith('HOT ')
|
||
}
|
||
|
||
function isKeepOutput(output: string) {
|
||
return String(output || '').replaceAll('\\', '/').includes('/keep/')
|
||
}
|
||
|
||
function modelNameFromOutput(output?: string) {
|
||
const fileRaw = baseName(output || '')
|
||
const file = stripHotPrefix(fileRaw)
|
||
if (!file) return '—'
|
||
|
||
const stem = file.replace(/\.[^.]+$/, '')
|
||
|
||
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
||
if (m?.[1]) return m[1]
|
||
|
||
const i = stem.lastIndexOf('_')
|
||
return i > 0 ? stem.slice(0, i) : stem
|
||
}
|
||
|
||
function readJobDate(job: RecordJob): Date | null {
|
||
const raw =
|
||
(job as any)?.endedAt ??
|
||
(job as any)?.completedAt ??
|
||
(job as any)?.startedAt ??
|
||
null
|
||
|
||
if (!raw) return null
|
||
|
||
const d = new Date(raw)
|
||
return Number.isFinite(d.getTime()) ? d : null
|
||
}
|
||
|
||
function startOfDay(value: Date) {
|
||
const d = new Date(value)
|
||
d.setHours(0, 0, 0, 0)
|
||
return d
|
||
}
|
||
|
||
function addDays(value: Date, days: number) {
|
||
const d = new Date(value)
|
||
d.setDate(d.getDate() + days)
|
||
return d
|
||
}
|
||
|
||
function startOfWeekMonday(value: Date) {
|
||
const d = startOfDay(value)
|
||
const day = d.getDay()
|
||
const diff = day === 0 ? -6 : 1 - day
|
||
d.setDate(d.getDate() + diff)
|
||
return d
|
||
}
|
||
|
||
function reportPeriod(mode: FinishedReportMode, now = new Date()) {
|
||
if (mode === 'week') {
|
||
const start = startOfWeekMonday(now)
|
||
return {
|
||
start,
|
||
end: addDays(start, 7),
|
||
}
|
||
}
|
||
|
||
const start = startOfDay(now)
|
||
return {
|
||
start,
|
||
end: addDays(start, 1),
|
||
}
|
||
}
|
||
|
||
function dateKey(value: Date) {
|
||
return [
|
||
value.getFullYear(),
|
||
String(value.getMonth() + 1).padStart(2, '0'),
|
||
String(value.getDate()).padStart(2, '0'),
|
||
].join('-')
|
||
}
|
||
|
||
function shortDateLabel(value: Date) {
|
||
return value.toLocaleDateString('de-DE', {
|
||
weekday: 'short',
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
})
|
||
}
|
||
|
||
function longDateLabel(value: Date) {
|
||
return value.toLocaleDateString('de-DE', {
|
||
weekday: 'long',
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})
|
||
}
|
||
|
||
function rangeLabel(start: Date, end: Date) {
|
||
const last = addDays(end, -1)
|
||
|
||
if (dateKey(start) === dateKey(last)) {
|
||
return longDateLabel(start)
|
||
}
|
||
|
||
return `${start.toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})} – ${last.toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})}`
|
||
}
|
||
|
||
function incrementMap(map: Map<string, FinishedReportTopItem>, label: string, iconLabel?: string) {
|
||
const clean = String(label || '').trim()
|
||
if (!clean) return
|
||
|
||
const key = clean.toLowerCase()
|
||
const current = map.get(key)
|
||
|
||
if (current) {
|
||
current.count += 1
|
||
return
|
||
}
|
||
|
||
map.set(key, {
|
||
label: clean,
|
||
iconLabel,
|
||
count: 1,
|
||
})
|
||
}
|
||
|
||
function topItems(map: Map<string, FinishedReportTopItem>, limit = 8) {
|
||
return Array.from(map.values())
|
||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'de'))
|
||
.slice(0, limit)
|
||
}
|
||
|
||
function buildReportStats(rows: RecordJob[], mode: FinishedReportMode): FinishedReportStats {
|
||
const { start, end } = reportPeriod(mode)
|
||
|
||
const modelCounts = new Map<string, FinishedReportTopItem>()
|
||
const tagCounts = new Map<string, FinishedReportTopItem>()
|
||
|
||
const dailyMap = new Map<string, FinishedReportDailyItem>()
|
||
for (let d = new Date(start); d < end; d = addDays(d, 1)) {
|
||
const key = dateKey(d)
|
||
dailyMap.set(key, {
|
||
key,
|
||
label: shortDateLabel(d),
|
||
count: 0,
|
||
durationSeconds: 0,
|
||
sizeBytes: 0,
|
||
})
|
||
}
|
||
|
||
const ratingCounts: Record<1 | 2 | 3 | 4 | 5, number> = {
|
||
1: 0,
|
||
2: 0,
|
||
3: 0,
|
||
4: 0,
|
||
5: 0,
|
||
}
|
||
|
||
let totalCount = 0
|
||
let totalDurationSeconds = 0
|
||
let totalSizeBytes = 0
|
||
let knownSizeCount = 0
|
||
let hotCount = 0
|
||
let keepCount = 0
|
||
let ratedCount = 0
|
||
|
||
for (const job of rows) {
|
||
const d = readJobDate(job)
|
||
if (!d || d < start || d >= end) continue
|
||
|
||
totalCount++
|
||
|
||
const output = String((job as any)?.output ?? '')
|
||
if (isHotOutput(output)) hotCount++
|
||
if (isKeepOutput(output)) keepCount++
|
||
|
||
const duration = readJobDurationSeconds(job)
|
||
if (duration != null) {
|
||
totalDurationSeconds += duration
|
||
}
|
||
|
||
const sizeBytes = readJobSizeBytes(job)
|
||
if (sizeBytes != null) {
|
||
totalSizeBytes += sizeBytes
|
||
knownSizeCount++
|
||
}
|
||
|
||
const bucket = dailyMap.get(dateKey(d))
|
||
if (bucket) {
|
||
bucket.count += 1
|
||
bucket.durationSeconds += duration ?? 0
|
||
bucket.sizeBytes += sizeBytes ?? 0
|
||
}
|
||
|
||
const model = modelNameFromOutput(output)
|
||
if (model && model !== '—') {
|
||
incrementMap(modelCounts, model)
|
||
}
|
||
|
||
for (const tag of autoTagsForJob(job)) {
|
||
incrementMap(tagCounts, tag, tag)
|
||
}
|
||
|
||
const stars = ratingStarsForJob(job)
|
||
if (stars != null) {
|
||
ratedCount++
|
||
ratingCounts[stars as 1 | 2 | 3 | 4 | 5] += 1
|
||
}
|
||
}
|
||
|
||
return {
|
||
mode,
|
||
start,
|
||
end,
|
||
totalCount,
|
||
totalDurationSeconds,
|
||
totalSizeBytes,
|
||
knownSizeCount,
|
||
hotCount,
|
||
keepCount,
|
||
ratedCount,
|
||
ratingCounts,
|
||
topModels: topItems(modelCounts, 8),
|
||
topTags: topItems(tagCounts, 10),
|
||
daily: Array.from(dailyMap.values()),
|
||
}
|
||
}
|
||
|
||
function ReportMetric({
|
||
label,
|
||
value,
|
||
sub,
|
||
icon,
|
||
tone = 'slate',
|
||
}: {
|
||
label: string
|
||
value: ReactNode
|
||
sub?: ReactNode
|
||
icon: ReactNode
|
||
tone?: 'slate' | 'sky' | 'amber' | 'emerald' | 'rose'
|
||
}) {
|
||
const toneCls =
|
||
tone === 'sky'
|
||
? 'border-sky-200 bg-sky-50/80 text-sky-700 dark:border-sky-400/20 dark:bg-sky-400/10 dark:text-sky-200'
|
||
: tone === 'amber'
|
||
? 'border-amber-200 bg-amber-50/80 text-amber-700 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200'
|
||
: tone === 'emerald'
|
||
? 'border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-200'
|
||
: tone === 'rose'
|
||
? 'border-rose-200 bg-rose-50/80 text-rose-700 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200'
|
||
: 'border-gray-200 bg-gray-50/90 text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300'
|
||
|
||
return (
|
||
<div className={['rounded-2xl border p-3 shadow-sm', toneCls].join(' ')}>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-[10px] font-black uppercase tracking-wide opacity-80">
|
||
{label}
|
||
</div>
|
||
|
||
<div className="mt-1 min-w-0 text-2xl font-black leading-tight text-gray-950 dark:text-white">
|
||
{value}
|
||
</div>
|
||
|
||
{sub ? (
|
||
<div className="mt-1 truncate text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||
{sub}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/70 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
|
||
{icon}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ReportBarList({
|
||
title,
|
||
items,
|
||
empty,
|
||
icons = false,
|
||
}: {
|
||
title: string
|
||
items: FinishedReportTopItem[]
|
||
empty: string
|
||
icons?: boolean
|
||
}) {
|
||
const max = Math.max(1, items[0]?.count ?? 1)
|
||
const total = items.reduce((sum, item) => sum + item.count, 0)
|
||
|
||
return (
|
||
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||
<div>
|
||
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||
{title}
|
||
</div>
|
||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||
{items.length > 0 ? `${fmtInt(total)} Treffer gesamt` : 'Keine Daten'}
|
||
</div>
|
||
</div>
|
||
|
||
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-bold text-gray-600 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
|
||
Top {fmtInt(items.length)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="p-3">
|
||
{items.length === 0 ? (
|
||
<div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
||
{empty}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{items.map((item, index) => {
|
||
const width = `${Math.max(8, Math.round((item.count / max) * 100))}%`
|
||
const share = total > 0 ? Math.round((item.count / total) * 100) : 0
|
||
|
||
return (
|
||
<div
|
||
key={item.label}
|
||
className="rounded-2xl bg-gray-50 p-2.5 ring-1 ring-black/5 transition hover:bg-gray-100/80 dark:bg-white/5 dark:ring-white/10 dark:hover:bg-white/10"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white text-[11px] font-black text-gray-500 ring-1 ring-gray-200 dark:bg-gray-950/40 dark:text-gray-400 dark:ring-white/10">
|
||
#{index + 1}
|
||
</span>
|
||
|
||
{icons ? (
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||
<SegmentLabelIcon
|
||
label={item.iconLabel ?? item.label}
|
||
className="h-4.5 w-4.5"
|
||
/>
|
||
</span>
|
||
) : null}
|
||
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-bold text-gray-900 dark:text-white">
|
||
{item.label}
|
||
</div>
|
||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||
{share}% Anteil
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<span className="shrink-0 rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-black text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||
{fmtInt(item.count)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||
<div
|
||
className="h-full rounded-full bg-indigo-500 dark:bg-indigo-400"
|
||
style={{ width }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function DailyBars({ items }: { items: FinishedReportDailyItem[] }) {
|
||
const max = Math.max(1, ...items.map((item) => item.count))
|
||
const total = items.reduce((sum, item) => sum + item.count, 0)
|
||
|
||
return (
|
||
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||
<div>
|
||
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||
Verlauf
|
||
</div>
|
||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||
Downloads pro Tag
|
||
</div>
|
||
</div>
|
||
|
||
<ChartBarIcon className="h-5 w-5 text-sky-500 dark:text-sky-300" />
|
||
</div>
|
||
|
||
<div className="p-3">
|
||
<div className="space-y-2.5">
|
||
{items.map((item) => {
|
||
const width = `${Math.max(item.count > 0 ? 8 : 0, Math.round((item.count / max) * 100))}%`
|
||
const share = total > 0 ? Math.round((item.count / total) * 100) : 0
|
||
|
||
return (
|
||
<div key={item.key}>
|
||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||
<span className="font-semibold text-gray-700 dark:text-gray-200">
|
||
{item.label}
|
||
</span>
|
||
<span className="text-gray-500 dark:text-gray-400">
|
||
{fmtInt(item.count)} · {fmtDuration(item.durationSeconds)} · {share}%
|
||
</span>
|
||
</div>
|
||
|
||
<div className="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||
<div
|
||
className="h-full rounded-full bg-sky-500 dark:bg-sky-400"
|
||
style={{ width }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function RatingDistribution({ stats }: { stats: FinishedReportStats }) {
|
||
const max = Math.max(1, ...([1, 2, 3, 4, 5] as const).map((n) => stats.ratingCounts[n]))
|
||
|
||
return (
|
||
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||
<div>
|
||
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||
Bewertung
|
||
</div>
|
||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||
{fmtInt(stats.ratedCount)} bewertete Downloads
|
||
</div>
|
||
</div>
|
||
|
||
<StarIcon className="h-5 w-5 text-amber-500 dark:text-amber-300" />
|
||
</div>
|
||
|
||
<div className="p-3">
|
||
<div className="space-y-2.5">
|
||
{([5, 4, 3, 2, 1] as const).map((stars) => {
|
||
const count = stats.ratingCounts[stars]
|
||
const width = `${Math.max(count > 0 ? 8 : 0, Math.round((count / max) * 100))}%`
|
||
|
||
return (
|
||
<div key={stars}>
|
||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||
<span className="font-semibold text-gray-700 dark:text-gray-200">
|
||
{'★'.repeat(stars)}
|
||
<span className="ml-1 text-gray-400">
|
||
{'★'.repeat(5 - stars)}
|
||
</span>
|
||
</span>
|
||
<span className="text-gray-500 dark:text-gray-400">
|
||
{fmtInt(count)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||
<div
|
||
className="h-full rounded-full bg-amber-400 dark:bg-amber-300"
|
||
style={{ width }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default function FinishedReportModal({
|
||
open,
|
||
onClose,
|
||
}: {
|
||
open: boolean
|
||
onClose: () => void
|
||
}) {
|
||
const [mode, setMode] = useState<FinishedReportMode>('day')
|
||
const [rows, setRows] = useState<RecordJob[]>([])
|
||
const [availableCount, setAvailableCount] = useState(0)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const loadReportRows = useCallback(async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
|
||
try {
|
||
const url =
|
||
`/api/record/done?page=1&pageSize=${REPORT_PAGE_SIZE}` +
|
||
`&sort=completed_desc&includeKeep=1&withCount=1`
|
||
|
||
const r = await fetch(url, { cache: 'no-store' })
|
||
const data = await r.json().catch(() => null)
|
||
|
||
if (!r.ok) {
|
||
throw new Error(String(data?.error || data?.message || `HTTP ${r.status}`))
|
||
}
|
||
|
||
const items = Array.isArray(data?.items) ? data.items as RecordJob[] : []
|
||
const count = Number(data?.count ?? items.length)
|
||
|
||
setRows(items)
|
||
setAvailableCount(Number.isFinite(count) ? count : items.length)
|
||
} catch (err: any) {
|
||
setError(err?.message || 'Report konnte nicht geladen werden.')
|
||
setRows([])
|
||
setAvailableCount(0)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
void loadReportRows()
|
||
}, [open, loadReportRows])
|
||
|
||
const stats = useMemo(() => {
|
||
return buildReportStats(rows, mode)
|
||
}, [rows, mode])
|
||
|
||
const partialHint =
|
||
availableCount > rows.length
|
||
? `Es werden die neuesten ${fmtInt(rows.length)} von ${fmtInt(availableCount)} Downloads berücksichtigt.`
|
||
: `${fmtInt(rows.length)} Downloads geladen.`
|
||
|
||
const avgDuration =
|
||
stats.totalCount > 0 ? stats.totalDurationSeconds / stats.totalCount : 0
|
||
|
||
const avgSize =
|
||
stats.knownSizeCount > 0 ? stats.totalSizeBytes / stats.knownSizeCount : 0
|
||
|
||
const reportModeSwitch = (
|
||
<ButtonGroup
|
||
value={mode}
|
||
onChange={(id) => setMode(id as FinishedReportMode)}
|
||
size="md"
|
||
ariaLabel="Report-Zeitraum"
|
||
items={[
|
||
{
|
||
id: 'day',
|
||
label: 'Heute',
|
||
icon: <CalendarDaysIcon className="size-4" />,
|
||
},
|
||
{
|
||
id: 'week',
|
||
label: 'Diese Woche',
|
||
icon: <ChartBarIcon className="size-4" />,
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
|
||
const refreshButton = (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
rounded="md"
|
||
variant="secondary"
|
||
color="indigo"
|
||
onClick={() => void loadReportRows()}
|
||
isLoading={loading}
|
||
leadingIcon={<ArrowPathIcon className="h-4 w-4" />}
|
||
>
|
||
Aktualisieren
|
||
</Button>
|
||
)
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={onClose}
|
||
title="Tages-/Wochenreport"
|
||
width="max-w-6xl"
|
||
scroll="body"
|
||
rightClassName="bg-gray-50/60 dark:bg-gray-950/20"
|
||
titleRight={
|
||
<div className="flex items-center gap-2">
|
||
{reportModeSwitch}
|
||
{refreshButton}
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-4 p-4 sm:p-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 sm:hidden">
|
||
{reportModeSwitch}
|
||
{refreshButton}
|
||
</div>
|
||
<section className="overflow-hidden rounded-3xl border border-gray-200/70 bg-gradient-to-br from-white via-sky-50/70 to-indigo-50/70 shadow-sm dark:border-white/10 dark:from-white/[0.06] dark:via-sky-400/10 dark:to-indigo-400/10">
|
||
<div className="p-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="inline-flex items-center gap-2 rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-white/10 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||
<CalendarDaysIcon className="h-4 w-4" />
|
||
{mode === 'day' ? 'Tagesreport' : 'Wochenreport'}
|
||
</div>
|
||
|
||
<h2 className="mt-3 text-2xl font-black tracking-tight text-gray-950 dark:text-white">
|
||
{mode === 'day' ? 'Heute' : 'Diese Woche'}
|
||
</h2>
|
||
|
||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||
{rangeLabel(stats.start, stats.end)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||
{loading ? (
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||
<LoadingSpinner size="sm" />
|
||
Lädt…
|
||
</span>
|
||
) : (
|
||
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||
{partialHint}
|
||
</span>
|
||
)}
|
||
|
||
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||
Ø Dauer: {fmtDuration(avgDuration)}
|
||
</span>
|
||
|
||
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||
Ø Größe: {avgSize > 0 ? fmtBytes(avgSize) : '—'}
|
||
</span>
|
||
</div>
|
||
|
||
{error ? (
|
||
<div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-400/25">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-4 grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||
<ReportMetric
|
||
label="Downloads"
|
||
value={fmtInt(stats.totalCount)}
|
||
sub="fertiggestellt"
|
||
icon={<CalendarDaysIcon className="h-5 w-5" />}
|
||
tone="sky"
|
||
/>
|
||
|
||
<ReportMetric
|
||
label="Laufzeit"
|
||
value={fmtDuration(stats.totalDurationSeconds)}
|
||
sub="Videodauer"
|
||
icon={<ClockIcon className="h-5 w-5" />}
|
||
/>
|
||
|
||
<ReportMetric
|
||
label="Speicher"
|
||
value={stats.knownSizeCount > 0 ? fmtBytes(stats.totalSizeBytes) : '—'}
|
||
sub={stats.knownSizeCount > 0 ? `${fmtInt(stats.knownSizeCount)} Dateien` : 'keine Größen'}
|
||
icon={<ArchiveBoxIcon className="h-5 w-5" />}
|
||
tone="emerald"
|
||
/>
|
||
|
||
<ReportMetric
|
||
label="Markiert"
|
||
value={`${fmtInt(stats.hotCount)} / ${fmtInt(stats.keepCount)}`}
|
||
sub="HOT / Keep"
|
||
icon={<FireIcon className="h-5 w-5" />}
|
||
tone="amber"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
||
<DailyBars items={stats.daily} />
|
||
<RatingDistribution stats={stats} />
|
||
</div>
|
||
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<ReportBarList
|
||
title="Top Models"
|
||
items={stats.topModels}
|
||
empty="In diesem Zeitraum wurden keine Models gefunden."
|
||
/>
|
||
|
||
<ReportBarList
|
||
title="Top Inhalte"
|
||
items={stats.topTags}
|
||
empty="In diesem Zeitraum wurden keine Auto-Tags gefunden."
|
||
icons
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
} |