Compare commits

...
3 Commits
Author SHA1 Message Date
mrhid6 3d59836d0c feat: dual list box for workflow target servers
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 5m19s
2026-08-04 17:13:50 +01:00
mrhid6 d9184312aa fix: schedule card placement, preview state, and scheduled-workflow docs 2026-08-04 17:08:11 +01:00
mrhid6 b9802e6b04 docs: Updated docs 2026-08-04 17:03:29 +01:00
15 changed files with 386 additions and 274 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,
@@ -10,67 +10,54 @@ HQ portal.
## What a licence is
A signed file. It carries the instance UUID it belongs to, the tier, the server
A signed file. It carries the instance ID it belongs to, the tier, the server
allowance, feature toggles and an expiry. The control plane verifies the
signature locally checking a licence never contacts HQ, and a running instance
does not need HQ to be reachable.
signature locally.
Signing happens in exactly one place, in HQ. The control plane can only verify.
A running instance does not need HQ to be reachable.
## 1. Find your instance UUID
:::info One Free per account, per deployment.
The limit is enforced per account **and** deployment, so a Free cloud instance does not stop you claiming Free on a self-hosted install.
:::
In the control plane, go to **Settings → Licence**. The instance UUID is shown
there. It is the identity your licence binds to.
## 1. Find your instance ID
## 2. Link the install to your HQ account
In the control plane, go to **Settings → Licence**. The instance ID is shown there.
## 2. Create a free license
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
account, see [Accounts and signup](../hq/accounts-and-signup.md).
2. Choose **Link an instance**.
3. Paste the instance UUID and give it a name you will recognise.
2. Click on the **Buy A Plan** button.
3. Click on **Self Hosted** then click on the **Free** plan, then finally Paste the instance ID and give it a name you will recognise.
Linking claims the UUID for your account. A UUID already linked elsewhere is
refused with a conflict rather than silently moved.
You will then see the new instance on the **Overview** page.
## 3. Claim Free
## 3. Downloading the free license
With the instance linked, choose **Claim Free** on it. HQ issues a Free licence
bound to that UUID and hands it back.
With the instance created go to the **Overview** page and expand the new instance.
:::info One Free per account, per deployment
The limit is enforced per account **and** deployment, so a Free cloud instance
does not stop you claiming Free on a self-hosted install. Both the friendly
pre-check and the issuer apply the same rule deliberately, because a
pre-check stricter than the issuer would refuse something that would actually
have worked.
:::
Click on the **View Instance Settings** button. You can then click on the **Download License** or the **Copy to clipboard** button.
## 4. Install the licence
Download the licence from HQ and paste it in the control plane at
**Settings → Licence**.
The instance validates the signature, checks the UUID matches its own, and
The instance validates the signature, checks the ID matches its own, and
starts reporting the tier, allowance and expiry.
:::warning Cloud instances cannot paste a licence
On a cloud instance `POST /license` answers `409 cloud_managed`, and the UI
hides the form entirely. A cloud licence is written directly by HQ. This is not
a restriction the injection path has to work around it writes to the database,
not through the endpoint.
:::info Cloud instances do **not** require installing the license as this is done automatically.
:::
## Renewing
Free licences are renewable from HQ within a renewal window near expiry;
outside that window the renew call refuses. See [Free tier](../hq/free-tier.md).
Pasting a licence keeps working while the current one is expired that endpoint
is exempt from the licence check, because it is the way out of degraded mode.
outside that window you cannot renew early. See [Free tier](../hq/free-tier.md).
## Moving the install to new hardware
Rebuilding produces a new instance UUID, and a licence binds to a UUID. Use
Rebuilding produces a new instance ID, and a licence binds to a ID. Use
**Relink** in HQ to move the licence across. The number of relinks per term is
capped; the portal shows how many you have left.
+33 -38
View File
@@ -4,7 +4,7 @@ title: First login
sidebar_label: First login
---
A fresh install has no users and no organisation. The first visit creates both.
A fresh install has no users and no instance. The first visit creates both.
## 1. Bootstrap
@@ -13,48 +13,49 @@ Open the control plane in a browser. Because no user exists, you land on
Fill in:
| Field | Notes |
| ----------------- | ----------------------------------------------------------------- |
| Organisation name | Display name. Shown throughout the UI |
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
| Your name | |
| Email | Becomes your sign-in identity |
| Password | Stored bcrypt-hashed |
| Field | Notes |
| ------------- | ------------------------------------- |
| Instance name | Display name. Shown throughout the UI |
| Email | Becomes your sign-in identity |
| Password | Stored bcrypt-hashed |
Submitting creates the organisation and its **owner** you.
Submitting creates the instance and its **owner** you.
:::warning Bootstrap works exactly once
The endpoint is open only while the database has no users. As soon as the first
one exists, `/setup` redirects to the login page and the bootstrap endpoint
refuses. There is no second chance to create the first owner, so record the
credentials before you close the tab.
one exists, There is no second chance to create the first owner, so record the
credentials before you continue.
:::
## 2. Sign in
## 2. Copy the Instance ID
You are taken to `/login`. Sign in with the email and password you just set.
Once you have finished setup you will see the successfully created page.
Sessions are an opaque 32-byte token in the `km_session` cookie, with the body
held in Redis for 24 hours. Restarting Redis signs everyone out and loses
nothing else.
This will show the Instance ID. You will need this ID when creating a license in the HQ.
## 3. Look around
## 3. Sign in
You land on the fleet dashboard, which is empty. The sidebar is the whole
Click the continue to sign in button on the successful setup page.
You will be taken to `/login`. Sign in with the email and password you just set.
## 4. Look around
You land on the servers dashboard, which is empty. The sidebar is the whole
product:
| Section | What it does |
| --------- | ----------------------------------------- |
| Servers | The fleet enrol, inspect, console, update |
| Keys | SSH public keys and their assignments |
| Workflows | Compose and run scripted work |
| Steps | The reusable step library |
| Monitors | HTTP, TCP, ICMP and TLS checks |
| Secrets | The encrypted vault |
| Audit | Every mutating action |
| Settings | Members, SSO, alerts, retention, licence |
| Section | What it does |
| --------- | ------------------------------------------ |
| Servers | The server enrol, inspect, console, update |
| Keys | SSH public keys and their assignments |
| Workflows | Compose and run scripted work |
| Steps | The reusable step library |
| Monitors | HTTP, TCP, ICMP and TLS checks |
| Secrets | The encrypted vault |
| Audit | Every mutating action |
| Settings | Members, SSO, alerts, retention, licence |
## 4. Add the rest of your team
## 5. Add the rest of your team
Go to **Settings → Access**. Add members with a role:
@@ -66,12 +67,6 @@ Go to **Settings → Access**. Add members with a role:
Settings and organisation management require `owner` or `admin`.
If you would rather not manage passwords, configure single sign-on instead:
see [Settings](../vantage/settings.md#single-sign-on). You can add more than
one identity provider; each gets its own button on the login page, and no
buttons appear at all until at least one provider is configured. Client
secrets are stored encrypted.
If you would rather not manage passwords, configure single sign-on instead: see [Settings](../vantage/settings.md#single-sign-on).
## Next
[Add your first server](./first-server.md).
You can add more than one identity provider; each gets its own button on the login page, and no buttons appear at all until at least one provider is configured.
+27 -41
View File
@@ -4,41 +4,39 @@ title: Add your first server
sidebar_label: Add your first server
---
Enrolling a machine means running one command on it. The control plane issues a
Enrolling a server means running one command on it. The control plane issues a
short-lived token, the install script fetches the agent and writes a config, and
the machine registers itself.
## 1. Create the enrolment
In the UI, go to **Servers → Add server**. That calls `POST /api/servers/new`,
which generates a server ID and a pre-registration token and hands back a ready
one-liner.
In the UI, go to **Servers → Add server** Then click the **Generate Install Command** button.
This generates a server ID and a pre-registration token
:::warning The token is single-use and lives one hour
It is the only credential in the flow, and it is spent the moment the agent
calls `Register`. If you paste it somewhere and come back tomorrow, create a new
enrolment instead nothing is lost by doing so.
It is the only credential in the flow, and it is spent the moment the agent registers.
:::
## 2. Run the one-liner
### Linux
Run the generated install script as root.
Here is an example of the install script:
```bash
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
```
Run it as root. The script:
What the script does:
1. Detects architecture `x86_64` and `aarch64` only; anything else exits.
2. Asks the Gitea API for the newest `agent/v*` release.
3. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**,
aborting on a mismatch.
4. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
5. Writes `/etc/vantage/config.yaml` (directory `0700`, file `0600`) containing
the server ID, the pre-registration token and the gRPC host.
6. Writes `/etc/systemd/system/vantage-agent.service` with `Restart=always`, and
runs `systemctl enable --now vantage-agent`.
2. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**, aborting on a mismatch.
3. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
4. Writes the config file at `/etc/vantage/config.yaml`
1. This contains the server ID, the pre-registration token and the gRPC host.
5. Writes the systemd service file `/etc/systemd/system/vantage-agent.service` and starts the agent.
### Windows
@@ -46,42 +44,30 @@ Run it as root. The script:
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
```
Run from an elevated PowerShell. The agent is registered as a service through
NSSM, with the config at `%ProgramData%\vantage\config.yaml`. There is also an
MSI built by CI if you would rather deploy that.
Run from an elevated PowerShell.
:::info Windows agents are second-class on purpose
They register, heartbeat, run workflow steps and report inventory. They do
**not** manage `authorized_keys` the key subsystem is Linux-only, and a
Windows agent stops after the heartbeat portion of the poll.
What the script does:
1. Creates the config at `%ProgramData%\vantage\config.yaml`.
1. This contains the server ID, the pre-registration token and the gRPC host.
2. Downloads the agent MSI from Gitea.
3. Installs the MSI and creates the Windows service.
4. Starts the agent.
:::info Windows agents do **not** manage `authorized_keys` as this is a Linux-only function.
:::
## 3. Watch it come up
The server appears immediately as `pending`. Within one poll interval 30
seconds it flips to `active`.
The server appears immediately as `pending`. Within one poll interval, 30 seconds it becomes `active`.
On the machine:
Check the systemd logs using the following commands:
```bash
systemctl status vantage-agent
journalctl -u vantage-agent -f
```
What happens on that first run:
```
1. Load /etc/vantage/config.yaml
2. pre_reg_token present → register → save agent_token, clear pre_reg_token
3. Reconnect with the permanent token
4. Start: command stream · hourly update check · inventory · monitors
5. Enter the key poll loop
```
After registration the config no longer contains the pre-registration token; it
contains a permanent agent token instead. The control plane stores only the
SHA-256 of that token, never the token itself.
## 4. Confirm it works
Open the server's detail page. Within a minute or two you should see:
@@ -104,7 +90,7 @@ Open the server's detail page. Within a minute or two you should see:
A server is marked `offline` when its last-seen time passes the threshold; that
sweep runs every two minutes, so allow for it before concluding anything.
## Next
## Next Steps
- [Assign an SSH key](../vantage/ssh-keys.md)
- [Run a workflow](../vantage/workflows.md)
+7 -27
View File
@@ -4,8 +4,7 @@ title: Accounts and signup
sidebar_label: Accounts and signup
---
Vantage HQ, at `vantage-hq.hostxtra.co.uk`, is where you manage the **account**
behind your instances: your team, your instances, their licences and billing.
[Vantage HQ](https://vantage-hq.hostxtra.co.uk) is where you manage the **account**, your team, your instances, their licences and billing.
## An account is a team, not a person
@@ -31,26 +30,15 @@ Signup is **account-first**. Creating an account creates the account and you;
it does not create a Vantage instance. Nothing exists in any control plane until
you later create or link one.
1. Go to the signup form.
1. Go to the [signup form](https://vantage.hostxtra.co.uk/start).
2. Enter your name, email and a password.
3. Check your email and click the verification link.
:::info Verify before you can sign in
An unverified account gets a distinct "check your email" message rather than a
generic authentication failure the address is already known to be yours, so
there is nothing to protect by being vague.
:::
Verification links are valid for **24 hours**. The token is 32 random bytes and
only its SHA-256 hash is stored, so a leaked database yields no working links.
If the verification email cannot be sent, the signup is rolled back rather than
left stranded retry rather than assuming a half-created account is in the way.
Verification links are valid for **24 hours**.
## Signing in
Email and password. The session is a cookie, separate from the control plane's:
signing in to HQ does not sign you in to an instance, and vice versa.
Use the Email and password used in the signup form to login to the HQ, signing in to HQ does not sign you in to an instance, and vice versa.
## What comes next
@@ -65,14 +53,6 @@ signing in to HQ does not sign you in to an instance, and vice versa.
Three destinations: **Overview**, **People**, **Billing**.
Settings lives in the account menu rather than the nav, because it is your
password rather than a place. The appearance toggle is there too.
Overview lists your instances. Each is one record, closed to a row and open to
its licence contents, members and actions. It opens by default when it is your
only instance or when it needs attention, and your manual choice is remembered.
There is deliberately no "your plan" card in the sidebar: tier, limits and
expiry belong to a **licence**, and a licence belongs to one instance. An
account with a Free cloud instance and a Professional self-hosted one has no
single plan to show.
- Overview lists your instances.
- People shows all the account members and their roles.
- Billing show the current subscriptions and subscription management.
+17 -50
View File
@@ -8,56 +8,29 @@ Paid plans are billed through **Paddle**, which is the merchant of record. Your
invoice, your card details and your tax handling are all Paddle's; HQ holds a
customer reference and nothing sensitive.
Billing is **owner-only**.
:::warning
The Billing page requires the **owner-only** account role.
:::
## Buying
## Buying A Plan
Buying a plan license can be found in Vantage HQ by clicking on the **Buy a Plan** button on the **Overview** page.
### Cloud
Open the instance, change its configuration to what you want, and check out.
Checkout runs in the browser.
On the **Buy A Plan** page you will need to select the **Deployment** to **Cloud** then chose your **Billing** cycle (Monthly or Annually).
Then select your desired **Plan** and configure the features.
Finally specify the **Instance Name** and click the **Continue to payment** button.
### Self-hosted
**Buy self-hosted**, then bind the purchase to your install's UUID. See
[Self-hosted instances](./self-hosted-instances.md).
On the **Buy A Plan** page you will need to select the **Deployment** to **Self-Hosted** then chose your **Billing** cycle (Monthly or Annually).
## What you are buying
Then select your desired **Plan** and configure the features.
A subscription's line items are the configuration: the plan base, the metered
server count above the base, and any per-instance features. Changing the
configuration changes the line items.
## Changing configuration
**Instance → Configuration**, adjust servers or features, and save.
- **Increases** take effect when the payment confirms.
- **Reductions** are scheduled for the end of the term. The portal shows the
date and the new value.
## The customer portal
**Billing → Manage** mints a Paddle customer-portal session where you can
update your payment method, see invoices and cancel.
## How a licence follows a payment
```mermaid
flowchart LR
C["Checkout / change"] --> P["Paddle"]
P -->|signed webhook| H["HQ"]
H --> G["Entitlement: desired → granted"]
G --> L["Licence signed from granted"]
```
The webhook is the **only** issuing path for paid plans. It is signature
verified, processed exactly once, and resolved from the subscription's _current_
line items so a webhook that arrives out of order still produces the right
answer rather than replaying a stale state.
A licence is signed from **granted** only. A checkout you abandon changes
nothing.
Finally specify the **Instance Name** and click the **Continue to payment** button.
## Cancelling and failed payments
@@ -66,18 +39,12 @@ Your licence runs to its grace-padded expiry and then lapses normally. There is
no mid-term cut-off.
For a cloud Free instance, lapsing eventually leads to deletion see
[Free tier](./free-tier.md). Paid instances are not reaped.
[Free tier](./free-tier.md). Paid instances are not deleted.
## Renewals
At renewal the subscription bills again and the licence is reissued for the new
term. It is also the only moment a scheduled **reduction** takes effect.
Self-hosted customers: download and paste the reissued licence. Cloud customers:
nothing to do.
## Free is not in Paddle at all
Free has no subscription, no £0 line item and no Paddle record. It has its own
renewal, in the portal. An account only acquires a Paddle customer reference
with its first paid purchase.
- Self-hosted customers: download and paste the reissued licence.
- Cloud customers: the license is automatically linked to the instance.
-5
View File
@@ -25,11 +25,6 @@ features on a paid plan.
The limit is enforced per account **and** deployment. A Free cloud instance does
not prevent a Free self-hosted one they are separate slots.
## Free is outside Paddle
There is no subscription, no £0 line item and no invoice. Your account acquires
a Paddle customer reference only with its first paid purchase.
## Renewing
Free licences have a term and must be renewed from the portal.
@@ -30,9 +30,9 @@ entitlement.
Two are per-instance toggles rather than tier bundles:
| Feature | What it enables |
| --------- | ------------------------------------------------------------------------- |
| `console` | The [browser console](../vantage/browser-console.md) |
| Feature | What it enables |
| --------- | -------------------------------------------------------------------- |
| `console` | The [browser console](../vantage/browser-console.md) |
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on) |
No tier includes them by default; you enable them on the instances that need
@@ -88,8 +88,3 @@ or let it be written for you (cloud).
When you exceed your server allowance, enrolling another one is refused. The
existing fleet is unaffected. Raise the allowance in the portal, or remove a
server you are not using.
## Legacy tiers
An older `self_hosted` tier is mapped forward to self-hosted Professional
wherever it appears. Nothing needs doing about it.
-3
View File
@@ -10,7 +10,6 @@ themselves on command.
## Checking the current version
Each server's detail page shows the version it reported at its last sync.
`GET /api/agent/latest-version` reports the newest release available.
## Updating from the UI
@@ -21,8 +20,6 @@ version. The agent then:
2. Verifies the SHA-256 against `checksums.txt`.
3. Stops itself, replaces the binary in place, and starts again.
`Restart=always` on the systemd unit is what makes the last step work.
The server briefly goes `offline` and comes back within a poll interval or two.
## Updating from the machine
+1 -8
View File
@@ -52,16 +52,9 @@ cp /opt/vantage/.env /secure-location/vantage.env
Treat it as a credential in its own right it holds the encryption key.
## Run logs
Workflow run logs live in the `./data` bind mount, not in the database. They are
swept on the retention schedule anyway, so most people do not back them up. If
you keep them for compliance, set retention to `0` (forever) and include the
directory.
## What a restore gives you
Everything: fleet, keys, assignments, workflows and their history, monitors and
Everything: server, keys, assignments, workflows and their history, monitors and
incidents, secrets, settings and the audit log.
What it does **not** do is reconcile the world. After a restore:
+2 -32
View File
@@ -37,31 +37,9 @@ tls: true
:::danger This file is the credential
`agent_token` is plaintext here and nowhere else the control plane holds only
its SHA-256. Anyone who can read this file can act as this agent. That is why
it is `0600` and the directory is `0700`.
its SHA-256. Anyone who can read this file can act as this agent.
:::
## Startup sequence
```
1. Load the config
2. pre_reg_token present → register → save agent_token,
clear pre_reg_token, reconnect
3. Start: command stream · hourly update check · inventory · monitors
4. Enter the key poll loop
```
## The poll loop
```
1. Ask the control plane for the desired key state, reporting the
agent version
2. Non-Linux hosts stop here Windows agents register and heartbeat only
3. Diff the desired keys against /root/.ssh/authorized_keys;
unchanged → write nothing
4. Changed → write a temp file, rename it over the real one, chmod 0600
```
## Service management
### Linux
@@ -77,21 +55,13 @@ journalctl -u vantage-agent -f
### Windows
A service registered through NSSM, or installed by the MSI that CI builds.
A service registered through NSSM, or installed by the MSI.
```powershell
Get-Service vantage-agent
Restart-Service vantage-agent
```
## Command-line flags
```
vantage-agent -generate-key
```
Generates a keypair locally. Normal operation takes no flags.
## Moving an agent to a new control plane
Change `server_url`, clear `agent_token`, set a fresh `pre_reg_token` from a new
+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
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useMemo, useState } from "react";
/*
* A two-pane picker: everything available on the left, everything chosen on the
* right, moved across with the four buttons between them.
*
* The panes are divs rather than <select multiple>. A native multi-select draws
* its selected rows with the platform's own highlight colour, which cannot be
* restyled reliably across browsers — on a dark ground it renders as a pale
* band that belongs to no palette. Rebuilding the widget is the only way to
* keep it inside the token system.
*/
export interface DualItem {
id: string;
label: string;
hint?: string;
}
function Pane({
items,
marked,
onToggle,
onCommit,
empty,
}: {
items: DualItem[];
marked: string[];
onToggle: (id: string, additive: boolean) => void;
onCommit: (id: string) => void;
empty: string;
}) {
return (
<div className="h-56 overflow-y-auto rounded-lg border border-border bg-surface-2" role="listbox" aria-multiselectable>
{items.length === 0 ? (
<p className="px-3 py-2 text-xs text-text-tertiary">{empty}</p>
) : (
items.map((it) => {
const on = marked.includes(it.id);
return (
<button
key={it.id}
type="button"
role="option"
aria-selected={on}
onClick={(e) => onToggle(it.id, e.ctrlKey || e.metaKey || e.shiftKey)}
onDoubleClick={() => onCommit(it.id)}
className={`flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${
on ? "bg-accent text-accent-ink" : "text-text-primary hover:bg-surface"
}`}
>
<span className="truncate">{it.label}</span>
{it.hint && <span className={`truncate font-mono text-[11px] ${on ? "opacity-70" : "text-text-tertiary"}`}>{it.hint}</span>}
</button>
);
})
)}
</div>
);
}
function MoveButton({ children, onClick, disabled, label }: { children: React.ReactNode; onClick: () => void; disabled: boolean; label: string }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-secondary"
>
{children}
</button>
);
}
export function DualListBox({
items,
selected,
onChange,
availableLabel = "Available",
selectedLabel = "Selected",
emptyAvailable = "Nothing left to add.",
emptySelected = "Nothing selected.",
}: {
items: DualItem[];
selected: string[];
onChange: (next: string[]) => void;
availableLabel?: string;
selectedLabel?: string;
emptyAvailable?: string;
emptySelected?: string;
}) {
// Which rows are highlighted in each pane, not which are chosen. Highlight
// is transient and per-pane; membership is the `selected` prop.
const [markedLeft, setMarkedLeft] = useState<string[]>([]);
const [markedRight, setMarkedRight] = useState<string[]>([]);
const byLabel = (a: DualItem, b: DualItem) => a.label.localeCompare(b.label);
const available = useMemo(() => items.filter((i) => !selected.includes(i.id)).sort(byLabel), [items, selected]);
// Ordered by the same rule as the left pane rather than by the order things
// were clicked, so a workflow's targets read the same way every time.
const chosen = useMemo(() => items.filter((i) => selected.includes(i.id)).sort(byLabel), [items, selected]);
const mark = (setter: typeof setMarkedLeft) => (id: string, additive: boolean) =>
setter((m) => (additive ? (m.includes(id) ? m.filter((x) => x !== id) : [...m, id]) : m.length === 1 && m[0] === id ? [] : [id]));
const add = (ids: string[]) => {
if (ids.length === 0) return;
onChange([...selected, ...ids.filter((id) => !selected.includes(id))]);
setMarkedLeft([]);
};
const remove = (ids: string[]) => {
if (ids.length === 0) return;
onChange(selected.filter((id) => !ids.includes(id)));
setMarkedRight([]);
};
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3">
<div>
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">{availableLabel}</p>
<Pane items={available} marked={markedLeft} onToggle={mark(setMarkedLeft)} onCommit={(id) => add([id])} empty={emptyAvailable} />
</div>
<div className="flex flex-col gap-2 pt-6">
<MoveButton label={`Add all to ${selectedLabel}`} disabled={available.length === 0} onClick={() => add(available.map((i) => i.id))}>
»
</MoveButton>
<MoveButton label={`Add to ${selectedLabel}`} disabled={markedLeft.length === 0} onClick={() => add(markedLeft)}>
</MoveButton>
<MoveButton label={`Remove from ${selectedLabel}`} disabled={markedRight.length === 0} onClick={() => remove(markedRight)}>
</MoveButton>
<MoveButton label={`Remove all from ${selectedLabel}`} disabled={chosen.length === 0} onClick={() => remove(chosen.map((i) => i.id))}>
«
</MoveButton>
</div>
<div>
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">
{selectedLabel} · {chosen.length}
</p>
<Pane items={chosen} marked={markedRight} onToggle={mark(setMarkedRight)} onCommit={(id) => remove([id])} empty={emptySelected} />
</div>
</div>
);
}
+22 -21
View File
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
import { ScheduleCard } from "./ScheduleCard";
import { DualListBox } from "./DualListBox";
const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
@@ -24,8 +25,6 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
}
}, [open, workflow]);
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
const save = async () => {
setBusy(true);
setError(null);
@@ -54,7 +53,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>
@@ -63,23 +62,26 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
<div className="flex flex-wrap gap-2">
{servers?.map((s) => {
const on = targets.includes(s.server_id);
return (
<label
key={s.server_id}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}
>
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
{s.hostname}
</label>
);
})}
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
</div>
{servers && servers.length === 0 ? (
<p className="text-xs text-text-secondary">No servers registered.</p>
) : (
<DualListBox
items={(servers ?? []).map((s) => ({ id: s.server_id, label: s.hostname, hint: s.status === "active" ? undefined : s.status }))}
selected={targets}
onChange={setTargets}
selectedLabel="Targets"
emptyAvailable="Every server is a target."
emptySelected="No servers targeted."
/>
)}
<p className="mt-1.5 text-[11px] text-text-tertiary">Click to highlight, ctrl-click for several, double-click to move. Tag selectors are set on the workflow page.</p>
</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 +90,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>