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. // // **Phase 5 gave it a second one of those, and the panel is this phase's real // deliverable** (§ Observability): a phase whose steps have all finished and // whose advance condition has not been met is also `running` and also // healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the // steps, in the condition builder's own words — and the sentence is the // SERVER'S. `gates[].where` arrives already rendered, because those labels are // defined in the condition grammar and a second renderer in the browser would // be a second opinion about what `gte` reads as. // // **Phase 8 gave it a third, and it is the one that outlives the event.** The // resource ledger is what this run changed in the world and what became of it, // and its unresolved rows are the reason a `completed` run can still need a // person — EVENTS.md §L: a run reaches `completed` with `cleanup_status = // 'incomplete'` rather than being held open, because a tidy `completed` row over // a shard full of orphaned monsters is the failure that would end this feature's // credibility on its first bad night. The panel is shown on finished runs for // exactly that reason, and it is the only panel here whose empty state matters. const POLL_MS = 5000 const STATUS_COLOR = { failed: '#d98b84', missed: '#d98b84', paused: '#d9c184', cancelled: 'var(--muted)', running: '#8fc79a', completed: '#8fc79a', } // The six ledger statuses, in the two groups that matter to a reader: green is // resolved, amber wants a person. `orphaned` and `drifted` are amber rather than // red because neither is a fault — one thing vanished, the other was taken by // somebody with every right to take it — and red is reserved for "this did not // come back and core kept asking". const RESOURCE_COLOR = { reverted: '#8fc79a', confirmed: '#d9c184', pending: '#d9c184', reverting: '#d9c184', drifted: '#d9c184', orphaned: '#d9c184', } const RESOURCE_WORD = { pending: 'recorded, unconfirmed', confirmed: 'still out there', reverting: 'being given back', reverted: 'given back', orphaned: 'gone', drifted: 'someone else moved it', } 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() : '') // How many participants the console renders before it stops and counts the rest. // A run's participants are people and a busy event has hundreds; this panel is a // check that the collection worked and that the ranking looks right, not the // results page — that is Phase 14's, and it is public. const PARTICIPANTS_SHOWN = 50 /** * Seconds as an operator reads them — the same vocabulary the spec authors a * gate in, so "28 min" on this screen and `after: '30m'` in the editor are * obviously the same kind of thing. */ function elapsed(seconds) { const s = Math.max(0, Number(seconds) || 0) if (s < 60) return `${s} sec` if (s < 3600) return `${Math.floor(s / 60)} min` const h = Math.floor(s / 3600) const m = Math.floor((s % 3600) / 60) return m ? `${h} hr ${m} min` : `${h} hr` } /** * One phase gate, as the panel draws it. * * The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and * was released by the third boss" is the same question as the live one, asked * after the fact, and it is the one an operator asks the morning after. */ function GateRow({ gate, current }) { const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184' return (
Phase {gate.phase} {current && !gate.satisfied ? ' has not started' : ''} {gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`} {gate.stalled && ' — STALLED'}
{gate.kind === 'after' ? ( <>
waiting for
{elapsed(gate.after)} from the start of the phase
until
{when(gate.dueAt)}
) : ( <>
waiting on
{gate.waitingOn} {gate.where ? <> where {gate.where} : — any firing}
seen so far
{gate.seen} of {gate.needed}
)}
since
{when(gate.since)} ({elapsed(gate.elapsedSeconds)})
{gate.kind === 'on' && gate.lastEvent && ( <>
last related event
{gate.lastEvent.trigger} at {clock(gate.lastEventAt)} {' — '} {/* The near miss is the valuable half: "the boss did spawn, in Britain" and "no boss has spawned" are different answers and look identical without this line. */} {gate.lastEvent.matched ? 'counted' : 'did not count'} {Object.keys(gate.lastEvent.variables || {}).length > 0 && ( {' ('} {Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} {')'} )}
)}
) } export default function EventRun() { const { runId } = useParams() const [run, setRun] = useState(null) const [steps, setSteps] = useState([]) const [counts, setCounts] = useState({}) const [gates, setGates] = useState([]) // The caps this run was given and what it has spent of them (Phase 6). Copied // into the run when it was created, so this is what THIS run is allowed rather // than what the switchboard says today. const [budget, setBudget] = useState([]) // What this run created or borrowed, and what became of each (Phase 8). const [resources, setResources] = useState([]) const [unresolved, setUnresolved] = useState(0) // Who took part, best first (Phase 10). Present whether or not the results // have been published; `run.resultsPublishedAt` is what says which. const [participants, setParticipants] = 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 || {}) setGates(detail.gates || []) setBudget(detail.budget || []) setResources(detail.resources || []) setUnresolved(detail.unresolvedResources || 0) setParticipants(detail.participants || []) 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 if (error) return if (!run) return const controls = runControlsFor(run, gates, steps) const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied) const parked = steps.filter(isParked) const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ') return (

{run.definitionTitle}{' '} v{run.version}

Occurrence {when(run.scheduledFor)} {run.scope ? ` · scope ${run.scope}` : ''} {run.rehearsal ? ' · rehearsal' : ''} {run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}

{runStatusWord(run.status)} {run.currentPhase && · {run.currentPhase}}
{run.health !== 'ok' && {run.health} · } {summary || 'no steps'} {!isTerminalRun(run.status) && · refreshing}
{/* 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) && (

{run.status === 'paused' ? ( <> Something in this run failed, and it is waiting for a person. Resuming carries the phase past the failed step; Retry & resume 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. )}

)} {run.lastError && (

{run.lastError}

)} {problem && (

{problem}

)} {/* ── The run controls ── */}
{/* The separate, admin-only decision (§L). It is a second button rather than a checkbox on the first because the two are not variants of one action: one gives the world back, the other deliberately leaves it changed. A checkbox next to Cancel is a thing an operator unticks by accident at two in the morning. The server refuses this to a moderator, and the refusal arrives as a sentence in `problem`. */} {controls.cancel && ( )}
{isTerminalRun(run.status) && (

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.

)} {/* ── Why this phase has not started (Phase 5) ── Above the step list for the same reason the parked cue is: a phase waiting on a condition is `running` and looks completely healthy, and the one screen an operator opens to find out why nothing is happening must say so before they have to read a log. */} {gates.length > 0 && (

{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}

{waiting ? ( <> Every step of this phase has finished. It advances when the condition below is met — nothing times out, and Advance phase is how a person overrides it. {waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'} ) : ( 'What each phase of this run waited for, and what released it.' )}

{gates.map((gate) => ( ))}
)} {/* ── What this run is allowed, and what it has spent ── A meter rather than a sentence: a cap is two numbers and a name, and unlike a gate it needs no grammar rendered to be read. It is shown for every run that has a budget at all, finished ones included — "how much did last night's invasion actually spawn" is the same question asked the morning after. */} {budget.length > 0 && (

Caps

{budget.map((b) => { const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1) const full = b.cap !== null && b.consumed >= b.cap return ( {/* Which switch set the number, so an operator can trace a cap back to a thing they can change rather than wondering where 30 came from. */} ) })}
{b.dimension} {b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`} {b.cap === null ? ( no cap ) : ( )} {b.from || ''}
)} {/* ── What this run changed in the world (Phase 8) ── The WHOLE ledger, reverted rows included: "how much did last night's invasion actually spawn, and did all of it come back" is one question with two halves, and a list of only the failures answers neither. Shown on finished runs for the same reason the caps meter is. */} {(resources.length > 0 || run.cleanupStatus === 'incomplete') && (
0 ? '#d9c184' : 'var(--rule)'}`, }} >

What this run changed

{/* The manual retry. Offered only on a terminal run, because a run still in flight has a ledger that is still growing and reverting a resource the next step is about to use would be undoing an event while it is happening. */} {isTerminalRun(run.status) && unresolved > 0 && ( )}

{unresolved > 0 ? ( <> {unresolved} of these {unresolved === 1 ? 'is' : 'are'} still unresolved. The runner gives them back on its own and stops asking after a few tries;{' '} Try cleanup again clears that count and asks once more. ) : ( 'Everything this run created or borrowed has been given back.' )}

{resources.length === 0 ? (

Nothing named — a step changed the world and its answer never arrived, so core kept the record it wrote beforehand and will ask the module to undo it by key.

) : ( {resources.map((r) => ( ))}
{r.kind}{' '} {r.ref} {RESOURCE_WORD[r.status] || r.status} {r.module} {r.leaseUntil ? ` · until ${clock(r.leaseUntil)}` : ''} {r.revertAttempts > 0 ? ` · ${r.revertAttempts} attempt${r.revertAttempts === 1 ? '' : 's'}` : ''} {r.lastError || ''}
)}
)} {/* ── Who took part (Phase 10) ── Shown whenever a module has reported anybody, published or not — and the difference between the two is the whole point of the line under the heading. A run whose participants are collected and unranked is a real state, not an error: an author has not placed a `core.results.publish` step, or has not run it yet. Saying "not published yet" is what stops somebody reading this table as the final standings. */} {participants.length > 0 && (

Who took part

{run.resultsPublishedAt ? ( <>Results published {clock(run.resultsPublishedAt)}. Ranked best first. ) : ( <> {participants.length} recorded, and the results have not been published — nothing outside this page shows them, and nobody has a rank yet. Publishing is a{' '} core.results.publish step in the event itself. )}

{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => ( {/* A participant with no `userId` is not a defect: it is somebody who turned up without a linked website account, and the module is the only thing that could have known otherwise. Saying so beats a blank cell. */} ))}
{p.rank ?? ''} {p.memberKey} {p.userId ? `account ${p.userId}` : 'no linked account'} {p.score} {clock(p.joinedAt)}
{participants.length > PARTICIPANTS_SHOWN && (

and {participants.length - PARTICIPANTS_SHOWN} more.

)}
)} {/* ── Waiting on a person ── */} {parked.length > 0 && (

Waiting on a person

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.

{parked.map((step) => (

{step.params?.instruction || step.actionId} {step.params?.assignee && — for {step.params.assignee}}

))}
)} {/* ── The steps ── */}

Steps

{steps.map((step) => { const c = stepControlsFor(run, step, steps) return ( ) })} {steps.length === 0 && ( )}
Phase # Action Status Attempts Detail
{step.phase} {step.phase === run.currentPhase && ·now} {step.seq + 1} {step.actionId}
{JSON.stringify(step.params)}
{isParked(step) ? waiting : step.status} {step.attempts} {step.dueAt && new Date(step.dueAt) > new Date() && (
due {clock(step.dueAt)}
)}
{step.lastError || ''} {c.retry && ( )} {c.skip && !isParked(step) && ( )}
No steps have been materialised yet.

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 Retry & resume puts the step the run is stopped at back in the queue.

{/* ── The log ── */}

Log

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.

{lines.map((line) => ( ))} {lines.length === 0 && }
{clock(line.at)} {line.phase || ''} {describeLogLine(line)}
Nothing logged yet.
) }