feat(events): the minimal admin surface (Phase 3)
Three screens, an Events nav group and the six live run controls Phase 1 left
absent on purpose because nothing was in flight. An admin can now author,
publish, start and watch an event that announces things and cues a human; a
moderator can stop one that is going wrong.
Six controls, not eight. `advance` is absent because a phase today advances when
its steps go terminal — the per-step skip already does that — and Phase 5 is what
gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`,
until Phase 8's ledger exists. Every control is a compare-and-set on the status it
may act from, so a console rendered thirty seconds ago cannot act on a run that
has moved.
Fixes a defect in the Phase 2 runner: `advanceRun` drained up to
EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the
tick, so a pause pressed mid-batch did nothing for up to 24 more steps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
352
client/src/routes/admin/views/EventRun.jsx
Normal file
352
client/src/routes/admin/views/EventRun.jsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
runStatusWord,
|
||||
isTerminalRun,
|
||||
isParked,
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
describeLogLine,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// One run: where it is, what each of its steps did, what a human can still do
|
||||
// about it, and the diagnostic log underneath. Staff-wide to read; the six
|
||||
// controls are `admin` + `moderator`, and the server re-checks every one of them
|
||||
// against the run's live status — this screen predicts, it does not decide.
|
||||
//
|
||||
// **It polls rather than streaming.** A run changes on the runner's tick, which
|
||||
// is a fifteen-second clock, and a console watched for the length of an event is
|
||||
// a tab left open for two hours: an SSE channel for that is a connection held
|
||||
// per staff member for a screen that could not use the latency. The poll stops
|
||||
// the moment the run reaches a terminal status, because a completed run has
|
||||
// nothing further to say.
|
||||
//
|
||||
// **The parked step is the thing this screen exists to make impossible to
|
||||
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
|
||||
// stay that way for ever unless somebody presses confirm. It is called out above
|
||||
// the step list rather than being one row in it.
|
||||
|
||||
const POLL_MS = 5000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
completed: '#8fc79a',
|
||||
}
|
||||
|
||||
const STEP_COLOR = {
|
||||
done: '#8fc79a',
|
||||
failed: '#d98b84',
|
||||
refused: '#d9c184',
|
||||
skipped: 'var(--muted)',
|
||||
cancelled: 'var(--muted)',
|
||||
}
|
||||
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
|
||||
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
|
||||
|
||||
export default function EventRun() {
|
||||
const { runId } = useParams()
|
||||
const [run, setRun] = useState(null)
|
||||
const [steps, setSteps] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notes, setNotes] = useState({})
|
||||
const [reason, setReason] = useState('')
|
||||
const alive = useRef(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [detail, log] = await Promise.all([
|
||||
api.admin.getEventRun(runId),
|
||||
api.admin.getEventRunLog(runId, 200),
|
||||
])
|
||||
if (!alive.current) return
|
||||
setRun(detail.run)
|
||||
setSteps(detail.steps || [])
|
||||
setCounts(detail.counts || {})
|
||||
setLines(log.log || [])
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (alive.current) setError(err.message)
|
||||
} finally {
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive.current = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
// The poll, and its own off switch. A terminal run is not re-read: it cannot
|
||||
// change, and a console left open on last night's completed event should not
|
||||
// be a request every five seconds until the tab is closed.
|
||||
useEffect(() => {
|
||||
if (!run || isTerminalRun(run.status)) return undefined
|
||||
const timer = setInterval(() => {
|
||||
load().catch(() => {})
|
||||
}, POLL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [run, load])
|
||||
|
||||
/** Every control goes through here: press, reload, and surface a refusal. */
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
} catch (err) {
|
||||
// A 409 is the ordinary answer to a button pressed against a run that has
|
||||
// moved on since the screen was drawn, so it is shown as a sentence rather
|
||||
// than as an error state — and the reload above has already re-drawn the
|
||||
// controls as they now stand.
|
||||
setProblem(err.body?.errors?.[0] || err.message)
|
||||
await load().catch(() => {})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !run) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!run) return <ErrorState message="No such run." />
|
||||
|
||||
const controls = runControlsFor(run)
|
||||
const parked = steps.filter(isParked)
|
||||
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
|
||||
<Link to={`/admin/events/${run.definitionId}`}>{run.definitionTitle}</Link>{' '}
|
||||
<span className="dim" style={{ fontWeight: 400 }}>v{run.version}</span>
|
||||
</h2>
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
||||
Occurrence {when(run.scheduledFor)}
|
||||
{run.scope ? ` · scope ${run.scope}` : ''}
|
||||
{run.rehearsal ? ' · rehearsal' : ''}
|
||||
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
|
||||
{runStatusWord(run.status)}
|
||||
{run.currentPhase && <span className="dim" style={{ fontSize: '0.82rem' }}> · {run.currentPhase}</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{run.health !== 'ok' && <span style={{ color: '#d9c184' }}>{run.health} · </span>}
|
||||
{summary || 'no steps'}
|
||||
{!isTerminalRun(run.status) && <span> · refreshing</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health is not status, which is the whole reason the two are separate
|
||||
columns — but the sentence has to agree with the status it sits beside.
|
||||
A degraded RUNNING run is the interesting case: still going, already in
|
||||
trouble. A degraded PAUSED run is not "still running", and saying so on
|
||||
the one screen an operator opens to find out what stopped it would be
|
||||
the console contradicting itself. Found in the browser walk. */}
|
||||
{run.health === 'degraded' && !isTerminalRun(run.status) && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d9c184', marginTop: 10 }}>
|
||||
{run.status === 'paused' ? (
|
||||
<>
|
||||
Something in this run failed, and it is waiting for a person. Resuming carries the phase
|
||||
past the failed step; <em>Retry & resume</em> puts that step back in the queue first.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Something in this run has already had to be retried. It is still running — this is
|
||||
what “degraded” means, and the log below says what happened.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{run.lastError && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{run.lastError}</p>
|
||||
)}
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{problem}</p>
|
||||
)}
|
||||
|
||||
{/* ── The run controls ── */}
|
||||
<div className="panel-flat" style={{ padding: '12px 14px', margin: '14px 0', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Reason (recorded with your name)</span>
|
||||
<input className="input" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="optional" />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.pause}
|
||||
onClick={() => act(() => api.admin.pauseEventRun(run.id, reason))}>
|
||||
Pause
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.resume}
|
||||
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
|
||||
Resume
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
|
||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
|
||||
Cancel run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isTerminalRun(run.status) && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>
|
||||
This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
|
||||
— a run pins the version it started from so that it can still be explained later.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Waiting on a person ── */}
|
||||
{parked.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Waiting on a person</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
Nothing else in this phase runs until each of these is confirmed. There is no timeout —
|
||||
a cue posted on Friday is still waiting on Monday.
|
||||
</p>
|
||||
{parked.map((step) => (
|
||||
<div key={step.id} style={{ marginBottom: 10 }}>
|
||||
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.86rem' }}>
|
||||
{step.params?.instruction || step.actionId}
|
||||
{step.params?.assignee && <span className="dim"> — for {step.params.assignee}</span>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">What you did (optional)</span>
|
||||
<input className="input" value={notes[step.id] || ''}
|
||||
onChange={(e) => setNotes((n) => ({ ...n, [step.id]: e.target.value }))} />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.confirmEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Confirm — done
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Skip it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The steps ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Steps</h3>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Phase</th>
|
||||
<th className="adm-th">#</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Attempts</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map((step) => {
|
||||
const c = stepControlsFor(run, step, steps)
|
||||
return (
|
||||
<tr key={step.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>
|
||||
{step.phase}
|
||||
{step.phase === run.currentPhase && <span className="dim"> ·now</span>}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{step.seq + 1}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
|
||||
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(step.params)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
|
||||
{isParked(step) ? <span style={{ color: '#d9c184' }}>waiting</span> : step.status}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{step.attempts}
|
||||
{step.dueAt && new Date(step.dueAt) > new Date() && (
|
||||
<div style={{ fontSize: '0.74rem' }}>due {clock(step.dueAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.78rem', maxWidth: 280, overflowWrap: 'anywhere' }}>
|
||||
{step.lastError || ''}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{c.retry && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.retryEventStep(run.id, step.id))}>
|
||||
Retry & resume
|
||||
</button>
|
||||
)}
|
||||
{c.skip && !isParked(step) && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, reason))}>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{steps.length === 0 && (
|
||||
<tr><td className="adm-td dim" colSpan={7}>No steps have been materialised yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
Steps run strictly in order within a phase, and the phase ends when every one of them has
|
||||
finished. A failed step is not retried by the runner past its attempt limit — resuming a
|
||||
paused run carries the phase past it, and <em>Retry & resume</em> puts the step the run is
|
||||
stopped at back in the queue.
|
||||
</p>
|
||||
|
||||
{/* ── The log ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>Log</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
The run’s own diagnostic record, newest first — this is what answers “why didn’t phase 3
|
||||
start?” without reading server logs. Who published or started what is recorded separately, in
|
||||
the activity log.
|
||||
</p>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem', whiteSpace: 'nowrap' }}>{clock(line.at)}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem' }}>{line.phase || ''}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>{describeLogLine(line)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{lines.length === 0 && <tr><td className="adm-td dim">Nothing logged yet.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user