The four closed recurrence shapes computed in the definition's own IANA zone, a fourteen-day materialisation horizon with projections beyond it, series as a managed thing, and the admin calendar that replaces the plugin this feature exists to replace. An event now happens on its own. No schema change: Phase 1 built every column this needed. - events/recurrence.js is the ONE place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree. No date library added — Node ships the tzdata one would vendor, behind Intl. - The runner's materialise leg is now two halves: expand, then sweep. The window starts at `now - grace`, so an occurrence nobody could have seen is never invented retroactively; the horizon is what makes the missed sweep mean anything for a recurrence. - Publishing is the schedule switch and archiving turns it off, and publishing re-pins every occurrence that has not started. - A projection is never drawn over an instant a run occupies, so a cancelled occurrence does not reappear as a forecast. 54 new tests, incl. the DST fixture set the plan asked for and three new statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail (pre-existing CRLF). Walked end to end on the local review stack. Docs: RunicGateway/docs#PENDING Co-Authored-By: Claude <noreply@anthropic.com>
266 lines
11 KiB
JavaScript
266 lines
11 KiB
JavaScript
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.
|
|
//
|
|
// **The calendar is a separate screen, not a third table here.** It answers
|
|
// "when", this one answers "what" — and Phase 4, which built it, also made a
|
|
// definition able to carry a recurrence, so the two questions stopped having the
|
|
// same answer the moment an occurrence could exist before anybody pressed Start.
|
|
|
|
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. A definition can repeat — once, weekly, or on the nth weekday of the month, in its
|
|
own timezone — and the <Link to="/admin/events/calendar">calendar</Link> is where those
|
|
occurrences are read.
|
|
</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>
|
|
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/calendar">
|
|
Calendar
|
|
</Link>
|
|
{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>
|
|
)
|
|
}
|