/** * BoostMedia AI Content Generator Admin - Jobs Page * * Active jobs tracking with polling, history, and summary. * * @package BoostMedia_AI * @license GPL-2.0-or-later */ import { useState, useEffect, useCallback, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Activity, FileText, Coins, Repeat, RefreshCw, FlaskConical, PenTool, CheckCircle, XCircle, Clock, ArrowRight, Eye, Play, AlertCircle, CalendarClock, } from 'lucide-react' import { Header } from '../components/layout/Header' import { Card, CardContent, Badge, Button } from '../components/common' import { endpoints } from '../api/client' import type { JobListItem, JobSummary, UpcomingPlan } from '../types' import { t, tf, getDateLocale } from '../lib/i18n' function formatRelativeTime(dateStr: string): string { const date = new Date(dateStr) const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMinutes = Math.floor(diffMs / 60000) const diffHours = Math.floor(diffMs / 3600000) const diffDays = Math.floor(diffMs / 86400000) if (diffMinutes < 1) return t('just now') if (diffMinutes < 60) return tf('%d minutes ago', diffMinutes) if (diffHours < 24) return tf('%d hours ago', diffHours) if (diffDays < 7) return tf('%d days ago', diffDays) return date.toLocaleDateString(getDateLocale(), { day: 'numeric', month: 'short', year: 'numeric' }) } function formatDuration(startStr: string, endStr?: string | null): string { const start = new Date(startStr) const end = endStr ? new Date(endStr) : new Date() const diffMs = end.getTime() - start.getTime() const seconds = Math.floor(diffMs / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) if (hours > 0) return `${hours}h ${minutes % 60}m` if (minutes > 0) return `${minutes}m ${seconds % 60}s` return tf('%d seconds', seconds) } function formatNextRun(dateStr: string | null): string { if (!dateStr) return '' const date = new Date(dateStr) const now = new Date() const diffMs = date.getTime() - now.getTime() const diffHours = Math.floor(diffMs / 3600000) const diffDays = Math.floor(diffMs / 86400000) if (diffMs < 0) return t('Overdue') if (diffHours < 1) return t('Less than 1 hour') if (diffHours < 24) return tf('%d hours', diffHours) if (diffDays < 7) return tf('%d days', diffDays) return date.toLocaleDateString(getDateLocale(), { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) } function jobStatusBadge(status: string) { switch (status) { case 'queued': return {t('Queued')} case 'processing': case 'running': case 'forming_articles': return {t('In Progress')} case 'complete': case 'completed': case 'delivered': return {t('Completed')} case 'failed': return {t('Failed')} default: return {status} } } function SummaryCard({ icon: Icon, value, label }: { icon: typeof Activity; value: string | number; label: string }) { return (
{value}
{label}
) } function ActiveJobCard({ job, onView }: { job: JobListItem; onView: (job: JobListItem) => void }) { const isSprint = job.type === 'sprint' const progress = job.articles_total > 0 ? Math.round((job.articles_completed / job.articles_total) * 100) : 0 return (
{isSprint ? : }
{job.plan_name || (isSprint ? t('Research Sprint') : t('Article Generation'))}
{isSprint ? t('Research Sprint') : t('Article Generation')} {job.execution_type === 'scheduled' && ( {t('Scheduled')} )} {job.status === 'running' && job.rounds_max > 0 && ( {tf('Round %d of %d', job.rounds_used, job.rounds_max)} )} {job.findings_count > 0 && ( {tf('%d findings', job.findings_count)} )}
{jobStatusBadge(job.status)}
{job.articles_total > 0 && (
{tf('Article %d of %d', job.articles_completed, job.articles_total)} {progress}%
)}
{formatDuration(job.started_at)} {job.cost_coins > 0 && ( {job.cost_coins.toFixed(1)} BC )}
) } function HistoryJobCard({ job, onView, onRunAgain }: { job: JobListItem onView: (job: JobListItem) => void onRunAgain: (job: JobListItem) => void }) { const isSprint = job.type === 'sprint' const isComplete = job.status === 'complete' || job.status === 'completed' || job.status === 'delivered' const isFailed = job.status === 'failed' return (
{isComplete ? : isFailed ? : }
{isComplete ? (job.articles_completed === 1 ? t('Created 1 article') : tf('Created %d articles', job.articles_completed)) : isFailed ? (job.type === 'sprint' ? t('Sprint failed') : job.type === 'generation' ? t('Generation failed') : t('Job failed')) : job.plan_name || t('Article Generation') }
{job.plan_name ? {job.plan_name} : null} {job.execution_type === 'scheduled' && ( {t('Scheduled')} )} {job.execution_type === 'one_time' && ( {t('One-time')} )} {isFailed && job.error_message && ( {job.error_message} )}
{jobStatusBadge(job.status)}
{job.started_at && ( {formatRelativeTime(job.started_at)} )} {job.completed_at && job.started_at && ( {formatDuration(job.started_at, job.completed_at)} )} {job.cost_coins > 0 && ( {job.cost_coins.toFixed(1)} BC )} {isSprint && ( <> {job.rounds_used > 0 && ( {tf('Round %d of %d', job.rounds_used, job.rounds_max)} )} {job.findings_count > 0 && ( {tf('%d findings', job.findings_count)} )} )}
{isComplete && ( <> {job.plan_id ? ( ) : null} )} {isFailed && job.plan_id ? ( ) : null}
) } type FilterStatus = 'all' | 'complete' | 'failed' export default function JobsPage() { const navigate = useNavigate() const [allJobs, setAllJobs] = useState([]) const [activeJobs, setActiveJobs] = useState([]) const [upcomingPlans, setUpcomingPlans] = useState([]) const [summary, setSummary] = useState({ active_count: 0, completed_today: 0, articles_today: 0, coins_today: 0 }) const [filter, setFilter] = useState('all') const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [nextCursor, setNextCursor] = useState(null) const [hasMore, setHasMore] = useState(false) const mountedRef = useRef(true) const PAGE_SIZE = 20 const prevActiveIdsRef = useRef>(new Set()) const loadUpcomingPlans = useCallback(async () => { try { const result = await endpoints.getUpcomingPlans() if (!mountedRef.current) return setUpcomingPlans(result.data ?? []) } catch { // silently ignore } }, []) const loadJobs = useCallback(async (showLoader = false) => { if (showLoader) setLoading(true) try { const [jobsResult] = await Promise.all([ endpoints.listJobs({ limit: PAGE_SIZE, status: 'all' }), loadUpcomingPlans(), ]) if (!mountedRef.current) return const data = jobsResult.data setAllJobs(data.jobs) setNextCursor(data.next_cursor ?? null) setHasMore(data.has_more ?? false) const active = data.jobs.filter(j => ['queued', 'running', 'forming_articles', 'processing'].includes(j.status)) setActiveJobs(active) prevActiveIdsRef.current = new Set(active.map(j => j.id)) setSummary(data.summary) } catch { // silently ignore } finally { if (mountedRef.current) setLoading(false) } }, [loadUpcomingPlans]) const pollActive = useCallback(async () => { try { const result = await endpoints.listJobs({ status: 'active', limit: 50 }) if (!mountedRef.current) return const data = result.data const freshActive = data.jobs setActiveJobs(freshActive) setSummary(data.summary) setAllJobs(prev => { const activeMap = new Map(freshActive.map(j => [j.id, j])) return prev.map(job => activeMap.get(job.id) || job) }) const freshIds = new Set(freshActive.map(j => j.id)) const prevIds = prevActiveIdsRef.current const newlyCompleted = [...prevIds].filter(id => !freshIds.has(id)) prevActiveIdsRef.current = freshIds if (newlyCompleted.length > 0) { loadJobs(false) } } catch { // silently ignore } }, [loadJobs]) useEffect(() => { mountedRef.current = true loadJobs(true) const poll = setInterval(pollActive, 10000) return () => { mountedRef.current = false clearInterval(poll) } }, [loadJobs, pollActive]) const loadMore = async () => { if (!nextCursor) return try { const result = await endpoints.listJobs({ limit: PAGE_SIZE, before: nextCursor, status: filter === 'all' ? 'all' : filter }) if (!mountedRef.current) return const data = result.data const newJobs = data.jobs setAllJobs(prev => { const existingIds = new Set(prev.map(j => j.id)) const unique = newJobs.filter(j => !existingIds.has(j.id)) return [...prev, ...unique] }) setNextCursor(data.next_cursor ?? null) setHasMore(data.has_more ?? false) } catch { // silently ignore } } const handleRefresh = async () => { setRefreshing(true) await loadJobs(false) setRefreshing(false) } const handleFilterChange = async (newFilter: FilterStatus) => { setFilter(newFilter) setLoading(true) try { const status = newFilter === 'all' ? 'all' : newFilter const result = await endpoints.listJobs({ limit: PAGE_SIZE, status }) if (!mountedRef.current) return const data = result.data setAllJobs(data.jobs) setNextCursor(data.next_cursor ?? null) setHasMore(data.has_more ?? false) } catch { // silently ignore } finally { if (mountedRef.current) setLoading(false) } } const handleView = (job: JobListItem) => { const isComplete = ['complete', 'completed', 'delivered'].includes(job.status) if (isComplete) { const planParam = job.plan_id ? `?planId=${job.plan_id}` : '' navigate(`/generated${planParam}`) } else { navigate(`/generate?planId=${job.plan_id}&intent=review&step=settings`) } } const handleRunAgain = (job: JobListItem) => { if (job.plan_id != null && job.plan_id > 0) { navigate(`/generate?planId=${job.plan_id}&intent=review&step=settings`) } } const historyJobs = allJobs.filter(j => { const isActive = ['queued', 'running', 'forming_articles', 'processing'].includes(j.status) if (isActive) return false if (filter === 'complete') return ['complete', 'completed', 'delivered'].includes(j.status) if (filter === 'failed') return j.status === 'failed' return true }) const canLoadMore = hasMore && !!nextCursor if (loading && allJobs.length === 0) { return (
{t('Loading...')}
) } return (
{/* Summary Cards */}
0 ? summary.coins_today.toFixed(1) : '0'} label={t('BoostCoins today')} />
{/* Scheduled Plans */} {upcomingPlans.length > 0 && (

{tf('Scheduled Plans (%d)', upcomingPlans.length)}

{upcomingPlans.map(plan => { const isRunning = activeJobs.some(job => job.plan_id === plan.plan_id) return (

{plan.plan_name}

{isRunning && ( {t('Running now')} )}

{plan.schedule_text} ยท {tf('%d articles per run', plan.article_count)}

{t('Next run')}:

{formatNextRun(plan.next_run_at)}

) })}
)} {/* Active Jobs */}

{tf('Active Jobs (%d)', activeJobs.length)}

{activeJobs.length > 0 && (
{t('Live')}
)}
{activeJobs.length === 0 ? (

{t('No active jobs')}

{t('Jobs will appear here when you start generating content')}

) : ( activeJobs.map(job => ( )) )}
{/* Job History */}

{t('Job History')}

{(['all', 'complete', 'failed'] as FilterStatus[]).map(f => ( ))}
{historyJobs.length === 0 ? (

{filter === 'all' ? t('No job history yet') : t('No jobs matching this filter')}

) : ( <> {historyJobs.map(job => ( ))} {canLoadMore && ( )} )}
) }