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:
260
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
260
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// Two tables on one screen: the definitions an operator authors, and the runs
|
||||
// those definitions have produced. They are together rather than on two nav rows
|
||||
// because the question this screen exists to answer is one question — "what is
|
||||
// scheduled, and what is happening right now" — and the second half of it is the
|
||||
// one somebody opens at 8pm on a Friday.
|
||||
//
|
||||
// **The waiting badge is the whole reason the run table is here rather than
|
||||
// buried a click away.** A run parked on a GM cue looks perfectly healthy: it is
|
||||
// `running`, nothing has failed, and it will stay that way for ever because it
|
||||
// is waiting for a person who does not know they are being waited for. The count
|
||||
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
|
||||
// it before it can say so.
|
||||
//
|
||||
// What is NOT here: a calendar. Recurrence and the month view are Phase 4, and a
|
||||
// definition today can only carry `schedule: { kind: 'manual' }` — so the honest
|
||||
// list is a list, and the screen says as much rather than showing an empty grid.
|
||||
|
||||
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
}
|
||||
|
||||
const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' }
|
||||
|
||||
const when = (value) => (value ? new Date(value).toLocaleString() : '—')
|
||||
|
||||
export default function EventsAdmin() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [events, setEvents] = useState([])
|
||||
const [runs, setRuns] = useState([])
|
||||
const [state, setState] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const mayAuthor = isAdmin || user?.role === 'editor'
|
||||
|
||||
const load = useCallback(async (nextState) => {
|
||||
const [defs, runList] = await Promise.all([
|
||||
api.admin.listEvents(nextState || undefined),
|
||||
api.admin.listEventRuns({ limit: 50 }),
|
||||
])
|
||||
setEvents(defs.events || [])
|
||||
setRuns(runList.runs || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(state)
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load, state])
|
||||
|
||||
// "Start now" is an occurrence whose instant is the present, not a separate
|
||||
// concept — the same route a scheduled occurrence will use in Phase 4. Admin
|
||||
// only, deliberately (§N2): starting commits the deployment to everything the
|
||||
// definition contains, unattended.
|
||||
const startNow = async (event) => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const result = await api.admin.startEventRun(event.id, {})
|
||||
navigate(`/admin/events/runs/${result.run.id}`)
|
||||
} catch (err) {
|
||||
setNotice(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !events.length && !runs.length) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const live = runs.filter((r) => !isTerminalRun(r.status))
|
||||
const waiting = live.filter((r) => r.waitingSteps > 0)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
|
||||
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
|
||||
published as an immutable version, and every occurrence of it runs against the version it
|
||||
pinned. Recurrence and the calendar arrive with the next phase — for now an occurrence is
|
||||
started by hand.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
<label>
|
||||
<span className="field-label">Show</span>
|
||||
<select className="select" value={state} onChange={(e) => setState(e.target.value)}>
|
||||
<option value="">All definitions</option>
|
||||
<option value="draft">Drafts</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
{mayAuthor && (
|
||||
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/new">
|
||||
New event
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p className="sans" style={{ fontSize: '0.84rem', color: '#d98b84', marginTop: 0 }}>{notice}</p>
|
||||
)}
|
||||
|
||||
{waiting.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: '12px 14px', marginBottom: 16, borderLeft: '3px solid #d9c184' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem' }}>
|
||||
<strong>{waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a
|
||||
person.</strong>{' '}
|
||||
<span className="dim">
|
||||
A cue holds its phase until somebody confirms it was done in-client — nothing else will
|
||||
move it.
|
||||
</span>
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{waiting.map((r) => (
|
||||
<Link key={r.id} className="pill" style={{ fontSize: '0.74rem' }} to={`/admin/events/runs/${r.id}`}>
|
||||
{r.definitionTitle} · {r.waitingSteps} waiting
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Definitions</h3>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{state ? 'Nothing matches that filter.' : 'No events have been authored yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th">Version</th>
|
||||
<th className="adm-th">Series</th>
|
||||
<th className="adm-th">Updated</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.85rem' }}>
|
||||
<Link to={`/admin/events/${e.id}`}>{e.title}</Link>
|
||||
<div className="dim" style={{ fontSize: '0.76rem' }}>{e.slug}</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{STATE_WORD[e.state] || e.state}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{e.currentVersion ? `v${e.currentVersion}` : <span className="dim">unpublished</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{e.seriesName || <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>{when(e.updatedAt)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
{/* Start is admin only and the button follows the route: an
|
||||
editor sees the definition and cannot commit the
|
||||
deployment to running it. */}
|
||||
{isAdmin && e.state === 'ready' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
disabled={busy} onClick={() => startNow(e)}>
|
||||
Start now
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>
|
||||
Recent runs
|
||||
{live.length > 0 && <span className="dim" style={{ fontWeight: 400 }}> · {live.length} in flight</span>}
|
||||
</h3>
|
||||
{runs.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing has run yet.</p>
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Occurrence</th>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Phase</th>
|
||||
<th className="adm-th">Health</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
|
||||
<Link to={`/admin/events/runs/${r.id}`}>{when(r.scheduledFor)}</Link>
|
||||
{r.rehearsal && <span className="dim" style={{ fontSize: '0.74rem' }}> · rehearsal</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.definitionTitle} <span className="dim">v{r.version}</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||||
{runStatusWord(r.status)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.currentPhase || <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: HEALTH_COLOR[r.health] || undefined }}>
|
||||
{r.health === 'ok' ? <span className="dim">ok</span> : r.health}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', fontSize: '0.78rem' }}>
|
||||
{r.waitingSteps > 0 && (
|
||||
<span style={{ color: '#d9c184' }}>
|
||||
waiting on {r.waitingSteps === 1 ? 'a person' : `${r.waitingSteps} people`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user