246 lines
16 KiB
TypeScript
246 lines
16 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { History, RefreshCw } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import type { AssistantHeartbeatConfig, AssistantProject } from '../../shared/assistant-contracts'
|
|
import type { SupervisionActivity } from '../../shared/supervision-contracts'
|
|
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
|
import { EmptyState, ScopeBadge } from './WorkspacePrimitives'
|
|
import { SupervisionBatchDetails } from './SupervisionBatchDetails'
|
|
import { SupervisionReviewStages } from './SupervisionReviewStages'
|
|
|
|
export function SupervisorActivity({ active, projects, onOpenResult, configId, configs = [], onPlanChange }: {
|
|
configId?: string
|
|
configs?: AssistantHeartbeatConfig[]
|
|
onPlanChange?: (id: string) => void
|
|
active: boolean
|
|
projects: AssistantProject[]
|
|
onOpenResult: (resultId: string, tab: 'overview' | 'graph') => void
|
|
}) {
|
|
const { t, i18n } = useTranslation('heartbeat')
|
|
const [rows, setRows] = useState<SupervisionActivity[]>([])
|
|
const [offset, setOffset] = useState(0)
|
|
const [refresh, setRefresh] = useState(0)
|
|
const [loading, setLoading] = useState(true)
|
|
const [reading, setReading] = useState(false)
|
|
const [error, setError] = useState<string>()
|
|
const [expanded, setExpanded] = useState<string>()
|
|
const [pending, setPending] = useState<string>()
|
|
const [execution, setExecution] = useState<SupervisionReviewExecution>({ active: false })
|
|
const [stopping, setStopping] = useState<{ runId: string; kind: 'paused' | 'cancelled' }>()
|
|
const actionPending = useRef(false)
|
|
const filterRef = useRef<HTMLSelectElement>(null)
|
|
const [appliedConfigId, setAppliedConfigId] = useState(configId)
|
|
if (appliedConfigId !== configId) {
|
|
setAppliedConfigId(configId)
|
|
setOffset(0)
|
|
setRows([])
|
|
setLoading(true)
|
|
setError(undefined)
|
|
setExpanded(undefined)
|
|
}
|
|
const api = window.goodbuddy?.supervision
|
|
useEffect(() => {
|
|
if (!active) return
|
|
let disposed = false
|
|
let timer: ReturnType<typeof setTimeout>
|
|
const load = async () => {
|
|
setReading(true)
|
|
try {
|
|
if (!api?.activity) throw new Error(t('supervisor.unavailable'))
|
|
const [result, current] = await Promise.all([
|
|
api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) }),
|
|
api.execution()
|
|
])
|
|
if (disposed) return
|
|
setRows(result)
|
|
setExecution(current)
|
|
setError(undefined)
|
|
// One request at a time, only while this page is active.
|
|
timer = setTimeout(() => void load(), current.active || result.some((row) => row.status === 'running') ? 2000 : 10000)
|
|
} catch (reason) {
|
|
if (!disposed) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
|
|
} finally {
|
|
if (!disposed) {
|
|
setLoading(false)
|
|
setReading(false)
|
|
}
|
|
}
|
|
}
|
|
timer = setTimeout(() => void load(), 0)
|
|
return () => { disposed = true; clearTimeout(timer) }
|
|
}, [active, api, offset, refresh, t, configId])
|
|
const reload = (nextOffset = offset) => {
|
|
setLoading(true)
|
|
setError(undefined)
|
|
setOffset(nextOffset)
|
|
setRefresh((value) => value + 1)
|
|
}
|
|
const date = (value: string) => new Date(value).toLocaleString(i18n.resolvedLanguage)
|
|
// The review stays published; only the suggestion step is repeated.
|
|
const retrySuggestions = async (heartbeatRunId: string) => {
|
|
if (actionPending.current) return
|
|
actionPending.current = true
|
|
setPending(heartbeatRunId)
|
|
setError(undefined)
|
|
try { await api.retrySuggestions({ heartbeatRunId }) }
|
|
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
|
|
finally {
|
|
actionPending.current = false
|
|
setPending(undefined)
|
|
setRefresh(value => value + 1)
|
|
}
|
|
}
|
|
// The review stays published; only story organizing is repeated.
|
|
const retryStories = async (row: SupervisionActivity) => {
|
|
if (actionPending.current || !row.reviewProgress) return
|
|
actionPending.current = true
|
|
setPending(row.id)
|
|
setError(undefined)
|
|
try { await api.retryStories({ runId: row.reviewProgress.runId }) }
|
|
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
|
|
finally {
|
|
actionPending.current = false
|
|
setPending(undefined)
|
|
setRefresh(value => value + 1)
|
|
}
|
|
}
|
|
const control = async (row: SupervisionActivity, action: 'pause' | 'cancel' | 'resume') => {
|
|
if (actionPending.current || !row.reviewProgress) return
|
|
const runId = row.reviewProgress.runId
|
|
actionPending.current = true
|
|
setPending(row.id)
|
|
setError(undefined)
|
|
if (action !== 'resume') setStopping({ runId, kind: action === 'cancel' ? 'cancelled' : 'paused' })
|
|
try {
|
|
if (action === 'resume') {
|
|
const current = await api.execution()
|
|
setExecution(current)
|
|
if (current.active) {
|
|
setError(t('supervisor.runningHint'))
|
|
return
|
|
}
|
|
// Resume resolves only when execution stops; keep its pause/cancel controls available.
|
|
void api.resume({ runId }).then(
|
|
() => setRefresh(value => value + 1),
|
|
reason => setError(reason instanceof Error ? reason.message : String(reason))
|
|
)
|
|
return
|
|
}
|
|
await api[action]({ runId })
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : String(reason))
|
|
} finally {
|
|
actionPending.current = false
|
|
setPending(undefined)
|
|
setStopping(undefined)
|
|
setRefresh(value => value + 1)
|
|
}
|
|
}
|
|
const planScope = configs.find(config => config.id === configId)?.scope
|
|
return <section className="supervisor-activity" aria-label={t('activity.title')} aria-busy={loading}>
|
|
<div className="supervisor-workspace__action-bar">
|
|
<ScopeBadge scope={planScope?.kind === 'global' ? { kind: 'global' }
|
|
: planScope?.kind === 'projects' ? { kind: 'projects', projectCount: planScope.projectIds.length }
|
|
: { kind: 'mixed' }} />
|
|
{onPlanChange && <label className="heartbeat-settings__field">
|
|
<select aria-label={t('activity.planFilter')} ref={filterRef} value={configId ?? ''} onChange={(event) => onPlanChange(event.target.value)}>
|
|
<option value="">{t('activity.allPlans')}</option>
|
|
{configId && !configs.some(config => config.id === configId) && <option value={configId}>{t('activity.unavailablePlan')}</option>}
|
|
{configs.map(config => <option key={config.id} value={config.id}>{config.name}</option>)}
|
|
</select>
|
|
</label>}
|
|
{configId && onPlanChange && <button className="secondary-button" type="button" onClick={() => {
|
|
onPlanChange('')
|
|
filterRef.current?.focus()
|
|
}}>{t('activity.clearFilter')}</button>}
|
|
<button className="secondary-button" type="button" disabled={loading || reading || !active} onClick={() => reload()}>
|
|
<RefreshCw size={14} aria-hidden="true" />{t('center.actions.refresh')}
|
|
</button>
|
|
</div>
|
|
{error && <div role="alert" className="heartbeat-center__error"><p>{error}</p>
|
|
<button className="secondary-button" type="button" onClick={() => reload()}>{t('center.actions.retry')}</button>
|
|
</div>}
|
|
{loading ? <EmptyState icon={<RefreshCw size={24} />} variant="loading" title={t('activity.loading')} description={t('activity.loadingHint')} />
|
|
: !error && rows.length === 0 ? <EmptyState icon={<History size={24} />} title={t(configId ? 'activity.filteredEmpty' : 'activity.empty')} description={t(configId ? 'activity.filteredEmptyHint' : 'activity.emptyHint')} />
|
|
: <ol className="supervisor-activity__list">{rows.map((row) => {
|
|
const runId = row.reviewProgress?.runId
|
|
const experiences = row.reviewProgress?.stories?.experiences
|
|
const stoppingKind = runId && stopping?.runId === runId ? stopping.kind
|
|
: runId && execution.active && execution.runId === runId ? execution.stopping : undefined
|
|
return <li key={`${row.kind}:${row.id}`}>
|
|
<article className="supervisor-activity__item">
|
|
<header><strong>{t(`activity.kind.${row.kind}`)}</strong>
|
|
<span className={`supervisor-activity__status supervisor-activity__status--${stoppingKind ? 'running' : row.status}`} role={stoppingKind ? 'status' : undefined}>{t(stoppingKind === 'cancelled' ? 'reviewSettings.cancelling' : stoppingKind === 'paused' ? 'reviewSettings.pausing' : `activity.status.${row.status}`)}</span>
|
|
<time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time>
|
|
</header>
|
|
<p className="supervisor-activity__scope">{!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}</p>
|
|
{!row.reviewProgress && <>
|
|
<p className="supervisor-activity__stages">
|
|
<span>{t('activity.supervisionStage')}: {row.supervisionStatus ? t(`activity.status.${row.supervisionStatus}`) : t('activity.notRecorded')}</span>
|
|
</p>
|
|
</>}
|
|
{row.reviewProgress && <>
|
|
{row.reviewProgress.restartRequired && <p>{t('reviewSettings.restartRequired')}</p>}
|
|
<div className="supervisor-workspace__actions">
|
|
{(row.supervisionStatus === 'running' || stoppingKind) && <>
|
|
<button type="button" className="secondary-button" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'pause')}>{t('reviewSettings.pause')}</button>
|
|
<button type="button" className="danger-ghost" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'cancel')}>{t('reviewSettings.cancel')}</button>
|
|
</>}
|
|
{!stoppingKind && !row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && <button type="button" className="secondary-button" disabled={!!pending || execution.active} onClick={() => void control(row, 'resume')}>{t(row.reviewProgress.phase === 'summarizing' ? 'activity.resumeMerge' : row.reviewProgress.phase === 'saving' ? 'activity.resumeSave' : 'reviewSettings.resume')}</button>}
|
|
<button type="button" className="secondary-button" aria-expanded={expanded === row.id} aria-controls={`batches-${row.id}`} onClick={() => setExpanded(expanded === row.id ? undefined : row.id)}>{t('reviewSettings.facts')}</button>
|
|
</div>
|
|
</>}
|
|
{row.reviewProgress && <SupervisionReviewStages row={row} stopping={stoppingKind || undefined} />}
|
|
{row.error && row.supervisionStatus !== 'cancelled' && !stoppingKind && <div className="supervisor-activity__failure">
|
|
<p>{t(row.status === 'paused' ? 'reviewSettings.pausedHint' : /persistedId|candidateRef|entity identity/.test(row.error)
|
|
? 'reviewSettings.identityError' : 'reviewSettings.runError')}</p>
|
|
<details className="supervisor-activity__diagnostics"><summary>{t('reviewSettings.diagnostics')}</summary>
|
|
<pre tabIndex={0} aria-label={t('reviewSettings.diagnostics')} style={{ userSelect: 'text' }}>{row.error}</pre>
|
|
</details>
|
|
</div>}
|
|
{row.reviewProgress?.stories && <p className="supervisor-activity__suggestion" role={row.reviewProgress.stories.status === 'failed' ? 'alert' : undefined}>
|
|
{row.reviewProgress.stories.status === 'failed' ? t('activity.storyFailed')
|
|
: `${t('activity.storyStage')}: ${t(`activity.storyStates.${row.reviewProgress.stories.status}`, { assigned: row.reviewProgress.stories.assigned ?? 0 })}`}
|
|
{row.reviewProgress.stories.status === 'failed' && typeof api?.retryStories === 'function' && <button type="button" className="secondary-button"
|
|
disabled={!!pending || execution.active} onClick={() => void retryStories(row)}>{t('activity.retryStories')}</button>}
|
|
</p>}
|
|
{experiences && <p className="supervisor-activity__suggestion" role={experiences.status === 'failed' ? 'alert' : undefined}>
|
|
{experiences.status === 'failed' ? t('activity.experienceFailed')
|
|
: `${t('activity.experienceStage')}: ${experiences.calls ? t('activity.experienceCompleted', { created: experiences.created ?? 0, applied: experiences.applied ?? 0 }) : t('activity.experienceNone')}`}
|
|
{experiences.status === 'failed' && typeof api?.retryStories === 'function' && <button type="button" className="secondary-button"
|
|
disabled={!!pending || execution.active} onClick={() => void retryStories(row)}>{t('activity.retryStories')}</button>}
|
|
</p>}
|
|
{row.suggestionStatus && <p className="supervisor-activity__suggestion" role={row.suggestionStatus === 'failed' ? 'alert' : undefined}>
|
|
{row.suggestionStatus === 'failed' ? t('activity.updatedSuggestionFailed')
|
|
: `${t('activity.suggestionStage')}: ${t(`activity.suggestionStates.${row.suggestionStatus}`, { count: row.suggestionCount ?? 0 })}`}
|
|
{row.suggestionStatus === 'failed' && typeof api?.retrySuggestions === 'function' && <button type="button" className="secondary-button" disabled={!!pending}
|
|
onClick={() => void retrySuggestions(row.id)}>{t('activity.retrySuggestions')}</button>}
|
|
</p>}
|
|
{row.resultId && <div className="supervisor-workspace__actions">
|
|
<button className="secondary-button" type="button" onClick={() => onOpenResult(row.resultId!, 'overview')}>{t('activity.openReview')}</button>
|
|
<button className="secondary-button" type="button" onClick={() => onOpenResult(row.resultId!, 'graph')}>{t('supervisor.viewInGraph')}</button>
|
|
</div>}
|
|
{row.reviewProgress && expanded === row.id && <div id={`batches-${row.id}`}><SupervisionBatchDetails key={row.reviewProgress.runId} runId={row.reviewProgress.runId} projects={projects} revision={row.reviewProgress.batches} /></div>}
|
|
<details className="supervisor-activity__advanced"><summary>{t('activity.runDetails')}</summary>
|
|
<dl>
|
|
<div><dt>{t('activity.started')}</dt><dd>{date(row.startedAt)}</dd></div>
|
|
<div><dt>{t('activity.finished')}</dt><dd>{row.completedAt ? date(row.completedAt) : t('common.unavailable')}</dd></div>
|
|
{row.timeRange && <div><dt>{t('supervisor.period')}</dt><dd>{date(row.timeRange.from)} / {date(row.timeRange.to)}</dd></div>}
|
|
</dl>
|
|
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
|
|
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
|
|
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
|
|
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
|
|
<p>{t('reviewSettings.configurationValues', row.reviewProgress.settings)}</p>
|
|
</details>}
|
|
</details>
|
|
</article>
|
|
</li>})}</ol>}
|
|
<nav className="supervisor-workspace__actions" aria-label={t('activity.pagination')}>
|
|
<button className="secondary-button" type="button" disabled={loading || reading || !active || offset === 0} onClick={() => reload(Math.max(0, offset - 50))}>{t('activity.previous')}</button>
|
|
<span>{t('activity.page', { page: offset / 50 + 1 })}</span>
|
|
<button className="secondary-button" type="button" disabled={loading || reading || !active || rows.length < 50 || offset >= 100000} onClick={() => reload(offset + 50)}>{t('activity.next')}</button>
|
|
</nav>
|
|
</section>
|
|
}
|