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:
@@ -53,6 +53,7 @@ const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4"
|
||||
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
|
||||
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
|
||||
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
|
||||
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /><circle cx="12" cy="15" r="1.4" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
@@ -120,6 +121,20 @@ export const NAV = [
|
||||
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own top-level group rather than a row under Content, and staff-wide
|
||||
// rather than admin-only. Both follow EVENTS.md §K: every read here is
|
||||
// `staff`, and the moderator's entire power over this feature is the run
|
||||
// console — the thing they open when an event is doing something wrong at
|
||||
// 2am. Hiding it from them would leave the one role that exists for incident
|
||||
// response unable to see the incident. The narrower gates live on the
|
||||
// actions: authoring is admin+editor and publish/start are admin only, both
|
||||
// enforced server-side and mirrored on the buttons.
|
||||
title: 'Events',
|
||||
items: [
|
||||
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
@@ -202,6 +217,8 @@ const TITLES = {
|
||||
'/admin/engagement/suppressions': 'Suppressions',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
'/admin/engagement/retention': 'Retention',
|
||||
'/admin/events': 'Events',
|
||||
'/admin/events/new': 'New event',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
@@ -222,6 +239,10 @@ function sectionTitle(pathname) {
|
||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||
if (pathname.startsWith('/admin/users/')) return 'User'
|
||||
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
|
||||
// /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
|
||||
// belong to the same section as far as the page title is concerned.
|
||||
if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
|
||||
if (pathname.startsWith('/admin/events/')) return 'Event'
|
||||
return 'Admin'
|
||||
}
|
||||
|
||||
|
||||
505
client/src/routes/admin/views/EventEditor.jsx
Normal file
505
client/src/routes/admin/views/EventEditor.jsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
formFromDefinition,
|
||||
payloadFromForm,
|
||||
blankPhase,
|
||||
blankStep,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// **A vertical timeline, not a node graph**, and that is a decision about what
|
||||
// the engine can actually do rather than a matter of taste. The condition
|
||||
// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at
|
||||
// depth five — so a canvas would promise power this project has never handed an
|
||||
// operator. Phases in order, each with its steps in order, says exactly what the
|
||||
// runner does with them.
|
||||
//
|
||||
// **Core renders no game word here.** Every label on a step comes from the
|
||||
// action's own registration — its `label`, its params' names, their descriptions
|
||||
// and their examples — so an installed module's vocabulary appears without core
|
||||
// knowing any of it, and `check:modules` already fails core's build on a UO
|
||||
// identifier.
|
||||
//
|
||||
// **The params box is a raw JSON field and it is captioned as a placeholder**,
|
||||
// because that is what it is: Phase 13 replaces it with the schema-driven form
|
||||
// the condition builder already models. What makes it usable in the meantime is
|
||||
// that a new step arrives PREFILLED from the action's declared examples, and the
|
||||
// declaration is rendered beside the box — every param's name, type, whether it
|
||||
// is required, and what a value looks like. All of that was already in the
|
||||
// catalog; none of it is a second copy of anything.
|
||||
|
||||
const DORMANT_NOTE =
|
||||
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
|
||||
|
||||
export default function EventEditor() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const isNew = id === 'new'
|
||||
|
||||
const [form, setForm] = useState(null)
|
||||
const [event, setEvent] = useState(null)
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [series, setSeries] = useState([])
|
||||
const [versions, setVersions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [problems, setProblems] = useState([])
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
// Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
|
||||
// — the run console is their whole job and a definition is what a run is OF. But
|
||||
// authoring is `admin` + `editor`, so Save has to follow the route it calls.
|
||||
// Offering it and letting the server answer 403 is the shape §K calls "a gate
|
||||
// nobody notices was missing", only inverted: a button that does nothing.
|
||||
const mayAuthor = isAdmin || user?.role === 'editor'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()])
|
||||
setCatalog(cat)
|
||||
setSeries(ser.series || [])
|
||||
if (isNew) {
|
||||
setEvent(null)
|
||||
setVersions([])
|
||||
setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
|
||||
return
|
||||
}
|
||||
const [{ event: loaded }, { versions: history }] = await Promise.all([
|
||||
api.admin.getEvent(id),
|
||||
api.admin.listEventVersions(id),
|
||||
])
|
||||
setEvent(loaded)
|
||||
setVersions(history || [])
|
||||
setForm(formFromDefinition(loaded))
|
||||
}, [id, isNew])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const actions = useMemo(() => catalog?.actions || [], [catalog])
|
||||
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
|
||||
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const setPhase = (pi, patch) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)),
|
||||
}))
|
||||
|
||||
const setStep = (pi, si, patch) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
phases: f.phases.map((p, i) =>
|
||||
i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
|
||||
),
|
||||
}))
|
||||
|
||||
const movePhase = (pi, delta) =>
|
||||
setForm((f) => {
|
||||
const next = [...f.phases]
|
||||
const to = pi + delta
|
||||
if (to < 0 || to >= next.length) return f
|
||||
;[next[pi], next[to]] = [next[to], next[pi]]
|
||||
return { ...f, phases: next }
|
||||
})
|
||||
|
||||
const moveStep = (pi, si, delta) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
phases: f.phases.map((p, i) => {
|
||||
if (i !== pi) return p
|
||||
const steps = [...p.steps]
|
||||
const to = si + delta
|
||||
if (to < 0 || to >= steps.length) return p
|
||||
;[steps[si], steps[to]] = [steps[to], steps[si]]
|
||||
return { ...p, steps }
|
||||
}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Changing a step's action REPLACES its params with the new action's examples.
|
||||
*
|
||||
* The alternative — keeping what was typed — leaves an object whose keys belong
|
||||
* to a different action, and the save refuses it with "x is not a param of y"
|
||||
* for every one of them. Replacing is the honest move and it is not
|
||||
* destructive in any way an author minds: they have just said this step does
|
||||
* something else.
|
||||
*/
|
||||
const changeAction = (pi, si, actionId) => {
|
||||
const action = actionById.get(actionId)
|
||||
const fresh = blankStep(action)
|
||||
setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' })
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setProblems([])
|
||||
setNotice(null)
|
||||
const built = payloadFromForm(form)
|
||||
if (!built.ok) {
|
||||
setProblems(built.errors)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (isNew) {
|
||||
const result = await api.admin.createEvent(built.payload)
|
||||
navigate(`/admin/events/${result.event.id}`, { replace: true })
|
||||
} else {
|
||||
const result = await api.admin.updateEvent(id, built.payload)
|
||||
setEvent(result.event)
|
||||
setForm(formFromDefinition(result.event))
|
||||
setNotice('Saved.')
|
||||
}
|
||||
} catch (err) {
|
||||
// The server answers with every problem rather than the first, so an author
|
||||
// fixing a spec does it in one pass rather than six round trips.
|
||||
setProblems(err.body?.errors || [err.message])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const publish = async () => {
|
||||
setBusy(true)
|
||||
setProblems([])
|
||||
setNotice(null)
|
||||
try {
|
||||
const result = await api.admin.publishEvent(id)
|
||||
setEvent(result.event)
|
||||
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
|
||||
setNotice(`Published as v${result.version}.`)
|
||||
} catch (err) {
|
||||
setProblems(err.body?.errors || [err.message])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true)
|
||||
setProblems([])
|
||||
try {
|
||||
const result = await api.admin.startEventRun(id, {})
|
||||
navigate(`/admin/events/runs/${result.run.id}`)
|
||||
} catch (err) {
|
||||
setProblems(err.body?.errors || [err.message])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !form) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const archived = event?.state === 'archived'
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
|
||||
{isNew ? 'New event' : event?.title}
|
||||
</h2>
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
||||
{isNew ? (
|
||||
'The slug is derived from the title once and frozen afterwards — the public event page lives at it.'
|
||||
) : (
|
||||
<>
|
||||
{event?.slug} · {event?.state}
|
||||
{event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{mayAuthor && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={save}>
|
||||
{isNew ? 'Create draft' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{/* Publish and start are admin ONLY (§N2) and not the same gate as the
|
||||
live controls: publishing commits a definition a schedule will later
|
||||
start unattended. */}
|
||||
{!isNew && isAdmin && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={publish}>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{!isNew && isAdmin && event?.state === 'ready' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={start}>
|
||||
Start now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!mayAuthor && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
You can read this definition and watch its runs. Editing and publishing an event are an
|
||||
admin or editor's, and starting one is an admin's alone — live control of a run already in
|
||||
flight is yours.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{archived && (
|
||||
<p className="sans" style={{ fontSize: '0.84rem', color: '#d9c184' }}>
|
||||
This definition is archived. It is kept so its past runs can still be explained, and it
|
||||
cannot be edited or run again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
|
||||
|
||||
{problems.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d98b84' }}>
|
||||
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>That did not save:</p>
|
||||
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.82rem' }}>
|
||||
{problems.map((p) => <li key={p}>{p}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Basics ── */}
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Title</span>
|
||||
<input className="input" value={form.title} onChange={(e) => set({ title: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Series</span>
|
||||
<select className="select" value={form.seriesId} onChange={(e) => set({ seriesId: e.target.value })}>
|
||||
<option value="">Not part of a series</option>
|
||||
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Timezone</span>
|
||||
<input className="input" value={form.timezone} onChange={(e) => set({ timezone: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Grace window (seconds)</span>
|
||||
<input className="input" type="number" min="0" value={form.graceSeconds}
|
||||
onChange={(e) => set({ graceSeconds: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Concurrency key</span>
|
||||
<input className="input" value={form.concurrencyKey} placeholder="invasion:{region}"
|
||||
onChange={(e) => set({ concurrencyKey: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<label style={{ display: 'block', marginTop: 12 }}>
|
||||
<span className="field-label">Summary</span>
|
||||
<input className="input" value={form.summary} onChange={(e) => set({ summary: e.target.value })} />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginTop: 12 }}>
|
||||
<span className="field-label">Storyline</span>
|
||||
<textarea className="input" rows={4} value={form.body} onChange={(e) => set({ body: e.target.value })} />
|
||||
</label>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
|
||||
The grace window is how late this event may still start: past it an occurrence becomes
|
||||
<em> missed</em> rather than beginning hours after it was announced. Two runs sharing a
|
||||
concurrency key never overlap — <code>{'{placeholders}'}</code> are filled from the run’s own
|
||||
params.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Schedule ── */}
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Schedule</h3>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||||
<strong>Started by hand.</strong> Recurrence — once, weekly, monthly on the nth weekday —
|
||||
is computed in the event’s own timezone and arrives in the next phase, with the calendar.
|
||||
Until then an occurrence exists because somebody pressed <em>Start now</em>, and the
|
||||
schedule shape a definition may carry is deliberately the single one the runner honours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── The phase timeline ── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Phases</h3>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={archived}
|
||||
onClick={() => set({ phases: [...form.phases, blankPhase(form.phases)] })}>
|
||||
Add phase
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form.phases.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
No phases yet. An event needs at least one phase with at least one step before it can be
|
||||
published.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{form.phases.map((phase, pi) => (
|
||||
<div key={pi} className="panel-flat" style={{ padding: 14, marginBottom: 12, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 200px' }}>
|
||||
<span className="field-label">Phase {pi + 1} — label</span>
|
||||
<input className="input" value={phase.label} onChange={(e) => setPhase(pi, { label: e.target.value })} />
|
||||
</label>
|
||||
<label style={{ flex: '0 1 180px' }}>
|
||||
<span className="field-label">Key</span>
|
||||
<input className="input" value={phase.key} onChange={(e) => setPhase(pi, { key: e.target.value })} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === 0} onClick={() => movePhase(pi, -1)}>↑</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === form.phases.length - 1} onClick={() => movePhase(pi, 1)}>↓</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
onClick={() => set({ phases: form.phases.filter((_, i) => i !== pi) })}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
||||
The key is what the run console groups by and what “phase 3 has not started” names, so it
|
||||
cannot change once runs exist. A phase advances when every one of its steps is finished;
|
||||
advancing on a condition instead is a later phase.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{phase.steps.map((step, si) => {
|
||||
const action = actionById.get(step.actionId)
|
||||
return (
|
||||
<div key={si} style={{ borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12, marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Step {si + 1} — action</span>
|
||||
<select className="select" value={step.actionId} onChange={(e) => changeAction(pi, si, e.target.value)}>
|
||||
<option value="">Pick an action…</option>
|
||||
{actions.map((a) => <option key={a.id} value={a.id}>{a.label} — {a.id}</option>)}
|
||||
{/* A step whose module has been uninstalled keeps its
|
||||
action id, so the select must be able to show a value
|
||||
that is not in the catalog rather than silently
|
||||
resetting the step to nothing. */}
|
||||
{step.actionId && !action && <option value={step.actionId}>{step.actionId} (not installed)</option>}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ flex: '0 1 200px' }}>
|
||||
<span className="field-label">If it fails</span>
|
||||
<select className="select" value={step.onFailure} onChange={(e) => setStep(pi, si, { onFailure: e.target.value })}>
|
||||
<option value="">
|
||||
Default{action ? ` — ${catalog?.onFailureByRisk?.[action.risk] || 'pause'}` : ''}
|
||||
</option>
|
||||
{(catalog?.onFailure || []).map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === 0} onClick={() => moveStep(pi, si, -1)}>↑</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === phase.steps.length - 1} onClick={() => moveStep(pi, si, 1)}>↓</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
onClick={() => setPhase(pi, { steps: phase.steps.filter((_, j) => j !== si) })}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step.dormant && (
|
||||
<p className="sans" style={{ fontSize: '0.78rem', color: '#d9c184', margin: '8px 0 0' }}>{DORMANT_NOTE}</p>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||
{action.description} <span style={{ opacity: 0.75 }}>· risk: {action.risk} · reversible: {action.reversible}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: 12, marginTop: 10 }}>
|
||||
<label>
|
||||
<span className="field-label">Params (JSON)</span>
|
||||
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
|
||||
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem' }}
|
||||
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
A raw JSON field, and a placeholder: the form that renders each param from its
|
||||
declared type arrives with the authoring pass. What is saved is checked
|
||||
against the declaration either way.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<span className="field-label">What this action takes</span>
|
||||
{action ? (
|
||||
<table className="adm-table" style={{ fontSize: '0.78rem' }}>
|
||||
<tbody>
|
||||
{(action.params || []).map((p) => (
|
||||
<tr key={p.name}>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
|
||||
<code>{p.name}</code>
|
||||
{p.required && <span style={{ color: '#d98b84' }}> *</span>}
|
||||
</td>
|
||||
<td className="adm-td dim">{p.type}</td>
|
||||
<td className="adm-td">
|
||||
{p.description}
|
||||
<div className="dim">e.g. <code>{JSON.stringify(p.example)}</code></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(action.params || []).length === 0 && (
|
||||
<tr><td className="adm-td dim">This action takes no params.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
Pick an action and its parameters are listed here, straight from what the
|
||||
module declared.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 12 }} disabled={archived}
|
||||
onClick={() => setPhase(pi, { steps: [...phase.steps, blankStep(actions[0])] })}>
|
||||
Add step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isNew && versions.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginTop: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Versions</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>
|
||||
Publishing snapshots the whole spec into a version nothing ever edits. Editing this
|
||||
definition while a run is live is free — <strong>the live run keeps the version it
|
||||
pinned</strong> and is unaffected by anything on this screen.
|
||||
</p>
|
||||
<table className="adm-table">
|
||||
<tbody>
|
||||
{versions.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>v{v.version}{v.current && <span className="dim"> · current</span>}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{new Date(v.publishedAt).toLocaleString()}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{v.publishedByUsername || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
352
client/src/routes/admin/views/EventRun.jsx
Normal file
352
client/src/routes/admin/views/EventRun.jsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
runStatusWord,
|
||||
isTerminalRun,
|
||||
isParked,
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
describeLogLine,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// One run: where it is, what each of its steps did, what a human can still do
|
||||
// about it, and the diagnostic log underneath. Staff-wide to read; the six
|
||||
// controls are `admin` + `moderator`, and the server re-checks every one of them
|
||||
// against the run's live status — this screen predicts, it does not decide.
|
||||
//
|
||||
// **It polls rather than streaming.** A run changes on the runner's tick, which
|
||||
// is a fifteen-second clock, and a console watched for the length of an event is
|
||||
// a tab left open for two hours: an SSE channel for that is a connection held
|
||||
// per staff member for a screen that could not use the latency. The poll stops
|
||||
// the moment the run reaches a terminal status, because a completed run has
|
||||
// nothing further to say.
|
||||
//
|
||||
// **The parked step is the thing this screen exists to make impossible to
|
||||
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
|
||||
// stay that way for ever unless somebody presses confirm. It is called out above
|
||||
// the step list rather than being one row in it.
|
||||
|
||||
const POLL_MS = 5000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
completed: '#8fc79a',
|
||||
}
|
||||
|
||||
const STEP_COLOR = {
|
||||
done: '#8fc79a',
|
||||
failed: '#d98b84',
|
||||
refused: '#d9c184',
|
||||
skipped: 'var(--muted)',
|
||||
cancelled: 'var(--muted)',
|
||||
}
|
||||
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
|
||||
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
|
||||
|
||||
export default function EventRun() {
|
||||
const { runId } = useParams()
|
||||
const [run, setRun] = useState(null)
|
||||
const [steps, setSteps] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notes, setNotes] = useState({})
|
||||
const [reason, setReason] = useState('')
|
||||
const alive = useRef(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [detail, log] = await Promise.all([
|
||||
api.admin.getEventRun(runId),
|
||||
api.admin.getEventRunLog(runId, 200),
|
||||
])
|
||||
if (!alive.current) return
|
||||
setRun(detail.run)
|
||||
setSteps(detail.steps || [])
|
||||
setCounts(detail.counts || {})
|
||||
setLines(log.log || [])
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (alive.current) setError(err.message)
|
||||
} finally {
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive.current = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
// The poll, and its own off switch. A terminal run is not re-read: it cannot
|
||||
// change, and a console left open on last night's completed event should not
|
||||
// be a request every five seconds until the tab is closed.
|
||||
useEffect(() => {
|
||||
if (!run || isTerminalRun(run.status)) return undefined
|
||||
const timer = setInterval(() => {
|
||||
load().catch(() => {})
|
||||
}, POLL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [run, load])
|
||||
|
||||
/** Every control goes through here: press, reload, and surface a refusal. */
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
} catch (err) {
|
||||
// A 409 is the ordinary answer to a button pressed against a run that has
|
||||
// moved on since the screen was drawn, so it is shown as a sentence rather
|
||||
// than as an error state — and the reload above has already re-drawn the
|
||||
// controls as they now stand.
|
||||
setProblem(err.body?.errors?.[0] || err.message)
|
||||
await load().catch(() => {})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !run) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!run) return <ErrorState message="No such run." />
|
||||
|
||||
const controls = runControlsFor(run)
|
||||
const parked = steps.filter(isParked)
|
||||
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
|
||||
<Link to={`/admin/events/${run.definitionId}`}>{run.definitionTitle}</Link>{' '}
|
||||
<span className="dim" style={{ fontWeight: 400 }}>v{run.version}</span>
|
||||
</h2>
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
||||
Occurrence {when(run.scheduledFor)}
|
||||
{run.scope ? ` · scope ${run.scope}` : ''}
|
||||
{run.rehearsal ? ' · rehearsal' : ''}
|
||||
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
|
||||
{runStatusWord(run.status)}
|
||||
{run.currentPhase && <span className="dim" style={{ fontSize: '0.82rem' }}> · {run.currentPhase}</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{run.health !== 'ok' && <span style={{ color: '#d9c184' }}>{run.health} · </span>}
|
||||
{summary || 'no steps'}
|
||||
{!isTerminalRun(run.status) && <span> · refreshing</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health is not status, which is the whole reason the two are separate
|
||||
columns — but the sentence has to agree with the status it sits beside.
|
||||
A degraded RUNNING run is the interesting case: still going, already in
|
||||
trouble. A degraded PAUSED run is not "still running", and saying so on
|
||||
the one screen an operator opens to find out what stopped it would be
|
||||
the console contradicting itself. Found in the browser walk. */}
|
||||
{run.health === 'degraded' && !isTerminalRun(run.status) && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d9c184', marginTop: 10 }}>
|
||||
{run.status === 'paused' ? (
|
||||
<>
|
||||
Something in this run failed, and it is waiting for a person. Resuming carries the phase
|
||||
past the failed step; <em>Retry & resume</em> puts that step back in the queue first.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Something in this run has already had to be retried. It is still running — this is
|
||||
what “degraded” means, and the log below says what happened.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{run.lastError && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{run.lastError}</p>
|
||||
)}
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{problem}</p>
|
||||
)}
|
||||
|
||||
{/* ── The run controls ── */}
|
||||
<div className="panel-flat" style={{ padding: '12px 14px', margin: '14px 0', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Reason (recorded with your name)</span>
|
||||
<input className="input" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="optional" />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.pause}
|
||||
onClick={() => act(() => api.admin.pauseEventRun(run.id, reason))}>
|
||||
Pause
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.resume}
|
||||
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
|
||||
Resume
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
|
||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
|
||||
Cancel run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isTerminalRun(run.status) && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>
|
||||
This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
|
||||
— a run pins the version it started from so that it can still be explained later.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Waiting on a person ── */}
|
||||
{parked.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Waiting on a person</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
Nothing else in this phase runs until each of these is confirmed. There is no timeout —
|
||||
a cue posted on Friday is still waiting on Monday.
|
||||
</p>
|
||||
{parked.map((step) => (
|
||||
<div key={step.id} style={{ marginBottom: 10 }}>
|
||||
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.86rem' }}>
|
||||
{step.params?.instruction || step.actionId}
|
||||
{step.params?.assignee && <span className="dim"> — for {step.params.assignee}</span>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">What you did (optional)</span>
|
||||
<input className="input" value={notes[step.id] || ''}
|
||||
onChange={(e) => setNotes((n) => ({ ...n, [step.id]: e.target.value }))} />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.confirmEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Confirm — done
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Skip it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The steps ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Steps</h3>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Phase</th>
|
||||
<th className="adm-th">#</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Attempts</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map((step) => {
|
||||
const c = stepControlsFor(run, step, steps)
|
||||
return (
|
||||
<tr key={step.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>
|
||||
{step.phase}
|
||||
{step.phase === run.currentPhase && <span className="dim"> ·now</span>}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{step.seq + 1}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
|
||||
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(step.params)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
|
||||
{isParked(step) ? <span style={{ color: '#d9c184' }}>waiting</span> : step.status}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{step.attempts}
|
||||
{step.dueAt && new Date(step.dueAt) > new Date() && (
|
||||
<div style={{ fontSize: '0.74rem' }}>due {clock(step.dueAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.78rem', maxWidth: 280, overflowWrap: 'anywhere' }}>
|
||||
{step.lastError || ''}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{c.retry && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.retryEventStep(run.id, step.id))}>
|
||||
Retry & resume
|
||||
</button>
|
||||
)}
|
||||
{c.skip && !isParked(step) && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, reason))}>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{steps.length === 0 && (
|
||||
<tr><td className="adm-td dim" colSpan={7}>No steps have been materialised yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
Steps run strictly in order within a phase, and the phase ends when every one of them has
|
||||
finished. A failed step is not retried by the runner past its attempt limit — resuming a
|
||||
paused run carries the phase past it, and <em>Retry & resume</em> puts the step the run is
|
||||
stopped at back in the queue.
|
||||
</p>
|
||||
|
||||
{/* ── The log ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>Log</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
The run’s own diagnostic record, newest first — this is what answers “why didn’t phase 3
|
||||
start?” without reading server logs. Who published or started what is recorded separately, in
|
||||
the activity log.
|
||||
</p>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem', whiteSpace: 'nowrap' }}>{clock(line.at)}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem' }}>{line.phase || ''}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>{describeLogLine(line)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{lines.length === 0 && <tr><td className="adm-td dim">Nothing logged yet.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
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