fix: schedule card placement, preview state, and scheduled-workflow docs

This commit is contained in:
2026-08-04 17:08:11 +01:00
parent b9802e6b04
commit d9184312aa
4 changed files with 110 additions and 12 deletions
+33
View File
@@ -124,6 +124,39 @@ A library of reusable **steps** (bash or PowerShell scripts with declared inputs
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Scheduled workflows
A workflow may carry `schedule{enabled, cron, tz}` — standard **5-field** cron
and an IANA zone name, both validated at save time. `next_run_at` is
**persisted on the document, not held in memory**: a leader handover between
computing an occurrence and firing it would otherwise lose it or fire it twice,
the same argument that put `workflow_log_seq` in MongoDB.
`server/internal/workflowsched` ticks every 30s inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched` and the sweepers —
one role, one lock. **The atomic claim, not the lock, is what prevents a double
fire**: the `UpdateOne` matches on the document *and* its current `next_run_at`
while setting the recomputed one, so a second process reaching the same workflow
matches nothing and does nothing. The lock only makes it cheap.
`workflowsched` **must not import `services`**`services` already imports it
for `SetSchedule`'s call to `NextOccurrence`, and Go has no cycles.
`TriggerWorkflow` and `LogEvent` are therefore injected as `workflowsched.Deps`
from `main.go`. Firing goes through the same `TriggerWorkflow` a person uses,
with `"schedule"` as the actor, so there is no second dispatch path and the run
detail page needed no changes.
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
builds on Alpine, which ships no zone database, so without it
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
back to UTC — an hour wrong for half the year, in the direction nobody notices
until a maintenance window lands in business hours. It works on a developer
machine either way, which is exactly why it gets forgotten.
Skips are recorded and surfaced, not just logged: past the 1h grace window is
`missed`, an active run is `already_running`, and a schedule that no longer
parses is disabled rather than left spinning the loop every 30 seconds forever.
### Server tags and workflow targeting
A server carries `tags map[string]string` — lowercase `[a-z0-9_-]`, key ≤32,
+51
View File
@@ -147,6 +147,57 @@ A run shows the script that actually executed, not the current library version.
Targets run **in parallel**; steps within one server run **in order**.
## Schedules
A workflow can carry a schedule, and Vantage will start it the same way a person
would — the same dispatch, the same snapshot, the same run page. A scheduled run
is an ordinary run with `schedule` recorded as who triggered it.
Open a workflow, choose **Edit**, and tick **Run on a schedule**. The expression
is standard five-field cron:
```
minute hour day-of-month month day-of-week
```
The presets write cron underneath, so you can start from one and adjust:
| Preset | Cron |
| ------------------- | ----------- |
| Hourly | `0 * * * *` |
| Nightly, 02:00 | `0 2 * * *` |
| Weekly, Sun 02:00 | `0 2 * * 0` |
| Monthly, 1st 02:00 | `0 2 1 * *` |
There is no seconds field and no `@daily`-style shorthand. The next three
occurrences are shown as you type, and they are computed by the server rather
than the browser, so what you see is exactly what will fire.
### Timezones
A schedule stores an IANA timezone by name — `Europe/London`, not an offset.
That is what makes a 02:00 job stay at 02:00 across a daylight-saving change
instead of drifting an hour for half the year. An unknown zone is refused when
you save it, not at 2am.
### Overlaps are skipped, not queued
If a run of the same workflow is still going when the next occurrence comes
round, the occurrence is **skipped** and the reason recorded. It is not queued
behind the running one. A patch workflow that takes longer than its interval
should fall behind visibly rather than pile up.
### Missed occurrences
If the control plane was not running when an occurrence was due, it still fires
when the control plane comes back — but only within **one hour** of the due
time. Anything older is recorded as missed and dropped. A job missed by ten
minutes during an upgrade should still run; one missed by two days should not
suddenly fire at lunchtime.
Either kind of skip is shown on the workflow's schedule panel, with the time it
was due and why it did not run.
## Watching a run
Step stdout and stderr stream back as chunks, are appended to a log file on the
@@ -54,7 +54,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
};
return (
<Modal open={open} onClose={onClose} title="Edit workflow">
<Modal open={open} onClose={onClose} title="Edit workflow" wide>
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div>
@@ -79,7 +79,12 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
</div>
</div>
<div className="flex items-center justify-between pt-2">
{/* The schedule saves through its own endpoint, so it sits above
the footer rather than under it — the footer's Save covers the
name and targets only, and the two are labelled accordingly. */}
<ScheduleCard workflow={workflow} />
<div className="flex items-center justify-between border-t border-border-soft pt-4">
<Button variant="danger" onClick={del} loading={busy}>
Delete workflow
</Button>
@@ -88,11 +93,10 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
Cancel
</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>
Save
Save workflow
</Button>
</div>
</div>
<ScheduleCard workflow={workflow} />
</div>
</Modal>
);
+18 -8
View File
@@ -28,7 +28,10 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [error, setError] = useState<string | null>(null);
const { data: preview } = useQuery({
// isError, not !preview: an in-flight query and a rejected expression both
// leave data undefined, so keying the invalid message off the data alone
// flashes "not valid" at every keystroke on a perfectly good cron string.
const { data: preview, isError: previewFailed } = useQuery({
queryKey: ["schedule-preview", workflow.workflow_id, cron, tz],
queryFn: () => api.previewSchedule(workflow.workflow_id, cron, tz),
retry: false,
@@ -44,13 +47,16 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
});
return (
<div className="rounded-lg border border-border bg-surface">
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
<h2 className="text-[15px] font-semibold text-text-primary">Schedule</h2>
// No panel chrome: this renders inside a Modal that already supplies the
// border, the background and a title bar, and nesting a second card in
// one produced a box inside a box.
<div className="border-t border-border-soft pt-4">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">Schedule</h3>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{enabled ? "Active" : "Off"}</span>
</div>
<div className="flex flex-col gap-4 p-5">
<div className="flex flex-col gap-4 pt-4">
<label className="flex items-start gap-3">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="mt-0.5 h-4 w-4 accent-accent" />
<span>
@@ -103,14 +109,16 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
<div className="rounded-lg bg-well px-4 py-3">
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Next three runs</p>
{preview ? (
{previewFailed ? (
<p className="mt-1.5 font-mono text-[11.5px] text-danger">That expression is not valid.</p>
) : preview ? (
<ul className="mt-1.5 flex flex-col gap-0.5 font-mono text-[11.5px] text-text-secondary">
{preview.occurrences.map((o) => (
<li key={o}>{new Date(o).toLocaleString()}</li>
))}
</ul>
) : (
<p className="mt-1.5 font-mono text-[11.5px] text-danger">That expression is not valid.</p>
<p className="mt-1.5 font-mono text-[11.5px] text-text-tertiary">Working it out</p>
)}
</div>
@@ -128,7 +136,9 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<Button variant="primary" loading={isPending} onClick={() => save()}>
{/* Gated on the preview: the server has already rejected this
expression once, and submitting it only earns the same 400. */}
<Button variant="primary" loading={isPending} disabled={previewFailed} onClick={() => save()}>
Save schedule
</Button>
</div>