Org admins see every Express schedule in the organization, so a shared organization mixed everyone's schedules together. The Schedule page now shows "Created by …" on each one and, when others' schedules are listed, switches between Mine and Everyone in the organization. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
17 KiB
TypeScript
305 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="/brain"><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.
|
||
const useProduction = (agentId: number) => useQuery({
|
||
queryKey: ['production', agentId],
|
||
queryFn: () => productionStatus(agentId),
|
||
refetchInterval: q => (q.state.data?.plans.some(p => PLAN_ACTIVE.has(p.status)) ? 5000 : 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>
|
||
)
|
||
}
|