Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
797 lines
37 KiB
JavaScript
797 lines
37 KiB
JavaScript
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,
|
|
blankAdvance,
|
|
ADVANCE_KINDS,
|
|
blankStep,
|
|
describeSchedule,
|
|
scheduleFromForm,
|
|
SCHEDULE_KINDS,
|
|
MONTHLY_NTHS,
|
|
WEEKDAYS,
|
|
} 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)
|
|
// The dry run's answer (Phase 6). Cleared on every save and every publish,
|
|
// because a report is a statement about a spec and both of those change it —
|
|
// a stale green report beside an edited plan is worse than no report.
|
|
const [report, setReport] = useState(null)
|
|
|
|
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])
|
|
// Phase 5. Served with the actions on the same route, so an EDITOR sees the
|
|
// same catalog an admin does — `/admin/engagement/triggers` is admin-only, and
|
|
// an editor writing a trigger id from memory into a field the save path then
|
|
// refuses is the failure this avoids.
|
|
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
|
|
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
|
|
|
|
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)
|
|
// A report describes a spec, and saving changes it. A green report left
|
|
// standing beside an edited plan is worse than no report at all.
|
|
setReport(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)
|
|
setReport(null)
|
|
try {
|
|
const result = await api.admin.publishEvent(id)
|
|
setEvent(result.event)
|
|
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
|
|
// The re-pin count is said out loud, because an editor who does not know
|
|
// their fix reached next Friday finds out on Friday.
|
|
setNotice(
|
|
result.repinned
|
|
? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
|
|
: `Published as v${result.version}.`,
|
|
)
|
|
} catch (err) {
|
|
setProblems(err.body?.errors || [err.message])
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The dry run (Phase 6).
|
|
*
|
|
* `admin, editor` — it dispatches nothing. What it verifies follows the
|
|
* definition's state, and the server says which: a `ready` definition is
|
|
* checked against its PUBLISHED version, because that is the only thing that
|
|
* ever actually runs and it is that pass §K's gate is about; a draft is checked
|
|
* against the working spec the author is still holding.
|
|
*
|
|
* Findings arrive with a 200 — the request succeeded, the plan has problems —
|
|
* so they are rendered rather than thrown into the error box.
|
|
*/
|
|
const verify = async () => {
|
|
setBusy(true)
|
|
setProblems([])
|
|
setNotice(null)
|
|
setReport(null)
|
|
try {
|
|
const result = await api.admin.verifyEvent(id)
|
|
setReport(result)
|
|
if (result.recorded) setEvent(await api.admin.getEvent(id).then((r) => r.event))
|
|
} 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>
|
|
)}
|
|
{/* The dry run is admin+editor, deliberately wider than publish: an
|
|
author should be able to find out what their event would cost
|
|
before asking an admin to commit the deployment to it. */}
|
|
{!isNew && mayAuthor && (
|
|
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={verify}>
|
|
Dry run
|
|
</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>
|
|
)}
|
|
|
|
{/* ── §K's gate, said where it can still be acted on ──
|
|
A published version that nobody has dry-run will not start on its
|
|
schedule. The alternative to saying so here is an operator finding out
|
|
on the Friday it did not run, so it is a banner rather than a log line —
|
|
and only for a definition that actually HAS a schedule to be held. */}
|
|
{!isNew && event?.state === 'ready' && !event?.currentVersionVerifiedAt && !archived && (
|
|
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
|
<p className="sans" style={{ margin: 0, fontSize: '0.84rem' }}>
|
|
<strong>This version has not been dry-run.</strong> Scheduled occurrences are held until it
|
|
is — an event that starts while nobody is watching gets one review, and this is it.
|
|
Starting it by hand is unaffected.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{report && (
|
|
<div
|
|
className="panel-flat"
|
|
style={{
|
|
padding: '10px 14px',
|
|
marginBottom: 14,
|
|
borderLeft: `3px solid ${report.report.ok ? '#8fc79a' : '#d98b84'}`,
|
|
}}
|
|
>
|
|
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>
|
|
<strong>
|
|
{report.report.ok ? 'Dry run passed' : 'Dry run found problems'}
|
|
</strong>{' '}
|
|
<span className="dim">
|
|
· {report.report.steps} step{report.report.steps === 1 ? '' : 's'} checked against{' '}
|
|
{/* Which spec was checked. The two answer different questions, and a
|
|
report that did not say would be read as the other one. */}
|
|
{report.target === 'version' ? `published v${report.version}` : 'the working draft'}
|
|
{report.recorded && ' · recorded, so scheduled occurrences may now start'}
|
|
</span>
|
|
</p>
|
|
|
|
{report.report.findings.length > 0 && (
|
|
<ul className="sans" style={{ margin: '0 0 6px', paddingLeft: 18, fontSize: '0.82rem' }}>
|
|
{report.report.findings.map((f, i) => (
|
|
<li key={`${f.phase}-${f.seq}-${f.code}-${i}`} style={{ color: f.level === 'warning' ? '#d9c184' : undefined }}>
|
|
{f.phase !== null && (
|
|
<code className="dim" style={{ fontSize: '0.78rem' }}>
|
|
{f.phase} · step {f.seq + 1}
|
|
{f.actionId ? ` · ${f.actionId}` : ''}
|
|
</code>
|
|
)}
|
|
{f.phase !== null && ' — '}
|
|
{f.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* The whole-plan cost, which is the finding no other path can make: a
|
|
step that fits on its own and does not fit alongside its siblings. */}
|
|
{report.report.cost.length > 0 && (
|
|
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse' }}>
|
|
<tbody>
|
|
{report.report.cost.map((c) => (
|
|
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
|
|
<td style={{ paddingRight: 12 }}><code style={{ fontSize: '0.78rem' }}>{c.dimension}</code></td>
|
|
<td style={{ paddingRight: 12 }}>{c.total}</td>
|
|
<td className="dim">
|
|
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{report.report.findings.length === 0 && report.report.cost.length === 0 && (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
|
Nothing this event does costs a capped resource.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{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 ── */}
|
|
{/*
|
|
Four closed shapes rendered as a form, never a cron string. A cron
|
|
expression is the one field an operator cannot proofread, and the whole
|
|
point of the closed set is that this panel can be read back in English —
|
|
which is what the preview line under it does.
|
|
*/}
|
|
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
|
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Schedule</h3>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: 12 }}>
|
|
<label>
|
|
<span className="field-label">Repeats</span>
|
|
<select className="select" value={form.scheduleKind} disabled={archived}
|
|
onChange={(e) => set({ scheduleKind: e.target.value })}>
|
|
{SCHEDULE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
|
|
</select>
|
|
</label>
|
|
|
|
{form.scheduleKind === 'once' && (
|
|
<label>
|
|
<span className="field-label">Date and time</span>
|
|
<input className="input" type="datetime-local" value={form.scheduleAt} disabled={archived}
|
|
onChange={(e) => set({ scheduleAt: e.target.value.slice(0, 16) })} />
|
|
</label>
|
|
)}
|
|
|
|
{form.scheduleKind === 'monthly' && (
|
|
<>
|
|
<label>
|
|
<span className="field-label">Week</span>
|
|
<select className="select" value={form.scheduleNth} disabled={archived}
|
|
onChange={(e) => set({ scheduleNth: e.target.value })}>
|
|
{MONTHLY_NTHS.map((n) => <option key={n.value} value={n.value}>{n.label}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span className="field-label">Weekday</span>
|
|
<select className="select" value={form.scheduleWeekday} disabled={archived}
|
|
onChange={(e) => set({ scheduleWeekday: e.target.value })}>
|
|
{WEEKDAYS.map((d) => (
|
|
<option key={d} value={d}>{d.charAt(0).toUpperCase() + d.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</>
|
|
)}
|
|
|
|
{(form.scheduleKind === 'weekly' || form.scheduleKind === 'monthly') && (
|
|
<label>
|
|
<span className="field-label">Time</span>
|
|
<input className="input" type="time" value={form.scheduleTime} disabled={archived}
|
|
onChange={(e) => set({ scheduleTime: e.target.value })} />
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{form.scheduleKind === 'weekly' && (
|
|
<div style={{ marginTop: 12 }}>
|
|
<span className="field-label">Days</span>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
|
{WEEKDAYS.map((day) => {
|
|
const on = (form.scheduleDays || []).includes(day)
|
|
return (
|
|
<button key={day} type="button" className="pill" disabled={archived}
|
|
aria-pressed={on}
|
|
style={{ fontSize: '0.72rem', opacity: on ? 1 : 0.45 }}
|
|
onClick={() => set({
|
|
scheduleDays: on
|
|
? form.scheduleDays.filter((d) => d !== day)
|
|
: WEEKDAYS.filter((d) => d === day || form.scheduleDays.includes(d)),
|
|
})}>
|
|
{day.charAt(0).toUpperCase() + day.slice(1, 3)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
|
{describeSchedule(scheduleFromForm(form), form.timezone)}
|
|
</p>
|
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
|
|
Times are the event’s own, in <code>{form.timezone}</code> — not the reader’s. A
|
|
recurring schedule goes live when the definition is published and stops when it is
|
|
archived; occurrences become real runs a fortnight before they happen, and the
|
|
calendar forecasts the rest. A time that daylight saving skips moves forward to the next
|
|
one that exists, and an hour that happens twice takes the first.
|
|
</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.
|
|
</p>
|
|
|
|
{/* ── The advance condition (Phase 5) ──
|
|
A gate is an ADDITIONAL condition and never a replacement, which is
|
|
what the caption has to say: a phase whose steps are still running
|
|
is not advanced by a boss that spawned early. */}
|
|
<div style={{ marginTop: 12, borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12 }}>
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
<label style={{ flex: '1 1 240px' }}>
|
|
<span className="field-label">This phase advances</span>
|
|
<select className="input" value={phase.advance?.kind || ''}
|
|
onChange={(e) => setPhase(pi, { advance: { ...(phase.advance || blankAdvance()), kind: e.target.value } })}>
|
|
{ADVANCE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
|
|
</select>
|
|
</label>
|
|
{phase.advance?.kind === 'after' && (
|
|
<label style={{ flex: '0 1 160px' }}>
|
|
<span className="field-label">Delay</span>
|
|
<input className="input" value={phase.advance.after}
|
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, after: e.target.value } })}
|
|
placeholder="30m" />
|
|
</label>
|
|
)}
|
|
{phase.advance?.kind === 'on' && (
|
|
<>
|
|
<label style={{ flex: '1 1 240px' }}>
|
|
<span className="field-label">Trigger</span>
|
|
<select className="input" value={phase.advance.on}
|
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, on: e.target.value } })}>
|
|
<option value="">Choose a trigger…</option>
|
|
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
|
|
</select>
|
|
</label>
|
|
<label style={{ flex: '0 1 110px' }}>
|
|
<span className="field-label">How many</span>
|
|
<input className="input" type="number" min="1" value={phase.advance.count}
|
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, count: e.target.value } })} />
|
|
</label>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{phase.advance?.kind === 'on' && (
|
|
<label style={{ display: 'block', marginTop: 10 }}>
|
|
<span className="field-label">Only when (JSON, optional)</span>
|
|
<textarea className="input" rows={3} spellCheck={false} value={phase.advance.whereText}
|
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, whereText: e.target.value } })}
|
|
placeholder={'{ "variable": "region", "cmp": "eq", "value": "Yew" }'} />
|
|
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
|
A raw JSON field, and a placeholder for the same reason the params box is one — the
|
|
condition builder proper is a later phase. It is checked at save against what the
|
|
trigger declares, and a variable the trigger does not have comes back named.
|
|
{triggerById.get(phase.advance.on)?.variables?.length > 0 && (
|
|
<>
|
|
{' '}
|
|
<code>{phase.advance.on}</code> declares{' '}
|
|
{triggerById.get(phase.advance.on).variables.map((v) => `${v.name} (${v.type})`).join(', ')}.
|
|
</>
|
|
)}
|
|
</span>
|
|
</label>
|
|
)}
|
|
|
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
|
{phase.advance?.kind
|
|
? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
|
|
: 'The phase advances the moment every one of its steps is finished.'}
|
|
</p>
|
|
</div>
|
|
|
|
<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>
|
|
)
|
|
}
|