`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
723 lines
34 KiB
JavaScript
723 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,
|
|
} 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 (
|
|
<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>
|
|
</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' }}>
|
|
{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>
|
|
)
|
|
}
|