Files
ContentGen_FE/src/app/[locale]/(dashboard)/dashboard/render-queue/page.tsx
T
Harun CAN 89eb9d4dfd
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
main
2026-04-27 12:50:54 +02:00

482 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { motion } from 'framer-motion';
import {
Loader2,
Clock,
CheckCircle2,
AlertCircle,
XCircle,
Play,
Pause,
Timer,
Zap,
BarChart3,
Film,
ArrowRight,
RefreshCw,
X,
} from 'lucide-react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useRenderQueue, useCancelRender } from '@/hooks/use-api';
// ── Render Pipeline Aşamaları ──
const RENDER_STAGES = [
{ key: 'VIDEO_GENERATION', label: 'Video Üretimi', icon: Film, color: 'violet' },
{ key: 'TTS_GENERATION', label: 'Seslendirme', icon: Zap, color: 'cyan' },
{ key: 'MUSIC_GENERATION', label: 'Müzik', icon: Zap, color: 'amber' },
{ key: 'AMBIENT_GENERATION', label: 'Ortam Sesi', icon: Zap, color: 'emerald' },
{ key: 'MEDIA_MERGE', label: 'Birleştirme', icon: Zap, color: 'blue' },
{ key: 'SUBTITLE_OVERLAY', label: 'Altyazı', icon: Zap, color: 'pink' },
{ key: 'FINALIZATION', label: 'Son İşlem', icon: Zap, color: 'indigo' },
{ key: 'UPLOAD', label: 'Yükleme', icon: CheckCircle2, color: 'emerald' },
];
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: React.ElementType }> = {
QUEUED: { label: 'Kuyrukta', color: 'text-amber-400', bg: 'bg-amber-500/12 border-amber-500/25', icon: Clock },
PROCESSING: { label: 'İşleniyor', color: 'text-cyan-400', bg: 'bg-cyan-500/12 border-cyan-500/25', icon: Play },
COMPLETED: { label: 'Tamamlandı', color: 'text-emerald-400', bg: 'bg-emerald-500/12 border-emerald-500/25', icon: CheckCircle2 },
FAILED: { label: 'Başarısız', color: 'text-red-400', bg: 'bg-red-500/12 border-red-500/25', icon: AlertCircle },
CANCELLED: { label: 'İptal Edildi', color: 'text-slate-400', bg: 'bg-slate-500/12 border-slate-500/25', icon: XCircle },
};
const stagger = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
};
const fadeUp = {
hidden: { opacity: 0, y: 14 },
show: { opacity: 1, y: 0, transition: { duration: 0.45, ease: [0.16, 1, 0.3, 1] as const } },
};
function formatDuration(ms: number | null | undefined): string {
if (!ms) return '—';
const sec = ms / 1000;
if (sec < 60) return `${sec.toFixed(1)}s`;
const min = Math.floor(sec / 60);
const remainSec = Math.round(sec % 60);
return `${min}dk ${remainSec}s`;
}
function timeAgo(date: string): string {
if (!date) return '';
const diff = Date.now() - new Date(date).getTime();
if (isNaN(diff)) return '';
const sec = Math.floor(diff / 1000);
const min = Math.floor(sec / 60);
if (min < 1) return 'az önce';
if (min < 60) return `${min}dk önce`;
const hours = Math.floor(min / 60);
if (hours < 24) return `${hours}sa önce`;
const days = Math.floor(hours / 24);
return `${days}gün önce`;
}
function getStageIndex(stage: string | null | undefined): number {
if (!stage) return -1;
return RENDER_STAGES.findIndex((s) => s.key === stage);
}
// ── Stats Card ──
function StatCard({ label, value, icon: Icon, color, sub }: {
label: string;
value: string | number;
icon: React.ElementType;
color: string;
sub?: string;
}) {
return (
<div className="card-surface p-4 md:p-5 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<div className={`w-9 h-9 rounded-xl bg-${color}-500/12 flex items-center justify-center`}>
<Icon size={18} className={`text-${color}-400`} />
</div>
</div>
<div className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold text-[var(--color-text-primary)]">
{value}
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-[var(--color-text-muted)]">{label}</span>
{sub && <span className="text-[10px] text-[var(--color-text-ghost)]">{sub}</span>}
</div>
</div>
);
}
// ── Stage Stepper ──
function StageStepper({ currentStage, status }: { currentStage?: string | null; status: string }) {
const activeIdx = getStageIndex(currentStage);
return (
<div className="flex items-center gap-0.5 overflow-x-auto py-1 scrollbar-hide">
{RENDER_STAGES.map((stage, idx) => {
const isCompleted = idx < activeIdx;
const isCurrent = idx === activeIdx;
const isPending = idx > activeIdx;
return (
<div key={stage.key} className="flex items-center min-w-0">
<div className="flex flex-col items-center gap-1">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[9px] font-bold transition-all ${
isCompleted
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40'
: isCurrent
? 'bg-cyan-500/20 text-cyan-300 border-2 border-cyan-400/60 animate-pulse shadow-[0_0_12px_rgba(34,211,238,0.25)]'
: 'bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] border border-[var(--color-border-faint)]'
}`}
>
{isCompleted ? '✓' : idx + 1}
</div>
<span
className={`text-[8px] whitespace-nowrap font-medium ${
isCurrent ? 'text-cyan-400' : isCompleted ? 'text-emerald-400/70' : 'text-[var(--color-text-ghost)]'
}`}
>
{stage.label}
</span>
</div>
{idx < RENDER_STAGES.length - 1 && (
<div
className={`w-3 md:w-5 h-px mx-0.5 mt-[-10px] ${
isCompleted ? 'bg-emerald-500/40' : 'bg-[var(--color-border-faint)]'
}`}
/>
)}
</div>
);
})}
</div>
);
}
// ── Active Job Card ──
function ActiveJobCard({ job, onCancel, isCancelling }: {
job: any;
onCancel: (projectId: string) => void;
isCancelling: boolean;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const statusCfg = STATUS_CONFIG[job.status] || STATUS_CONFIG.QUEUED;
const StatusIcon = statusCfg.icon;
return (
<div className="card-surface p-4 md:p-5 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-[10px] font-semibold border ${statusCfg.bg} ${statusCfg.color}`}>
<StatusIcon size={10} />
{statusCfg.label}
</span>
<span className="text-[10px] text-[var(--color-text-ghost)]">
Deneme #{job.attemptNumber}/{job.maxAttempts}
</span>
</div>
<Link
href={`/dashboard/projects/${job.project.id}`}
className="text-sm font-semibold text-[var(--color-text-primary)] hover:text-violet-400 transition-colors truncate block"
>
{job.project.title}
</Link>
<div className="flex items-center gap-3 mt-1 text-[10px] text-[var(--color-text-ghost)]">
<span>{job.project?.targetDuration}s video</span>
<span className="uppercase">{job.project?.videoStyle}</span>
<span>{mounted ? timeAgo(job.createdAt) : '--'}</span>
</div>
</div>
{/* İptal */}
<button
onClick={() => onCancel(job.project.id)}
disabled={isCancelling}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 text-[10px] font-medium transition-colors disabled:opacity-50"
>
{isCancelling ? <Loader2 size={11} className="animate-spin" /> : <X size={11} />}
İptal
</button>
</div>
{/* Stage Stepper */}
{job.status === 'PROCESSING' && (
<StageStepper currentStage={job.currentStage} status={job.status} />
)}
{/* Kuyrukta bekliyor — animasyonlu bar */}
{job.status === 'QUEUED' && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs text-amber-400">
<Clock size={12} className="animate-pulse" />
<span>Kuyrukta bekleniyor...</span>
</div>
<div className="w-full bg-slate-800 rounded-full h-1.5 overflow-hidden">
<div className="bg-gradient-to-r from-amber-500/60 to-amber-400/80 h-1.5 rounded-full w-[8%] relative">
<div className="absolute inset-0 bg-white/20 animate-pulse rounded-full" />
</div>
</div>
</div>
)}
{/* Son Loglar */}
{job.logs && job.logs.length > 0 && (
<div className="border-t border-[var(--color-border-faint)] pt-2 space-y-1">
{job.logs.slice(0, 3).map((log: any) => (
<div key={log.id} className="flex items-center gap-2 text-[10px]">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${
log.level === 'error' ? 'bg-red-400' : log.level === 'warn' ? 'bg-amber-400' : 'bg-emerald-400/60'
}`} />
<span className="text-[var(--color-text-ghost)] truncate">{log.message}</span>
{log.durationMs && (
<span className="text-[var(--color-text-ghost)] shrink-0 ml-auto">{formatDuration(log.durationMs)}</span>
)}
</div>
))}
</div>
)}
</div>
);
}
// ── Recent Job Row ──
function RecentJobRow({ job }: { job: any }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const statusCfg = STATUS_CONFIG[job.status] || STATUS_CONFIG.COMPLETED;
const StatusIcon = statusCfg.icon;
return (
<div className="card-surface p-3 flex items-center gap-3">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${statusCfg.bg}`}>
<StatusIcon size={13} className={statusCfg.color} />
</div>
<div className="flex-1 min-w-0">
<Link
href={`/dashboard/projects/${job.project.id}`}
className="text-xs font-medium text-[var(--color-text-secondary)] hover:text-violet-400 transition-colors truncate block"
>
{job.project.title}
</Link>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-[var(--color-text-ghost)]">
<span className={statusCfg.color}>{statusCfg.label}</span>
{job.processingTimeMs && <span> {formatDuration(job.processingTimeMs)}</span>}
<span>{timeAgo(job.completedAt || job.createdAt)}</span>
</div>
{job.errorMessage && (
<p className="text-[10px] text-red-400/80 mt-0.5 truncate">{job.errorMessage}</p>
)}
</div>
<Link href={`/dashboard/projects/${job.project.id}`}>
<ArrowRight size={14} className="text-[var(--color-text-ghost)] hover:text-violet-400 transition-colors" />
</Link>
</div>
);
}
// ═══════════════════════════════════════════════════════
// ANA SAYFA — Render Kuyruk Monitörü
// ═══════════════════════════════════════════════════════
export default function RenderQueuePage() {
const { data, isLoading, error, refetch } = useRenderQueue();
const cancelMutation = useCancelRender();
const handleCancel = (projectId: string) => {
if (confirm('Bu render işlemini iptal etmek istediğinize emin misiniz?')) {
cancelMutation.mutate(projectId, { onSuccess: () => refetch() });
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-3">
<Loader2 size={32} className="animate-spin text-violet-400" />
<p className="text-sm text-[var(--color-text-muted)]">Render kuyruğu yükleniyor...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="card-surface p-8 text-center max-w-md">
<AlertCircle size={40} className="text-red-400 mx-auto mb-3" />
<h2 className="text-lg font-semibold mb-2">Yüklenemedi</h2>
<p className="text-sm text-[var(--color-text-muted)] mb-4">
Render kuyruğu verileri alınamadı. Backend çalışıyor olmalı.
</p>
<button onClick={() => refetch()} className="btn-primary text-sm">
Tekrar Dene
</button>
</div>
</div>
);
}
const { stats, activeJobs, recentJobs } = data || { stats: { total: 0, queued: 0, processing: 0, completed: 0, failed: 0, cancelled: 0, avgProcessingTimeMs: null }, activeJobs: [], recentJobs: [] };
const successRate = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0;
return (
<motion.div
variants={stagger}
initial="hidden"
animate="show"
className="space-y-6 max-w-5xl mx-auto pb-8"
>
{/* ── Başlık ── */}
<motion.div variants={fadeUp} className="flex items-center justify-between">
<div>
<h1 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold tracking-tight">
Render Monitör
</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Video üretim süreçlerini canlı takip edin
</p>
</div>
<button
onClick={() => refetch()}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--color-bg-elevated)] text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors"
>
<RefreshCw size={14} />
Yenile
</button>
</motion.div>
{/* ── İstatistik Kartları ── */}
<motion.div variants={fadeUp} className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Kuyrukta" value={stats.queued} icon={Clock} color="amber" sub="Bekliyor" />
<StatCard label="İşleniyor" value={stats.processing} icon={Play} color="cyan" sub="Aktif" />
<StatCard label="Tamamlanan" value={stats.completed} icon={CheckCircle2} color="emerald" sub={`%${successRate} başarı`} />
<StatCard
label="Ort. Süre"
value={formatDuration(stats.avgProcessingTimeMs)}
icon={Timer}
color="violet"
sub={`${stats.failed} başarısız`}
/>
</motion.div>
{/* ── Durum Dağılımı — Donut Benzeri Görsel ── */}
<motion.div variants={fadeUp} className="card-surface p-5">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-4 flex items-center gap-2">
<BarChart3 size={15} className="text-violet-400" />
Durum Dağılımı
</h2>
<div className="flex items-center gap-3">
{/* Horizontal stacked bar */}
<div className="flex-1 h-4 rounded-full overflow-hidden bg-[var(--color-bg-elevated)] flex">
{stats.completed > 0 && (
<div
className="bg-emerald-500 transition-all duration-700"
style={{ width: `${(stats.completed / Math.max(stats.total, 1)) * 100}%` }}
/>
)}
{stats.processing > 0 && (
<div
className="bg-cyan-500 transition-all duration-700"
style={{ width: `${(stats.processing / Math.max(stats.total, 1)) * 100}%` }}
/>
)}
{stats.queued > 0 && (
<div
className="bg-amber-500 transition-all duration-700"
style={{ width: `${(stats.queued / Math.max(stats.total, 1)) * 100}%` }}
/>
)}
{stats.failed > 0 && (
<div
className="bg-red-500 transition-all duration-700"
style={{ width: `${(stats.failed / Math.max(stats.total, 1)) * 100}%` }}
/>
)}
{stats.cancelled > 0 && (
<div
className="bg-slate-500 transition-all duration-700"
style={{ width: `${(stats.cancelled / Math.max(stats.total, 1)) * 100}%` }}
/>
)}
</div>
<span className="text-xs text-[var(--color-text-ghost)] shrink-0">{stats.total} toplam</span>
</div>
{/* Lejant */}
<div className="flex flex-wrap gap-4 mt-3">
{[
{ label: 'Tamamlanan', count: stats.completed, color: 'bg-emerald-500' },
{ label: 'İşleniyor', count: stats.processing, color: 'bg-cyan-500' },
{ label: 'Kuyrukta', count: stats.queued, color: 'bg-amber-500' },
{ label: 'Başarısız', count: stats.failed, color: 'bg-red-500' },
{ label: 'İptal', count: stats.cancelled, color: 'bg-slate-500' },
].map((item) => (
<div key={item.label} className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-muted)]">
<div className={`w-2.5 h-2.5 rounded-sm ${item.color}`} />
{item.label} ({item.count})
</div>
))}
</div>
</motion.div>
{/* ── Aktif İşler ── */}
<motion.div variants={fadeUp}>
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
<Zap size={15} className="text-cyan-400" />
Aktif İşler
{(stats.queued > 0 || stats.processing > 0) && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-cyan-500/12 text-cyan-400 font-medium animate-pulse">
{stats.queued + stats.processing} aktif
</span>
)}
</h2>
{activeJobs.length === 0 ? (
<div className="card-surface p-8 text-center">
<Pause size={32} className="text-[var(--color-text-ghost)] mx-auto mb-2" />
<p className="text-sm text-[var(--color-text-muted)]">Şu anda aktif render işlemi yok</p>
<p className="text-xs text-[var(--color-text-ghost)] mt-1">
Bir projeyi onayladığınızda burada görünecek
</p>
</div>
) : (
<div className="space-y-3">
{activeJobs.map((job: any) => (
<ActiveJobCard
key={job.id}
job={job}
onCancel={handleCancel}
isCancelling={cancelMutation.isPending}
/>
))}
</div>
)}
</motion.div>
{/* ── Geçmiş İşler ── */}
<motion.div variants={fadeUp}>
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
<Clock size={15} className="text-emerald-400" />
Geçmiş İşler
</h2>
{recentJobs.length === 0 ? (
<div className="card-surface p-8 text-center">
<Film size={32} className="text-[var(--color-text-ghost)] mx-auto mb-2" />
<p className="text-sm text-[var(--color-text-muted)]">Henüz tamamlanan render işlemi yok</p>
</div>
) : (
<div className="space-y-2">
{recentJobs.map((job: any) => (
<RecentJobRow key={job.id} job={job} />
))}
</div>
)}
</motion.div>
</motion.div>
);
}