main
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-04-27 12:50:54 +02:00
parent cf12fc3942
commit 89eb9d4dfd
6 changed files with 622 additions and 34 deletions
@@ -0,0 +1,481 @@
'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>
);
}