Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's published version may carry `starts`: a trigger, a condition and a cooldown. A matching firing creates a run of it, now. - spec: `starts` validated by the phase gate's own function (same grammar, same dormant rule; a dormant start refuses publish), a cooldown from 0m to 7d (30m default), at most ten, event.* refused. - events/starts.js: the emit path's third subscriber, beside dispatch and the gate observer. An unverified version starts nothing; one run at a time per definition and the cooldown are one conditional UPDATE on the new event_trigger_starts table; the occurrence key makes one firing reported twice one run. Ignored firings are counted by why. - event_runs.origin: the firing (trigger, label, time, subject, matched condition, declared variables), also on the run.created audit line. - Editor: a "Started by the game" panel using the gate's condition builder, with the ignored-firings line. Run console: "Started because ... fired". - No module contract change and no MODULE_API bump. Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the dev database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
736 lines
34 KiB
JavaScript
736 lines
34 KiB
JavaScript
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,
|
|
describeParams,
|
|
describeOrigin,
|
|
} 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 seven 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',
|
|
// Green, like `reverted`: the game ended it at the deadline it was given, which
|
|
// is the plan working (MODULE_API 1.11.0). `orphaned` is the amber one.
|
|
expired: '#8fc79a',
|
|
}
|
|
|
|
const RESOURCE_WORD = {
|
|
pending: 'recorded, unconfirmed',
|
|
confirmed: 'still out there',
|
|
reverting: 'being given back',
|
|
reverted: 'given back',
|
|
orphaned: 'gone',
|
|
drifted: 'someone else moved it',
|
|
expired: 'ended by the game on time',
|
|
}
|
|
|
|
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 (
|
|
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
|
|
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
|
|
Phase <strong>{gate.phase}</strong>
|
|
{current && !gate.satisfied ? ' has not started' : ''}
|
|
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
|
|
{gate.stalled && ' — STALLED'}
|
|
</div>
|
|
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
|
|
{gate.kind === 'after' ? (
|
|
<>
|
|
<dt className="dim">waiting for</dt>
|
|
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
|
|
<dt className="dim">until</dt>
|
|
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
|
|
</>
|
|
) : (
|
|
<>
|
|
<dt className="dim">waiting on</dt>
|
|
<dd style={{ margin: 0 }}>
|
|
<code>{gate.waitingOn}</code>
|
|
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> — any firing</span>}
|
|
</dd>
|
|
<dt className="dim">seen so far</dt>
|
|
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
|
|
</>
|
|
)}
|
|
<dt className="dim">since</dt>
|
|
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
|
|
{gate.kind === 'on' && gate.lastEvent && (
|
|
<>
|
|
<dt className="dim">last related event</dt>
|
|
<dd style={{ margin: 0 }}>
|
|
<code>{gate.lastEvent.trigger}</code> 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 && (
|
|
<span className="dim">
|
|
{' ('}
|
|
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
|
|
{')'}
|
|
</span>
|
|
)}
|
|
</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <Loading />
|
|
if (error) return <ErrorState message={error} />
|
|
if (!run) return <ErrorState message="No such run." />
|
|
|
|
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 (
|
|
<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>
|
|
{/* Why it exists, when a game firing started it (D370). */}
|
|
{describeOrigin(run.origin) && (
|
|
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
|
{describeOrigin(run.origin)}
|
|
<span className="dim"> · {when(run.origin.occurredAt)}</span>
|
|
</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.advance}
|
|
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
|
|
Advance phase
|
|
</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>
|
|
{/* 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 && (
|
|
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
|
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason, false))}>
|
|
Cancel, leave changes up
|
|
</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>
|
|
)}
|
|
|
|
{/* ── 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 && (
|
|
<div
|
|
className="panel-flat"
|
|
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
|
|
>
|
|
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
|
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
|
|
</h3>
|
|
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
|
|
{waiting ? (
|
|
<>
|
|
Every step of this phase has finished. It advances when the condition below is met —
|
|
nothing times out, and <em>Advance phase</em> 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.'
|
|
)}
|
|
</p>
|
|
{gates.map((gate) => (
|
|
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 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 && (
|
|
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
|
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Caps</h3>
|
|
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
|
<tbody>
|
|
{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 (
|
|
<tr key={b.dimension}>
|
|
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
|
<code style={{ fontSize: '0.78rem' }}>{b.dimension}</code>
|
|
</td>
|
|
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: full ? '#d9c184' : undefined }}>
|
|
{b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`}
|
|
</td>
|
|
<td style={{ width: '100%', padding: '3px 0' }}>
|
|
{b.cap === null ? (
|
|
<span className="dim" style={{ fontSize: '0.78rem' }}>no cap</span>
|
|
) : (
|
|
<span style={{ display: 'block', height: 6, background: 'var(--rule)', borderRadius: 3 }}>
|
|
<span
|
|
style={{
|
|
display: 'block',
|
|
height: 6,
|
|
width: `${Math.round(spent * 100)}%`,
|
|
background: full ? '#d9c184' : '#8fc79a',
|
|
borderRadius: 3,
|
|
}}
|
|
/>
|
|
</span>
|
|
)}
|
|
</td>
|
|
{/* 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. */}
|
|
<td className="dim" style={{ padding: '3px 0 3px 12px', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
|
{b.from || ''}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 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') && (
|
|
<div
|
|
className="panel-flat"
|
|
style={{
|
|
padding: 14,
|
|
marginBottom: 14,
|
|
borderLeft: `3px solid ${unresolved > 0 ? '#d9c184' : 'var(--rule)'}`,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
|
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
|
What this run changed
|
|
</h3>
|
|
{/* 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 && (
|
|
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
|
onClick={() => act(() => api.admin.cleanupEventRun(run.id))}>
|
|
Try cleanup again
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
|
{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;{' '}
|
|
<em>Try cleanup again</em> clears that count and asks once more.
|
|
</>
|
|
) : (
|
|
'Everything this run created or borrowed has been given back.'
|
|
)}
|
|
</p>
|
|
{resources.length === 0 ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
|
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.
|
|
</p>
|
|
) : (
|
|
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
|
<tbody>
|
|
{resources.map((r) => (
|
|
<tr key={r.id}>
|
|
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
|
<code style={{ fontSize: '0.78rem' }}>{r.kind}</code>{' '}
|
|
<code className="dim" style={{ fontSize: '0.78rem' }}>{r.ref}</code>
|
|
</td>
|
|
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: RESOURCE_COLOR[r.status] }}>
|
|
{RESOURCE_WORD[r.status] || r.status}
|
|
</td>
|
|
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
|
{r.module}
|
|
{r.leaseUntil ? ` · until ${clock(r.leaseUntil)}` : ''}
|
|
{r.revertAttempts > 0 ? ` · ${r.revertAttempts} attempt${r.revertAttempts === 1 ? '' : 's'}` : ''}
|
|
</td>
|
|
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
|
{r.lastError || ''}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 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 && (
|
|
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
|
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
|
Who took part
|
|
</h3>
|
|
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
|
{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{' '}
|
|
<code style={{ fontSize: '0.78rem' }}>core.results.publish</code> step in the event
|
|
itself.
|
|
</>
|
|
)}
|
|
</p>
|
|
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
|
<tbody>
|
|
{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => (
|
|
<tr key={p.memberKey}>
|
|
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', width: 34, textAlign: 'right' }}>
|
|
{p.rank ?? ''}
|
|
</td>
|
|
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
|
<code style={{ fontSize: '0.78rem' }}>{p.memberKey}</code>
|
|
</td>
|
|
{/* 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. */}
|
|
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
|
{p.userId ? `account ${p.userId}` : 'no linked account'}
|
|
</td>
|
|
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>{p.score}</td>
|
|
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
|
{clock(p.joinedAt)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{participants.length > PARTICIPANTS_SHOWN && (
|
|
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.78rem' }}>
|
|
and {participants.length - PARTICIPANTS_SHOWN} more.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 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' }}>
|
|
{describeParams(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>
|
|
)
|
|
}
|