Files
learning-lab-express/src/pages/Schedule.tsx
Iqbal ebfd3802c6 Read each schedule's production status once a minute
Every content schedule row on the Schedule page asked the engine for its
production status every 5s while a plan was running, a steady stream of
/content requests during production. Read it once a minute; starting a
production still refreshes it at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:20:43 +07:00

307 lines
17 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.
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon'
import { Empty, PageHead, SkeletonLines, SkeletonRows } from '../components/Page'
import { toast } from '../components/Toast'
import { describeRule } from '../express/parse'
import { locale, t, type Key } from '../i18n'
import {
archiveSchedule, confirmProduction, isMine, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, scheduleOwners, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type Owners, type RunSummary, type Schedule,
} from '../express/schedules'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
const fmt = (iso: string | null | undefined) => (iso
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
: '–')
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled', 'paused_configuration', 'paused_budget'])
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
const RUN_ERRORS = new Set([
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable',
])
const ISSUE_REASONS = new Set([
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',
'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready',
])
const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready'])
const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code)
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
// Which schedules the page starts with; remembered per device.
type Scope = 'mine' | 'all'
const SCOPE_KEY = 'express.scheduleScope'
const savedScope = (): Scope => {
try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'mine' } catch { return 'mine' }
}
export function SchedulePage() {
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const owners = useQuery({ queryKey: ['scheduleOwners'], queryFn: scheduleOwners, staleTime: 5 * 60_000 })
const [scope, setScope] = useState<Scope>(savedScope)
const choose = (next: Scope) => {
setScope(next)
try { localStorage.setItem(SCOPE_KEY, next) } catch { /* keep it for this visit only */ }
}
const all = schedules.data
const mine = all && owners.data ? all.filter(s => isMine(s, owners.data)) : undefined
// Members only ever see their own, so the switch appears only when someone else's schedules are listed.
const mixed = !!all && !!mine && mine.length < all.length
const shown = mixed && scope === 'mine' ? mine : all
return (
<>
<PageHead title={t('sch.title')} description={t('sch.description')}
actions={<Link className="btn primary" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
{mixed && (
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={scope === 'mine'} className={scope === 'mine' ? 'active' : undefined} onClick={() => choose('mine')}>
{t('sch.mine')} <b>{mine.length}</b>
</button>
<button role="tab" aria-selected={scope === 'all'} className={scope === 'all' ? 'active' : undefined} onClick={() => choose('all')}>
{t('sch.all')} <b>{all.length}</b>
</button>
</div>
)}
<div className="card">
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />}
{schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>}
{all?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
{mixed && shown?.length === 0 && <Empty icon="calendar" title={t('sch.emptyMine')}>{t('sch.emptyMineCopy')}</Empty>}
{!!shown?.length && <div className="result-list">{shown.map(s => <ScheduleRow key={s.agent.id} schedule={s} owners={owners.data} />)}</div>}
</div>
</>
)
}
function createdBy(schedule: Schedule, owners: Owners) {
if (isMine(schedule, owners)) return t('sch.byYou')
const name = schedule.agent.created_by_user_id != null ? owners.names.get(schedule.agent.created_by_user_id) : undefined
return name ? t('sch.by', { name }) : t('sch.byUnknown')
}
function ScheduleRow({ schedule, owners }: { schedule: Schedule; owners?: Owners }) {
const { agent, rule, active } = schedule
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const [topic, setTopic] = useState(rule.topic)
const [open, setOpen] = useState(false)
const [producing, setProducing] = useState(false)
const refresh = () => queryClient.invalidateQueries({ queryKey: ['schedules'] })
const video = rule.type === 'content' && rule.kind === 'video'
// Content kinds run as weekly production plans; research and video as one run per slot.
const production = !isRunSchedule(rule)
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(schedule)), onSuccess: refresh })
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
const runNow = useMutation({
mutationFn: () => startRun(agent.id),
onSuccess: () => {
toast(t(video ? 'sch.videoStarted' : 'sch.runStarted'))
setOpen(true)
queryClient.invalidateQueries({ queryKey: ['runs', agent.id] })
queryClient.invalidateQueries({ queryKey: ['results'] })
},
})
const error = toggle.error ?? retopic.error ?? archive.error ?? runNow.error
const busy = toggle.isPending || retopic.isPending || archive.isPending || runNow.isPending
return (
<div className="schedule-row">
<div className="result-row">
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
<div style={{ minWidth: 0, flex: 1 }}>
<b>{describeRule(rule)}</b>
{owners && <div className="schedule-owner"><small><Icon name="user" />{createdBy(schedule, owners)}</small></div>}
<div><small>{production
? <ProductionLine agentId={agent.id} active={active} />
: t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div>
{editing && (
<form className="topic-edit" onSubmit={e => { e.preventDefault(); retopic.mutate() }}>
<input aria-label={t('sch.newTopic')} value={topic} placeholder={t('ins.topicPlaceholder')} autoFocus onChange={e => setTopic(e.target.value)} />
<button className="btn small primary" disabled={busy}>{t('common.save')}</button>
<button type="button" className="btn small" onClick={() => { setEditing(false); setTopic(rule.topic) }}>{t('common.cancel')}</button>
</form>
)}
{error && <span className="error">{error.message}</span>}
</div>
<span className={`status ${active ? 'ready' : 'draft'}`}><i className="dot"></i>{active ? t('sch.active') : t('sch.paused')}</span>
</div>
<div className="schedule-actions">
{!editing && <button className="btn small" disabled={busy} onClick={() => setEditing(true)}>{t('sch.changeTopic')}</button>}
<button className="btn small" disabled={busy} onClick={() => toggle.mutate()}>{active ? t('sch.pause') : t('sch.resume')}</button>
{!production && active && <button className="btn small" disabled={busy} onClick={() => runNow.mutate()}>{t(video ? 'sch.videoNow' : 'sch.runNow')}</button>}
{production && active && !producing && <button className="btn small" disabled={busy} onClick={() => setProducing(true)}>{t('sch.produceNow')}</button>}
<button className="btn small" onClick={() => setOpen(o => !o)}>{open ? t('sch.hideHistory') : t('sch.history')}</button>
<button className="btn small danger" disabled={busy}
onClick={() => confirm(t('sch.deleteConfirm', { name: describeRule(rule) })) && archive.mutate()}>{t('sch.delete')}</button>
</div>
{production && !producing && <ProductionProgress agentId={agent.id} />}
{producing && production && <ProducePanel schedule={schedule} onClose={() => setProducing(false)} />}
{open && <RunHistory agentId={agent.id} />}
</div>
)
}
// Content schedules report this week's progress from the engine's production period. Each row
// asks for its own, so it is read once a minute; starting a production refreshes it at once.
const useProduction = (agentId: number) => useQuery({
queryKey: ['production', agentId],
queryFn: () => productionStatus(agentId),
refetchInterval: 60_000,
staleTime: 60_000,
})
function ProductionLine({ agentId, active }: { agentId: number; active: boolean }) {
const status = useProduction(agentId)
const s = status.data
if (!s) return <>{status.isError ? status.error.message : t('sch.loadingProgress')}</>
if (!s.planning_available) {
return <span className="error">{t('sch.planningOff')}</span>
}
if (!active) return <>{t('sch.paused')}</>
const p = s.period
return <>{p ? t('sch.thisWeek', { ready: p.ready, target: p.target, failed: p.failed ? t('sch.thisWeekFailed', { n: p.failed }) : '' }) : t('sch.waitingFirst')}</>
}
// Starts this week's production early: quote the planning step, show its price, then confirm it.
// The latest production of this schedule, step by step: topics → creating → done.
function ProductionProgress({ agentId }: { agentId: number }) {
const plan = useProduction(agentId).data?.plans[0]
if (!plan || plan.status === 'quoted' || plan.status === 'cancelled') return null
const { total, ready, failed, shortfall } = planProgress(plan)
const step = plan.status === 'planning' ? 0 : plan.status === 'running' || plan.status === 'paused' ? 2 : plan.status === 'completed' ? 3 : 1
const steps = [t('prod.stepTopics'), t('prod.stepStart'), t('prod.stepCreate', { ready, total }), t('prod.stepDone')]
const finished = plan.status === 'completed' || plan.status === 'failed'
return (
<div className={`production-progress${finished ? ' finished' : ''}`} role="status" aria-live="polite">
<div className="production-head">
{!finished && <span className="spinner" aria-hidden="true" />}
<b>{finished ? t('prod.lastTitle') : t('prod.runningTitle', { n: plan.target })}</b>
{plan.status !== 'planning' && total > 0 && <span className="production-count">{t('prod.count', { ready, total })}</span>}
</div>
{plan.status === 'failed' ? (
<span className="error">{t('prod.failed', { code: plan.error_code ?? '–' })}</span>
) : (
<ol className="production-steps">
{steps.map((label, i) => (
<li key={i} className={i < step ? 'done' : i === step ? (finished ? 'done' : 'current') : undefined}>{label}</li>
))}
</ol>
)}
{!!total && plan.status !== 'planning' && <div className="production-bar"><i style={{ width: `${Math.round(((ready + failed) / total) * 100)}%` }} /></div>}
{(failed > 0 || (shortfall > 0 && plan.status !== 'planning')) && (
<small>{[failed ? t('prod.someFailed', { n: failed }) : '', shortfall ? t('prod.shortfall', { n: shortfall }) : ''].filter(Boolean).join(' ')}</small>
)}
{(ready > 0 || plan.status === 'running') && <Link className="text-btn" to="/results">{t('prod.viewResults')} <Icon name="arrow-right" /></Link>}
</div>
)
}
function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedule; onClose: () => void }) {
const queryClient = useQueryClient()
const status = useProduction(agent.id)
const period = status.data?.period
const remaining = period ? Math.max(0, period.target - period.claimed) : agent.content_goal?.weekly_target ?? 0
const [count, setCount] = useState(rule.type === 'content' ? rule.count : 1)
const n = Math.min(Math.max(1, count), Math.max(1, remaining))
const quote = useMutation({ mutationFn: () => quoteProduction(agent.id, n) })
const start = useMutation({
mutationFn: (plan: ContentPlan) => confirmProduction(agent.id, plan),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['production', agent.id] })
queryClient.invalidateQueries({ queryKey: ['results'] })
onClose()
},
})
const error = quote.error ?? start.error
const running = status.data?.plans.some(p => PLAN_ACTIVE.has(p.status))
if (status.isPending) return <div className="produce-panel"><SkeletonLines label={t('sch.loadingProgress')} /></div>
return (
<div className="produce-panel">
{running ? (
<p>{t('sch.alreadyProducing')}</p>
) : remaining === 0 ? (
<p>{t('sch.weekFull')}</p>
) : quote.data ? (
<p>{t('sch.produceQuote', { n: quote.data.target, credits: quote.data.planning_quote.credits ?? 0, budget: agent.content_goal?.weekly_budget ?? 0 })}</p>
) : (
<label>{t('sch.produceHowMany', { max: remaining })}
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
</label>
)}
{error && <span className="error">{productionError(error.message)}</span>}
<div className="produce-actions">
{!running && remaining > 0 && (quote.data
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>
: <button className="btn small primary" disabled={quote.isPending} onClick={() => quote.mutate()}>{quote.isPending ? t('sch.checkingPrice') : t('sch.checkPrice')}</button>)}
<button className="btn small" onClick={onClose}>{running || remaining === 0 ? t('res.close') : t('common.cancel')}</button>
</div>
</div>
)
}
function RunHistory({ agentId }: { agentId: number }) {
const runs = useQuery({
queryKey: ['runs', agentId],
queryFn: () => listRuns(agentId),
refetchInterval: q => (q.state.data?.some(r => ['queued', 'planning', 'running'].includes(r.status)) ? 3000 : false),
})
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
const shown = runs.data.slice(0, 8)
// Readiness is today's state, so it is shown once, under the newest run it stopped.
const blockedId = shown.find(isNotReady)?.id
return (
<ul className="run-history">
{shown.map(r => (
<li key={r.id}>
<span>{fmt(r.created_at)}</span>
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
{r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
{r.source_outcome === 'no_new_sources' && <span>{t('sch.noNewSources')}</span>}
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
</li>
))}
</ul>
)
}
function ReadinessIssues({ agentId }: { agentId: number }) {
const readiness = useQuery({
queryKey: ['readiness', agentId],
queryFn: () => knowledgeReadiness(agentId),
// Processing and building finish on their own; keep the list current until they do.
refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false),
})
if (readiness.isPending) return <div className="run-issues"><SkeletonLines count={1} label={t('sch.loadingHistory')} /></div>
if (readiness.isError) return <div className="run-issues"><span className="error">{readiness.error.message}</span></div>
if (!readiness.data.issues.length) return <div className="run-issues"><small>{t('sch.blockedNone')}</small></div>
return (
<div className="run-issues">
<strong>{t('sch.blockedBy')}</strong>
<ul>
{readiness.data.issues.map(issue => (
<li key={`${issue.kind}-${issue.resource_id}`}>
<span><b>{issue.title}</b> · {issueReason(issue.reason)}</span>
<a href={`${STUDIO_URL}${issue.action_url}`} target="_blank" rel="noreferrer">{t('sch.fixInStudio')}</a>
</li>
))}
</ul>
</div>
)
}