fix: schedule card placement, preview state, and scheduled-workflow docs
This commit is contained in:
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user