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>
307 lines
17 KiB
TypeScript
307 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|