/**
* 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 (
)
}
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 (
{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 (
)
}
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 */}
{(['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 && (
)}
>
)}
)
}