Compare commits
15
Commits
80f0afb28b
...
b21ac05547
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b21ac05547 | ||
|
|
484b620867 | ||
|
|
439bc2ed7d | ||
|
|
a1e6986a64 | ||
|
|
d0e1cc4ad6 | ||
|
|
b877024365 | ||
|
|
2de7ac116b | ||
|
|
fa1fd14ed1 | ||
|
|
d1b3cd2f74 | ||
|
|
e00a0da5d9 | ||
|
|
fef0b7c7a1 | ||
|
|
efd29dc259 | ||
|
|
13cd41d202 | ||
|
|
3530ce6cb7 | ||
|
|
09522c2566 |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
# Server tags and scheduled workflows
|
||||
|
||||
Date: 2026-08-04
|
||||
|
||||
Two features, designed together because the second is worth much less without
|
||||
the first. Tags make a target set describable; schedules make it recur. A
|
||||
nightly job that patches "everything tagged `env:staging`" needs both halves,
|
||||
and neither half is large on its own.
|
||||
|
||||
---
|
||||
|
||||
## Part A — Server tags
|
||||
|
||||
### Model
|
||||
|
||||
`models.Server` gains one field:
|
||||
|
||||
```go
|
||||
Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
|
||||
```
|
||||
|
||||
Keys and values are lowercase `[a-z0-9_-]`. Keys are capped at 32 characters,
|
||||
values at 64, and a server holds at most 20 tags. Validation lives in the
|
||||
service layer rather than the handler, so the tag endpoint, the server-create
|
||||
path and anything added later cannot disagree about what a valid tag is.
|
||||
|
||||
There is **no `tags` collection.** A tag is a property of a server, not an
|
||||
entity with a lifecycle: a registry would need reference counting to know when
|
||||
a tag stopped existing, and garbage collection to act on it, which is work
|
||||
bought for nothing. The list of known keys and values that the UI offers for
|
||||
autocomplete is a distinct aggregation over `servers`, cached for 60 seconds —
|
||||
the same treatment org lookups already get.
|
||||
|
||||
No reserved keys ship in this change. If inventory-derived tags (`os`, `arch`)
|
||||
are added later they take a `sys:` key prefix, so a user tag written today can
|
||||
never collide with a system tag invented tomorrow.
|
||||
|
||||
Index: `{instance_id: 1, "tags.$**": 1}` — a wildcard index over the tag
|
||||
subdocument, because the queried key is chosen by the user at request time and
|
||||
cannot be named in advance.
|
||||
|
||||
### API
|
||||
|
||||
```
|
||||
PUT /api/servers/:id/tags # replace the whole map
|
||||
GET /api/servers/tags # known keys and values, for pickers
|
||||
GET /api/servers?tag=env:prod # repeatable; AND across keys
|
||||
```
|
||||
|
||||
`PUT` replaces the entire map rather than patching one tag. A tag set is small
|
||||
enough that sending all of it is free, and last-write-wins over a whole map is
|
||||
easier to reason about than merge semantics between two people editing the same
|
||||
server. The audit event records the map before and after.
|
||||
|
||||
`?tag=` is repeatable and ANDs: `?tag=env:prod&tag=role:web` matches servers
|
||||
carrying both. A malformed value (no colon, unknown characters) is a 400 rather
|
||||
than a silent empty result — a filter that matches nothing and a filter that is
|
||||
nonsense look identical in a list, and only one of them is the user's fault.
|
||||
|
||||
### Targeting
|
||||
|
||||
`models.Workflow` gains `TargetTags map[string]string` beside the existing
|
||||
`TargetServerIDs`. One function in `services` resolves them:
|
||||
|
||||
```go
|
||||
ResolveTargets(ctx, instanceID string, ids []string, tags map[string]string) ([]Server, error)
|
||||
```
|
||||
|
||||
- Result is the **distinct union** of the explicit IDs and the tag matches.
|
||||
- Tag matching ANDs across keys.
|
||||
- Offline servers are included. The dispatcher already answers 503 per server,
|
||||
and a patch run that silently omits an unreachable machine is worse than one
|
||||
that visibly fails on it.
|
||||
- Empty IDs **and** empty tags returns `ErrNoTargets` (400). A workflow that
|
||||
matches nothing must say so rather than report success over zero servers.
|
||||
|
||||
The resolved set is snapshotted into `WorkflowRun.ServerRuns` exactly as today.
|
||||
History records what actually ran, not what the selector would match when the
|
||||
run is later read back — the same reason `steps_snapshot` exists.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Server detail**: tag chips in the header with an inline editor. Keys
|
||||
autocomplete from `GET /api/servers/tags`, values autocomplete per key.
|
||||
- **`/servers`**: a filter bar that reads and writes the same `?tag=` query
|
||||
params the API takes, so a filtered fleet view is a URL someone can send.
|
||||
- **Workflow designer**: a target section holding both inputs, with a live
|
||||
"runs on 14 servers" readout that lists them on hover. The union model costs
|
||||
us the at-a-glance answer to "what will this touch"; this readout buys it
|
||||
back, and it is the reason the union is acceptable.
|
||||
|
||||
---
|
||||
|
||||
## Part B — Scheduled workflows
|
||||
|
||||
### Model
|
||||
|
||||
```go
|
||||
type Schedule struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
Cron string `bson:"cron" json:"cron"` // 5-field
|
||||
TZ string `bson:"tz" json:"tz"` // IANA name
|
||||
}
|
||||
|
||||
type Skip struct {
|
||||
Reason string `bson:"reason" json:"reason"` // "missed" | "already_running"
|
||||
Due time.Time `bson:"due" json:"due"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
```
|
||||
|
||||
On `Workflow`:
|
||||
|
||||
```go
|
||||
Schedule *Schedule `bson:"schedule,omitempty"`
|
||||
NextRunAt *time.Time `bson:"next_run_at,omitempty"` // UTC, indexed
|
||||
LastRunAt *time.Time `bson:"last_run_at,omitempty"`
|
||||
LastSkipped *Skip `bson:"last_skipped,omitempty"`
|
||||
```
|
||||
|
||||
`next_run_at` is **persisted, not held in memory.** A leader handover between
|
||||
computing the next occurrence and firing it would otherwise either lose the
|
||||
occurrence or fire it twice. Coordination state has to live where every replica
|
||||
can see it — the same argument that put `workflow_log_seq` in MongoDB.
|
||||
|
||||
Cron parsing uses `robfig/cron/v3`'s **parser only** — `Parse` and
|
||||
`Next(time)`. Its scheduler and goroutines are not used; the loop below is ours
|
||||
and has to be, because it runs under the leader lock.
|
||||
|
||||
**Alpine ships no tzdata.** `server/Dockerfile` builds a slim image, so
|
||||
`time.LoadLocation("Europe/London")` returns an error and every schedule
|
||||
falls back to UTC — an hour wrong for half the year, in the direction nobody
|
||||
notices until a maintenance window lands in business hours. `main` therefore
|
||||
imports `_ "time/tzdata"`, embedding the database in the binary. Zone names are
|
||||
also validated at save time, so an unknown zone is a 400 rather than a surprise
|
||||
at 2am.
|
||||
|
||||
### Scheduler
|
||||
|
||||
A new `server/internal/workflowsched` package, started inside the **existing**
|
||||
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched`, `StartReaper`
|
||||
and the sweepers. One role, one lock. It takes the same cancellable context and
|
||||
returns the instant leadership is lost.
|
||||
|
||||
The loop ticks every 30 seconds:
|
||||
|
||||
1. `find({schedule.enabled: true, next_run_at: {$lte: now}})`.
|
||||
2. **Claim atomically.** `findOneAndUpdate` matching the document *and* its
|
||||
current `next_run_at`, setting the recomputed next occurrence. A process
|
||||
that reaches the same document after another has claimed it matches nothing
|
||||
and does nothing. The claim is what makes this correct; the leader lock only
|
||||
makes it cheap.
|
||||
3. **Grace check.** If `now - due > 1h`, record
|
||||
`last_skipped{reason: "missed"}`, write an audit event, and do not run. A
|
||||
job missed by ten minutes during a deploy should still run; one missed by
|
||||
two days should not fire at lunchtime.
|
||||
4. **Overlap check.** If a run for this workflow is still active, record
|
||||
`last_skipped{reason: "already_running"}`, audit, and do not run. A patch
|
||||
workflow must never run twice at once, and a silent skip is how a week goes
|
||||
by before anyone notices nothing ran.
|
||||
5. Otherwise start the run through the **same** `RunWorkflow` path a person
|
||||
uses, with `TriggeredBy: "schedule"`.
|
||||
|
||||
Step 5 is the design. A scheduled run is an ordinary run with a different
|
||||
trigger: no second dispatch path, no second snapshot format, and the run detail
|
||||
page needs no changes to display one.
|
||||
|
||||
### API
|
||||
|
||||
```
|
||||
PUT /api/workflows/:id/schedule # {enabled, cron, tz}
|
||||
GET /api/workflows/:id/schedule/preview?cron=…&tz=… # next 3 occurrences
|
||||
```
|
||||
|
||||
`PUT` validates the expression and the zone, then computes and stores
|
||||
`next_run_at`. The preview endpoint exists so the browser and the scheduler
|
||||
agree on what a cron string means — a client-side cron parser that disagrees
|
||||
with the server by one field is a bug found in production, at night.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Workflow page**: a schedule card with preset buttons (hourly, nightly at
|
||||
HH:MM, weekly on DAY at HH:MM) that write cron underneath, a raw cron field
|
||||
for anything else, a timezone select, and the next three occurrences rendered
|
||||
from the preview endpoint in mono.
|
||||
- **Workflows list**: a schedule chip and the next run as relative time.
|
||||
- **Skips are surfaced**, not just stored: a warning line reading
|
||||
"Skipped Sun 02:00 — previous run still active". Recording a reason nobody
|
||||
reads is the same as not recording one.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
**Notification on scheduled-run failure.** It needs the monitor channel
|
||||
machinery pointed at workflow outcomes and its own answer to what counts as
|
||||
failure — a non-zero exit on a step with `on_failure: continue` is not
|
||||
obviously an alert. Visibility in this change is the run list and the recorded
|
||||
skip reason. Excluded deliberately, not overlooked.
|
||||
|
||||
**Tag-scoped permissions.** Roles stay instance-wide. Tags describe servers;
|
||||
they do not yet gate who may act on them.
|
||||
|
||||
**Inventory-derived tags.** Reserved via the `sys:` prefix, not implemented.
|
||||
|
||||
---
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
No migration is required. `Tags`, `TargetTags` and `Schedule` are all
|
||||
`omitempty` and absent means what it meant before: no tags, no selector, no
|
||||
schedule. Existing workflows keep their explicit server lists and behave
|
||||
identically.
|
||||
|
||||
The wildcard tag index and the `next_run_at` index are declared by a new
|
||||
`EnsureServerIndexes`, following the convention `EnsureSecretIndexes` and
|
||||
`EnsureWorkflowIndexes` already set: it warns rather than aborting boot,
|
||||
because a missing index degrades
|
||||
tag filtering to a collection scan on a small collection rather than breaking
|
||||
the fleet list.
|
||||
|
||||
## Testing
|
||||
|
||||
- `ResolveTargets`: union deduplicates; AND across tag keys; empty/empty
|
||||
returns `ErrNoTargets`; offline servers are included.
|
||||
- Tag validation: charset, length caps, tag count cap, malformed `?tag=` is a
|
||||
400.
|
||||
- Schedule validation: bad cron and unknown zone both 400; `next_run_at` is
|
||||
computed in the stored zone, verified across a DST boundary.
|
||||
- Scheduler claim: two concurrent claims of the same due workflow start exactly
|
||||
one run.
|
||||
- Grace window: due 10 minutes ago runs; due 2 hours ago records `missed`.
|
||||
- Overlap: an active run yields `already_running` and no second run.
|
||||
- Preview endpoint and the scheduler agree on the next occurrence for a table
|
||||
of expressions, including a DST-crossing one.
|
||||
@@ -26,6 +26,52 @@ The offline sweep runs every two minutes, so a machine that has just gone away
|
||||
takes a little while to be marked as such. That delay is intentional a single
|
||||
missed poll is not an outage.
|
||||
|
||||
## Tags
|
||||
|
||||
A tag is a `key:value` label you put on a server. Tags are how you say what a
|
||||
machine **is** `env:prod`, `role:web`, `team:core-infra` so that you can find
|
||||
it later, and so that a [workflow](./workflows.md) can target it without you
|
||||
naming it by hand.
|
||||
|
||||
There is no tag library to manage first. A tag exists because a server carries
|
||||
it, and it stops existing when the last server carrying it drops it.
|
||||
|
||||
### The rules
|
||||
|
||||
| Rule | Value |
|
||||
| ---------- | ------------------------------------------------- |
|
||||
| Characters | lowercase letters, digits, `-` and `_`, on both halves |
|
||||
| Key length | up to 32 characters |
|
||||
| Value length | up to 64 characters |
|
||||
| Per server | up to 20 tags |
|
||||
|
||||
Neither half may be empty, and keys beginning `sys:` are reserved for tags
|
||||
Vantage may derive from inventory later, so a tag you write today can never
|
||||
collide with one invented for you tomorrow.
|
||||
|
||||
Anything outside those rules is refused with a message naming the rule, rather
|
||||
than quietly saved in a shape you did not intend. Uppercase is not folded to
|
||||
lowercase for you `Env` is a mistake, not a synonym for `env`.
|
||||
|
||||
### Editing a server's tags
|
||||
|
||||
On the server detail page, **Edit** beside the tag chips. Saving replaces the
|
||||
whole set: what you see in the editor is exactly what the server will have.
|
||||
There is no per-tag merge, so if two people edit the same server at once, the
|
||||
last save wins outright rather than producing a blend of the two.
|
||||
|
||||
### Filtering the fleet
|
||||
|
||||
The **Servers** list has a picker per tag key in use. Choosing values from more
|
||||
than one key narrows the list a server must match **all** of them, not any.
|
||||
Untagged servers appear only when no filter is set.
|
||||
|
||||
:::tip A filtered fleet view is a link
|
||||
The filter lives in the URL (`/servers?tag=env:prod&tag=role:web`). Copy the
|
||||
address bar and you have sent someone the same view, not a description of how to
|
||||
reproduce it.
|
||||
:::
|
||||
|
||||
## The server detail page
|
||||
|
||||
### Keys
|
||||
|
||||
@@ -76,7 +76,8 @@ the API is the boundary; the UI is the courtesy.
|
||||
2. Add steps in order from the library.
|
||||
3. Set inputs per step.
|
||||
4. Set failure behaviour per step.
|
||||
5. Choose target servers.
|
||||
5. Choose targets: named servers, a tag selector, or both. See
|
||||
[Targeting](#targeting).
|
||||
|
||||
### Failure behaviour
|
||||
|
||||
@@ -92,6 +93,48 @@ A workflow can override a step's script or its secret references without
|
||||
touching the library entry. This is how you adapt a default step, and it is
|
||||
scoped to that workflow.
|
||||
|
||||
## Targeting
|
||||
|
||||
A workflow names servers two ways, and it can use both at once:
|
||||
|
||||
- **Target servers** an explicit list you pick from the fleet.
|
||||
- **Target tags** a `key:value` selector matched against
|
||||
[server tags](./servers.md#tags). More than one key ANDs: a server must carry
|
||||
every pair to match.
|
||||
|
||||
A run goes to the **union** of the two, with duplicates removed. A server that is
|
||||
both named explicitly and matched by the selector runs once, not twice. This is
|
||||
what lets a workflow say "every production web server, plus this one box I am
|
||||
watching" without maintaining a list.
|
||||
|
||||
The designer shows the resolved count as you edit, so you can see how many
|
||||
machines a change to the selector just added or removed before you save.
|
||||
|
||||
:::warning An empty selector matches nothing
|
||||
Clearing the tag selector does not mean "all servers". A workflow with no named
|
||||
servers and no tags matches nothing and is refused at run time rather than
|
||||
reported as a success over zero machines.
|
||||
|
||||
The alternative reading, where an empty field means the whole fleet, turns a
|
||||
cleared box into a fleet-wide run. That is not a mistake anyone should be able to
|
||||
make by deleting text.
|
||||
:::
|
||||
|
||||
Tags are read **at run time**, not when you save. Tag a new machine `env:prod`
|
||||
and the next run of an `env:prod` workflow includes it, with nothing to update on
|
||||
the workflow itself. The same is true in reverse: removing a tag removes the
|
||||
machine from every workflow that selected on it.
|
||||
|
||||
### Offline servers are still targeted
|
||||
|
||||
A server matched by tag is dispatched to even if its agent is offline, and that
|
||||
step fails visibly on that machine. Vantage does not quietly shrink your target
|
||||
list to the machines that happened to be reachable a patch run that skipped
|
||||
three servers and reported success is worse than one that failed on three and
|
||||
said so.
|
||||
|
||||
Re-run the workflow once they are back, or fix the agent first.
|
||||
|
||||
## Running
|
||||
|
||||
**Run** snapshots the resolved steps into the run record and dispatches each step
|
||||
|
||||
@@ -11,6 +11,12 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// Embeds the IANA zone database in the binary. Load-bearing: server/Dockerfile
|
||||
// builds on Alpine, which ships no zoneinfo, so without this
|
||||
// time.LoadLocation("Europe/London") fails in production and every workflow
|
||||
// schedule silently falls back to UTC.
|
||||
_ "time/tzdata"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
|
||||
@@ -18,6 +24,7 @@ import (
|
||||
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -112,6 +119,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure secret indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureServerIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure server indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureSettingsIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure settings indexes: %v", err)
|
||||
}
|
||||
@@ -175,6 +186,10 @@ func serve() {
|
||||
services.StartAuditSweeper(jobCtx)
|
||||
services.StartReaper(jobCtx)
|
||||
monitorsched.Start(jobCtx)
|
||||
workflowsched.Start(jobCtx, workflowsched.Deps{
|
||||
TriggerWorkflow: services.TriggerWorkflow,
|
||||
LogEvent: services.LogEvent,
|
||||
})
|
||||
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
+5
-2
@@ -14,9 +14,13 @@ require (
|
||||
google.golang.org/grpc v1.64.0
|
||||
)
|
||||
|
||||
require github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
require (
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -38,7 +42,6 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/sirupsen/logrus v1.4.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
||||
@@ -70,6 +70,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -51,6 +51,9 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
|
||||
|
||||
apiGroup.GET("/servers", listServers)
|
||||
// Static segment, registered alongside /servers/:id exactly as
|
||||
// /servers/new already is — gin resolves statics ahead of wildcards.
|
||||
apiGroup.GET("/servers/tags", listKnownTags)
|
||||
apiGroup.POST("/servers", createServer)
|
||||
apiGroup.GET("/servers/new", newServer)
|
||||
apiGroup.POST("/servers/new", newServer)
|
||||
@@ -59,6 +62,7 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/servers/:id/generate-key", generateKey)
|
||||
apiGroup.POST("/servers/:id/update-agent", updateAgent)
|
||||
apiGroup.POST("/servers/:id/apply-updates", applyUpdates)
|
||||
apiGroup.PUT("/servers/:id/tags", putServerTags)
|
||||
|
||||
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
|
||||
|
||||
@@ -119,7 +123,12 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
}
|
||||
|
||||
func listServers(c *gin.Context) {
|
||||
servers, err := services.ListServers(auth.InstanceID(c))
|
||||
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
servers, err := services.ListServersFiltered(auth.InstanceID(c), sel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -127,6 +136,47 @@ func listServers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, servers)
|
||||
}
|
||||
|
||||
func listKnownTags(c *gin.Context) {
|
||||
tags, err := services.KnownTags(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
func putServerTags(c *gin.Context) {
|
||||
var body struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
serverID := c.Param("id")
|
||||
|
||||
before, err := services.GetServer(instanceID, serverID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.SetServerTags(instanceID, serverID, body.Tags); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidTag) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "server.tags_updated", actorFromCtx(c), serverID, "",
|
||||
fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags))
|
||||
c.JSON(http.StatusOK, gin.H{"tags": body.Tags})
|
||||
}
|
||||
|
||||
func createServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
@@ -34,6 +36,8 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
g.DELETE("/workflows/:id", deleteWorkflow)
|
||||
g.POST("/workflows/:id/run", runWorkflow)
|
||||
g.GET("/workflows/:id/runs", listWorkflowRuns)
|
||||
g.PUT("/workflows/:id/schedule", putWorkflowSchedule)
|
||||
g.GET("/workflows/:id/schedule/preview", previewWorkflowSchedule)
|
||||
|
||||
g.GET("/runs/:runId", getRun)
|
||||
g.POST("/runs/:runId/cancel", cancelRun)
|
||||
@@ -343,6 +347,10 @@ func deleteWorkflow(c *gin.Context) {
|
||||
func runWorkflow(c *gin.Context) {
|
||||
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrNoTargets) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -382,3 +390,51 @@ func cancelRun(c *gin.Context) {
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
||||
}
|
||||
|
||||
func putWorkflowSchedule(c *gin.Context) {
|
||||
var body models.Schedule
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := auth.InstanceID(c)
|
||||
next, err := services.SetSchedule(instanceID, c.Param("id"), &body)
|
||||
if err != nil {
|
||||
if errors.Is(err, workflowsched.ErrBadSchedule) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
|
||||
fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
|
||||
c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next})
|
||||
}
|
||||
|
||||
// previewWorkflowSchedule exists so the browser and the scheduler agree on
|
||||
// what a cron string means. A client-side cron parser that disagrees with the
|
||||
// server by one field is a bug found in production, at night.
|
||||
func previewWorkflowSchedule(c *gin.Context) {
|
||||
expr := c.Query("cron")
|
||||
tz := c.Query("tz")
|
||||
|
||||
occurrences := make([]time.Time, 0, 3)
|
||||
from := time.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
next, err := workflowsched.NextOccurrence(expr, tz, from)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
occurrences = append(occurrences, next)
|
||||
from = next
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"occurrences": occurrences})
|
||||
}
|
||||
|
||||
@@ -44,24 +44,25 @@ type Inventory struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
|
||||
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
|
||||
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
|
||||
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
|
||||
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
|
||||
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
|
||||
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
|
||||
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
|
||||
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
|
||||
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
|
||||
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
|
||||
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
|
||||
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
|
||||
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
|
||||
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
|
||||
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
|
||||
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
|
||||
Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -44,13 +44,32 @@ type StepOverride struct {
|
||||
SecretRefs []string `bson:"secret_refs,omitempty" json:"secret_refs,omitempty"`
|
||||
}
|
||||
|
||||
type Schedule struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
Cron string `bson:"cron" json:"cron"` // 5-field: minute hour dom month dow
|
||||
TZ string `bson:"tz" json:"tz"` // IANA name, e.g. Europe/London
|
||||
}
|
||||
|
||||
// Skip records why an occurrence did not run. Recording a reason nobody reads
|
||||
// is the same as not recording one, so this is surfaced in the UI.
|
||||
type Skip struct {
|
||||
Reason string `bson:"reason" json:"reason"` // "missed" | "already_running"
|
||||
Due time.Time `bson:"due" json:"due"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
|
||||
TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"`
|
||||
Steps []WorkflowStepRef `bson:"steps" json:"steps"`
|
||||
Schedule *Schedule `bson:"schedule,omitempty" json:"schedule,omitempty"`
|
||||
NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"`
|
||||
LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"`
|
||||
LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
@@ -371,3 +372,46 @@ func notifyServerOffline(instanceID string, channelIDs []string, s models.Server
|
||||
}(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// ListServersFiltered is ListServers with an optional tag selector. An empty
|
||||
// selector returns the whole fleet — unlike MatchesTags, where empty means
|
||||
// "nothing", because here the caller is a list view whose default is
|
||||
// "everything", not a run about to touch machines.
|
||||
func ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
for k, v := range sel {
|
||||
filter["tags."+k] = v
|
||||
}
|
||||
|
||||
cur, err := db.Col("servers").Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
servers := []models.Server{}
|
||||
if err := cur.All(ctx, &servers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// EnsureServerIndexes declares the wildcard index over the tag subdocument.
|
||||
// It is wildcard because the queried key is chosen by the user at request time
|
||||
// and cannot be named in advance.
|
||||
//
|
||||
// Non-fatal, following EnsureSecretIndexes: a missing index degrades tag
|
||||
// filtering to a collection scan over a small collection, which is slower.
|
||||
// A fatal error here would refuse to boot the fleet list over it.
|
||||
func EnsureServerIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("servers").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "tags.$**", Value: 1}},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// ErrInvalidTag is returned for any tag the rules below reject. Handlers map
|
||||
// it to 400 — a malformed tag is the caller's mistake, not a server fault.
|
||||
var ErrInvalidTag = errors.New("invalid tag")
|
||||
|
||||
const (
|
||||
maxTagKeyLen = 32
|
||||
maxTagValueLen = 64
|
||||
maxTagsPerHost = 20
|
||||
// Reserved for tags the agent may derive from inventory later. Refusing
|
||||
// it now means a user tag written today can never collide with a system
|
||||
// tag invented tomorrow.
|
||||
sysTagPrefix = "sys:"
|
||||
)
|
||||
|
||||
func validTagRunes(s string) bool {
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-' || r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateTags enforces the shape of a whole tag map. It lives in the service
|
||||
// layer rather than a handler so that every write path — the tags endpoint,
|
||||
// server create, anything added later — agrees on what a valid tag is.
|
||||
func ValidateTags(tags map[string]string) error {
|
||||
if len(tags) > maxTagsPerHost {
|
||||
return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost)
|
||||
}
|
||||
for k, v := range tags {
|
||||
if strings.HasPrefix(k, sysTagPrefix) {
|
||||
return fmt.Errorf("%w: keys beginning %q are reserved", ErrInvalidTag, sysTagPrefix)
|
||||
}
|
||||
if k == "" || len(k) > maxTagKeyLen || !validTagRunes(k) {
|
||||
return fmt.Errorf("%w: key %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagKeyLen)
|
||||
}
|
||||
if v == "" || len(v) > maxTagValueLen || !validTagRunes(v) {
|
||||
return fmt.Errorf("%w: value for %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagValueLen)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseTagFilters turns repeated ?tag=key:value query values into a map.
|
||||
//
|
||||
// A malformed filter is an error rather than a silently ignored value: a
|
||||
// filter that matches nothing and a filter that is nonsense look identical in
|
||||
// a list, and only one of them is the caller's fault.
|
||||
func ParseTagFilters(raw []string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(raw))
|
||||
for _, r := range raw {
|
||||
k, v, found := strings.Cut(r, ":")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: filter %q must be key:value", ErrInvalidTag, r)
|
||||
}
|
||||
if strings.Contains(v, ":") {
|
||||
return nil, fmt.Errorf("%w: filter %q has more than one colon", ErrInvalidTag, r)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if err := ValidateTags(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetServerTags replaces a server's whole tag map.
|
||||
//
|
||||
// Replace rather than patch: a tag set is small enough that sending all of it
|
||||
// is free, and last-write-wins over a whole map is easier to reason about than
|
||||
// merge semantics between two people editing the same server.
|
||||
func SetServerTags(instanceID, serverID string, tags map[string]string) error {
|
||||
if err := ValidateTags(tags); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := db.Col("servers").UpdateOne(ctx,
|
||||
bson.M{"server_id": serverID, "instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"tags": tags}},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return mongo.ErrNoDocuments
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KnownTags returns every key in use in this instance with its distinct
|
||||
// values, for the UI's pickers. This is an aggregation rather than a
|
||||
// maintained registry: a tag is a property of a server, not an entity, and a
|
||||
// registry would need reference counting to know when a tag stopped existing.
|
||||
func KnownTags(instanceID string) (map[string][]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cur, err := db.Col("servers").Find(ctx,
|
||||
bson.M{"instance_id": instanceID, "tags": bson.M{"$exists": true}},
|
||||
options.Find().SetProjection(bson.M{"tags": 1}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
seen := map[string]map[string]bool{}
|
||||
for cur.Next(ctx) {
|
||||
var s models.Server
|
||||
if err := cur.Decode(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range s.Tags {
|
||||
if seen[k] == nil {
|
||||
seen[k] = map[string]bool{}
|
||||
}
|
||||
seen[k][v] = true
|
||||
}
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string][]string, len(seen))
|
||||
for k, vals := range seen {
|
||||
list := make([]string, 0, len(vals))
|
||||
for v := range vals {
|
||||
list = append(list, v)
|
||||
}
|
||||
sort.Strings(list)
|
||||
out[k] = list
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
// ErrNoTargets means a workflow named no servers and matched none. Handlers
|
||||
// map it to 400: a workflow that matches nothing must say so rather than
|
||||
// report success over zero servers.
|
||||
var ErrNoTargets = errors.New("workflow has no target servers")
|
||||
|
||||
// MatchesTags reports whether srv carries every pair in sel — AND across keys.
|
||||
// An empty selector matches nothing. That is deliberate: the alternative,
|
||||
// "matches everything", turns a cleared field in the workflow designer into a
|
||||
// fleet-wide run.
|
||||
func MatchesTags(srv models.Server, sel map[string]string) bool {
|
||||
if len(sel) == 0 {
|
||||
return false
|
||||
}
|
||||
for k, v := range sel {
|
||||
if srv.Tags[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UnionTargets returns the distinct union of the servers named by ids and
|
||||
// those matching sel, in the order they appear in all.
|
||||
//
|
||||
// Order comes from the fleet rather than the arguments so that two workflows
|
||||
// naming the same servers in a different order still run them in the same
|
||||
// order, which makes two runs comparable line by line.
|
||||
//
|
||||
// Offline servers are NOT filtered out. The dispatcher already answers 503 per
|
||||
// server, and a patch run that silently omits an unreachable machine is worse
|
||||
// than one that visibly fails on it.
|
||||
func UnionTargets(all []models.Server, ids []string, sel map[string]string) []models.Server {
|
||||
named := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
named[id] = true
|
||||
}
|
||||
|
||||
out := make([]models.Server, 0, len(ids)+len(all))
|
||||
for _, s := range all {
|
||||
if named[s.ServerID] || MatchesTags(s, sel) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveTargets is the database-backed wrapper around UnionTargets. It is the
|
||||
// single answer to "which servers does this workflow touch", used by the run
|
||||
// path and by validation alike, so the two cannot disagree.
|
||||
func ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]models.Server, error) {
|
||||
all, err := ListServers(instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched := UnionTargets(all, ids, sel)
|
||||
if len(matched) == 0 {
|
||||
return nil, ErrNoTargets
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
@@ -22,17 +22,14 @@ func TriggerWorkflow(instanceID, workflowID, actor string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(wf.TargetServerIDs) == 0 {
|
||||
return "", fmt.Errorf("workflow has no target servers")
|
||||
targets, err := ResolveTargets(instanceID, wf.TargetServerIDs, wf.TargetTags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(wf.Steps) == 0 {
|
||||
return "", fmt.Errorf("workflow has no steps")
|
||||
}
|
||||
|
||||
if err := validateTargetServers(instanceID, wf.TargetServerIDs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := wfCtx()
|
||||
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"instance_id": instanceID, "workflow_id": workflowID, "status": "running"})
|
||||
cancel()
|
||||
@@ -54,14 +51,10 @@ func TriggerWorkflow(instanceID, workflowID, actor string) (string, error) {
|
||||
Status: "running",
|
||||
TriggeredBy: actor,
|
||||
StartedAt: time.Now(),
|
||||
ServerRuns: make([]models.ServerRun, 0, len(wf.TargetServerIDs)),
|
||||
ServerRuns: make([]models.ServerRun, 0, len(targets)),
|
||||
}
|
||||
for _, sid := range wf.TargetServerIDs {
|
||||
hostname := sid
|
||||
if s, e := getServerByID(sid); e == nil {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}}
|
||||
for _, srv := range targets {
|
||||
sr := models.ServerRun{ServerID: srv.ServerID, Hostname: srv.Hostname, Status: "queued", RunEnv: map[string]string{}}
|
||||
for _, rs := range resolved {
|
||||
sr.Steps = append(sr.Steps, models.StepRun{Order: rs.Order, Name: rs.Name, Status: "queued", OutputEnv: map[string]string{}})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
@@ -60,6 +61,11 @@ func EnsureWorkflowIndexes() error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("workflows").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "next_run_at", Value: 1}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EnsureLogIndexes(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -249,6 +255,9 @@ func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, err
|
||||
if err := ValidateWorkflow(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateTags(w.TargetTags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,6 +274,9 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
|
||||
if err := ValidateWorkflow(w); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateTags(w.TargetTags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -272,6 +284,7 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
|
||||
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}, bson.M{"$set": bson.M{
|
||||
"name": w.Name,
|
||||
"target_server_ids": w.TargetServerIDs,
|
||||
"target_tags": w.TargetTags,
|
||||
"steps": w.Steps,
|
||||
"updated_at": time.Now(),
|
||||
}})
|
||||
@@ -314,3 +327,45 @@ func DeleteWorkflow(instanceID, id string) error {
|
||||
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetSchedule validates and stores a workflow's schedule, computing the first
|
||||
// occurrence. next_run_at is persisted rather than held in memory: a leader
|
||||
// handover between computing an occurrence and firing it would otherwise lose
|
||||
// it or fire it twice.
|
||||
//
|
||||
// Passing s == nil, or a disabled schedule, clears next_run_at so the
|
||||
// scheduler's query stops matching the document at all.
|
||||
func SetSchedule(instanceID, workflowID string, s *models.Schedule) (*time.Time, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
|
||||
set := bson.M{"schedule": s, "updated_at": time.Now()}
|
||||
unset := bson.M{}
|
||||
|
||||
var next *time.Time
|
||||
if s != nil && s.Enabled {
|
||||
at, err := workflowsched.NextOccurrence(s.Cron, s.TZ, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next = &at
|
||||
set["next_run_at"] = at
|
||||
} else {
|
||||
unset["next_run_at"] = ""
|
||||
}
|
||||
|
||||
update := bson.M{"$set": set}
|
||||
if len(unset) > 0 {
|
||||
update["$unset"] = unset
|
||||
}
|
||||
|
||||
res, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": workflowID, "instance_id": instanceID}, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return nil, mongo.ErrNoDocuments
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package workflowsched fires workflow runs on a cron schedule.
|
||||
//
|
||||
// Only robfig/cron's parser is used — Parse and Next. Its own scheduler is
|
||||
// not, because this work runs under the housekeeping leader lock and has to
|
||||
// stop the moment leadership is lost.
|
||||
package workflowsched
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// ErrBadSchedule covers both a malformed expression and an unknown timezone.
|
||||
// Handlers map it to 400 — both are the caller's mistake, and both are much
|
||||
// cheaper to find at save time than at 2am.
|
||||
var ErrBadSchedule = errors.New("invalid schedule")
|
||||
|
||||
// Standard 5-field cron: minute hour dom month dow. Deliberately no seconds
|
||||
// field and no descriptors — a schedule a person cannot read back is a
|
||||
// schedule nobody can audit.
|
||||
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
|
||||
func ParseSchedule(expr, tz string) (cron.Schedule, error) {
|
||||
if tz == "" {
|
||||
return nil, fmt.Errorf("%w: a timezone is required", ErrBadSchedule)
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return nil, fmt.Errorf("%w: unknown timezone %q", ErrBadSchedule, tz)
|
||||
}
|
||||
sched, err := cronParser.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrBadSchedule, err)
|
||||
}
|
||||
return sched, nil
|
||||
}
|
||||
|
||||
// NextOccurrence returns the first firing strictly after from, computed in the
|
||||
// schedule's own zone so that a DST boundary moves the wall-clock time the way
|
||||
// a person expects rather than drifting by an hour for half the year.
|
||||
func NextOccurrence(expr, tz string, from time.Time) (time.Time, error) {
|
||||
sched, err := ParseSchedule(expr, tz)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("%w: unknown timezone %q", ErrBadSchedule, tz)
|
||||
}
|
||||
return sched.Next(from.In(loc)), nil
|
||||
}
|
||||
|
||||
type Decision string
|
||||
|
||||
const (
|
||||
Fire Decision = "fire"
|
||||
SkipMissed Decision = "missed"
|
||||
SkipRunning Decision = "already_running"
|
||||
)
|
||||
|
||||
// GraceWindow is how late an occurrence may be and still run. A job missed by
|
||||
// ten minutes during a deploy should still run; one missed by two days should
|
||||
// not fire at lunchtime.
|
||||
const GraceWindow = time.Hour
|
||||
|
||||
// Decide is the whole fire/skip policy, kept pure so it can be tested without
|
||||
// a database and read without following a loop.
|
||||
//
|
||||
// The missed check comes first: an occurrence that is already too old to run
|
||||
// should be recorded as missed regardless of what is running now, or a slow
|
||||
// run would relabel a stale occurrence as a fresh conflict.
|
||||
func Decide(due, now time.Time, runActive bool) Decision {
|
||||
if now.Sub(due) > GraceWindow {
|
||||
return SkipMissed
|
||||
}
|
||||
if runActive {
|
||||
return SkipRunning
|
||||
}
|
||||
return Fire
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package workflowsched
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
const tickInterval = 30 * time.Second
|
||||
|
||||
// Deps are the service functions the loop needs. They are injected rather than
|
||||
// imported because services already imports this package for NextOccurrence,
|
||||
// and a package cannot import its own importer.
|
||||
type Deps struct {
|
||||
TriggerWorkflow func(instanceID, workflowID, actor string) (string, error)
|
||||
LogEvent func(instanceID, eventType, actor, serverID, keyID, details string)
|
||||
}
|
||||
|
||||
// Start runs the scheduler until ctx is cancelled. It is called inside
|
||||
// bus.RunAsLeader("housekeeping", …) alongside monitorsched and the sweepers:
|
||||
// one role, one lock. N replicas each running this loop would fire every
|
||||
// scheduled workflow N times.
|
||||
func Start(ctx context.Context, deps Deps) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(tickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
tick(ctx, deps)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func tick(ctx context.Context, deps Deps) {
|
||||
now := time.Now()
|
||||
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{
|
||||
"schedule.enabled": true,
|
||||
"next_run_at": bson.M{"$lte": now},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("workflowsched: find due: %v", err)
|
||||
return
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
var due []models.Workflow
|
||||
if err := cur.All(ctx, &due); err != nil {
|
||||
log.Printf("workflowsched: decode due: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, wf := range due {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
process(ctx, deps, wf, now)
|
||||
}
|
||||
}
|
||||
|
||||
func process(ctx context.Context, deps Deps, wf models.Workflow, now time.Time) {
|
||||
if wf.NextRunAt == nil || wf.Schedule == nil {
|
||||
return
|
||||
}
|
||||
dueAt := *wf.NextRunAt
|
||||
|
||||
next, err := NextOccurrence(wf.Schedule.Cron, wf.Schedule.TZ, now)
|
||||
if err != nil {
|
||||
// A schedule that no longer parses cannot be advanced, and leaving
|
||||
// next_run_at in the past would spin this loop every 30 seconds
|
||||
// forever. Disable it and say so.
|
||||
log.Printf("workflowsched: workflow %s has an unusable schedule, disabling: %v", wf.WorkflowID, err)
|
||||
disable(ctx, deps, wf, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// The claim. Matching on the current next_run_at as well as the id means a
|
||||
// second process reaching this document after another has claimed it
|
||||
// matches nothing and does nothing. This — not the leader lock — is what
|
||||
// makes a double fire impossible; the lock only keeps it cheap.
|
||||
res, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": wf.WorkflowID, "next_run_at": dueAt},
|
||||
bson.M{"$set": bson.M{"next_run_at": next}},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("workflowsched: claim %s: %v", wf.WorkflowID, err)
|
||||
return
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return // claimed elsewhere
|
||||
}
|
||||
|
||||
switch Decide(dueAt, now, hasActiveRun(ctx, wf.InstanceID, wf.WorkflowID)) {
|
||||
case SkipMissed:
|
||||
recordSkip(ctx, deps, wf, string(SkipMissed), dueAt, now)
|
||||
case SkipRunning:
|
||||
recordSkip(ctx, deps, wf, string(SkipRunning), dueAt, now)
|
||||
case Fire:
|
||||
if _, err := deps.TriggerWorkflow(wf.InstanceID, wf.WorkflowID, "schedule"); err != nil {
|
||||
log.Printf("workflowsched: trigger %s: %v", wf.WorkflowID, err)
|
||||
recordSkip(ctx, deps, wf, "error: "+err.Error(), dueAt, now)
|
||||
return
|
||||
}
|
||||
_, _ = db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": wf.WorkflowID},
|
||||
bson.M{"$set": bson.M{"last_run_at": now}, "$unset": bson.M{"last_skipped": ""}},
|
||||
)
|
||||
deps.LogEvent(wf.InstanceID, "workflow.scheduled_run", "schedule", "", "",
|
||||
"workflow "+wf.Name+" started on schedule")
|
||||
}
|
||||
}
|
||||
|
||||
func hasActiveRun(ctx context.Context, instanceID, workflowID string) bool {
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"workflow_id": workflowID,
|
||||
"status": "running",
|
||||
}, options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func recordSkip(ctx context.Context, deps Deps, wf models.Workflow, reason string, due, at time.Time) {
|
||||
_, _ = db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": wf.WorkflowID},
|
||||
bson.M{"$set": bson.M{"last_skipped": models.Skip{Reason: reason, Due: due, At: at}}},
|
||||
)
|
||||
deps.LogEvent(wf.InstanceID, "workflow.schedule_skipped", "schedule", "", "",
|
||||
"workflow "+wf.Name+" skipped "+due.Format(time.RFC3339)+": "+reason)
|
||||
}
|
||||
|
||||
func disable(ctx context.Context, deps Deps, wf models.Workflow, reason string) {
|
||||
_, _ = db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": wf.WorkflowID},
|
||||
bson.M{
|
||||
"$set": bson.M{"schedule.enabled": false},
|
||||
"$unset": bson.M{"next_run_at": ""},
|
||||
},
|
||||
)
|
||||
deps.LogEvent(wf.InstanceID, "workflow.schedule_disabled", "schedule", "", "",
|
||||
"workflow "+wf.Name+" schedule disabled: "+reason)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function AssignModal({
|
||||
|
||||
const { data: servers } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: api.listServers,
|
||||
queryFn: () => api.listServers(),
|
||||
});
|
||||
|
||||
const { mutate: assign, isPending, error } = useMutation({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api, ServerStatus, GenerateKeyOptions, PackageUpdate, Inventory } from
|
||||
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { useLicense } from "@/lib/useLicense";
|
||||
import { TagChips } from "@/components/servers/TagChips";
|
||||
|
||||
function statusVariant(status: ServerStatus) {
|
||||
switch (status) {
|
||||
@@ -397,6 +398,9 @@ export default function ServerDetailPage() {
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
||||
<div className="mt-2">
|
||||
<TagChips serverId={server.server_id} tags={server.tags} editable />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Rendered disabled rather than hidden when the licence does not
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, Server } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { TagChips } from "@/components/servers/TagChips";
|
||||
import { TagFilterBar } from "@/components/servers/TagFilterBar";
|
||||
|
||||
|
||||
type DotStatus = "offline" | "needs-update" | "has-package-updates" | "ok";
|
||||
@@ -56,10 +60,31 @@ function formatLastSeen(dateStr: string): string {
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
export default function ServersPage() {
|
||||
// useSearchParams opts this page into client-side bailout, which the App
|
||||
// Router only permits inside a Suspense boundary — hence the wrapper at the
|
||||
// bottom of this file rather than a bare default export.
|
||||
function ServersPageBody() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// The filter lives in the URL so a filtered fleet view is a shareable link.
|
||||
const selected = Object.fromEntries(
|
||||
searchParams
|
||||
.getAll("tag")
|
||||
.map((t) => t.split(":"))
|
||||
.filter((p) => p.length === 2),
|
||||
) as Record<string, string>;
|
||||
|
||||
function setSelected(next: Record<string, string>) {
|
||||
const qs = Object.entries(next)
|
||||
.map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`)
|
||||
.join("&");
|
||||
router.replace(qs ? `/servers?${qs}` : "/servers");
|
||||
}
|
||||
|
||||
const { data: servers, isLoading, error } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: api.listServers,
|
||||
queryKey: ["servers", selected],
|
||||
queryFn: () => api.listServers(selected),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
@@ -89,6 +114,8 @@ export default function ServersPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<TagFilterBar value={selected} onChange={setSelected} />
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -105,6 +132,7 @@ export default function ServersPage() {
|
||||
<Th>Hostname</Th>
|
||||
<Th>IP Address</Th>
|
||||
<Th>OS</Th>
|
||||
<Th>Tags</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Last Seen</Th>
|
||||
<Th />
|
||||
@@ -126,6 +154,9 @@ export default function ServersPage() {
|
||||
<Td label="OS">
|
||||
<span className="text-text-secondary">{server.os_info}</span>
|
||||
</Td>
|
||||
<Td label="Tags">
|
||||
<TagChips serverId={server.server_id} tags={server.tags} />
|
||||
</Td>
|
||||
<Td label="Status">
|
||||
<StatusDot status={resolveStatus(server, latestVersion)} />
|
||||
</Td>
|
||||
@@ -166,3 +197,17 @@ export default function ServersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServersPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ServersPageBody />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ function snapshotOf(w: Workflow): string {
|
||||
return JSON.stringify({
|
||||
name: w.name,
|
||||
target_server_ids: w.target_server_ids,
|
||||
target_tags: w.target_tags ?? {},
|
||||
steps: w.steps,
|
||||
});
|
||||
}
|
||||
@@ -65,12 +66,21 @@ export default function WorkflowBuilder() {
|
||||
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
|
||||
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
// Rows rather than a map so a half-typed pair (a key with no value yet)
|
||||
// survives a keystroke. Only complete pairs are written into wf.target_tags,
|
||||
// which is what the debounced save persists.
|
||||
const [tagRows, setTagRows] = useState<[string, string][]>([]);
|
||||
const tagRowsSeeded = useRef(false);
|
||||
|
||||
const { data: loaded } = useQuery({
|
||||
queryKey: ["workflow", id],
|
||||
queryFn: () => api.getWorkflow(id),
|
||||
});
|
||||
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
|
||||
// The whole fleet, so the "runs on N servers" readout can be computed in the
|
||||
// browser rather than asking the server to resolve targets on every keystroke.
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
|
||||
const { data: secretGroups } = useQuery({
|
||||
queryKey: ["secret-groups"],
|
||||
queryFn: api.listSecretGroups,
|
||||
@@ -81,6 +91,10 @@ export default function WorkflowBuilder() {
|
||||
setWf(loaded);
|
||||
savedSnapshotRef.current = snapshotOf(loaded);
|
||||
}
|
||||
if (loaded && !tagRowsSeeded.current) {
|
||||
tagRowsSeeded.current = true;
|
||||
setTagRows(Object.entries(loaded.target_tags ?? {}));
|
||||
}
|
||||
|
||||
}, [loaded]);
|
||||
|
||||
@@ -135,6 +149,30 @@ export default function WorkflowBuilder() {
|
||||
|
||||
const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined);
|
||||
|
||||
const targetTags = wf.target_tags ?? {};
|
||||
|
||||
// Rows are the editing surface; the map is what is saved. Incomplete rows
|
||||
// are dropped rather than saved half-written, which is also what keeps the
|
||||
// readout below honest while someone is still typing a key.
|
||||
const commitTagRows = (rows: [string, string][]) => {
|
||||
setTagRows(rows);
|
||||
setWf({ ...wf, target_tags: Object.fromEntries(rows.filter(([k, v]) => k && v)) });
|
||||
};
|
||||
|
||||
/*
|
||||
* This is the other half of a deliberate duplication: the authority is
|
||||
* UnionTargets/MatchesTags in server/internal/services/targets.go, and this
|
||||
* only exists so the designer can answer "how many servers?" without a
|
||||
* round trip. It must stay identical in meaning — an EMPTY selector matches
|
||||
* NOTHING (a cleared field must not become a fleet-wide run), and multiple
|
||||
* tag keys AND together. Change one, change both.
|
||||
*/
|
||||
const matched = (servers ?? []).filter(
|
||||
(s) =>
|
||||
wf.target_server_ids.includes(s.server_id) ||
|
||||
(Object.keys(targetTags).length > 0 && Object.entries(targetTags).every(([k, v]) => s.tags?.[k] === v)),
|
||||
);
|
||||
|
||||
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
|
||||
const selectedRef = selected !== null ? sortedSteps[selected] : null;
|
||||
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
|
||||
@@ -310,7 +348,7 @@ export default function WorkflowBuilder() {
|
||||
<span className="text-text-secondary">· {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : ""}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">{wf.target_server_ids.length} servers</span>
|
||||
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">{matched.length} servers</span>
|
||||
<Link href={`/workflows/${id}/runs`} className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary">
|
||||
Runs
|
||||
</Link>
|
||||
@@ -338,6 +376,72 @@ export default function WorkflowBuilder() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-[340px] flex-col items-center">
|
||||
<div className="mb-2 w-full rounded border border-border bg-surface p-3">
|
||||
<div className="mb-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">Targets</div>
|
||||
|
||||
<p className="mb-2 text-xs text-text-secondary">
|
||||
{wf.target_server_ids.length} named in <button type="button" onClick={() => setEditWorkflowOpen(true)} className="text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal">Edit</button>, plus anything matching every tag below.
|
||||
</p>
|
||||
|
||||
<datalist id="workflow-tag-keys">
|
||||
{Object.keys(knownTags ?? {}).map((k) => (
|
||||
<option key={k} value={k} />
|
||||
))}
|
||||
</datalist>
|
||||
<datalist id="workflow-tag-values">
|
||||
{Object.values(knownTags ?? {})
|
||||
.flat()
|
||||
.map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{tagRows.map(([k, v], i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
list="workflow-tag-keys"
|
||||
value={k}
|
||||
onChange={(e) => commitTagRows(tagRows.map((row, j): [string, string] => (j === i ? [e.target.value, row[1]] : row)))}
|
||||
placeholder="env"
|
||||
className="w-28 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<span className="font-mono text-text-tertiary">:</span>
|
||||
<input
|
||||
list="workflow-tag-values"
|
||||
value={v}
|
||||
onChange={(e) => commitTagRows(tagRows.map((row, j): [string, string] => (j === i ? [row[0], e.target.value] : row)))}
|
||||
placeholder="prod"
|
||||
className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitTagRows(tagRows.filter((_, j) => j !== i))}
|
||||
className="text-xs text-text-tertiary hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
|
||||
aria-label={`Remove ${k || "tag"}`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{tagRows.length === 0 && <p className="text-xs text-text-secondary">No tag selector — only the named servers will run.</p>}
|
||||
</div>
|
||||
|
||||
{tagRows.length < 20 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitTagRows([...tagRows, ["", ""] as [string, string]])}
|
||||
className="mt-2 text-xs text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
|
||||
>
|
||||
Add tag
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="mt-3 font-mono text-xs text-text-secondary" title={matched.map((s) => s.hostname).join("\n")}>
|
||||
Runs on {matched.length} {matched.length === 1 ? "server" : "servers"}
|
||||
</p>
|
||||
{matched.length === 0 && <p className="text-xs text-danger">This workflow matches no servers and cannot run.</p>}
|
||||
</div>
|
||||
<DropZone pos={0} />
|
||||
{sortedSteps.map((ref, i) => {
|
||||
const lib = libById(ref.step_id);
|
||||
|
||||
@@ -9,106 +9,115 @@ import { Button, Card } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const qc = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: workflows, isLoading, error: loadError } = useQuery({
|
||||
queryKey: ["workflows"],
|
||||
queryFn: api.listWorkflows,
|
||||
});
|
||||
const {
|
||||
data: workflows,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useQuery({
|
||||
queryKey: ["workflows"],
|
||||
queryFn: api.listWorkflows,
|
||||
});
|
||||
|
||||
const { mutate: create, isPending } = useMutation({
|
||||
mutationFn: () => api.createWorkflow({ name: "Untitled workflow", target_server_ids: [], steps: [] }),
|
||||
onSuccess: (workflow) => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
router.push(`/workflows/${workflow.workflow_id}`);
|
||||
},
|
||||
onError: (err) => setError((err as Error).message),
|
||||
});
|
||||
const { mutate: create, isPending } = useMutation({
|
||||
mutationFn: () => api.createWorkflow({ name: "Untitled workflow", target_server_ids: [], steps: [] }),
|
||||
onSuccess: (workflow) => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
router.push(`/workflows/${workflow.workflow_id}`);
|
||||
},
|
||||
onError: (err) => setError((err as Error).message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Workflows</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{workflows?.length ?? 0} workflow{workflows?.length !== 1 ? "s" : ""} · run reusable steps across servers
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" loading={isPending} onClick={() => create()}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New Workflow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load workflows. Is the backend running?</div>
|
||||
) : workflows && workflows.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Targets</Th>
|
||||
<Th>Steps</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{workflows.map((w: Workflow) => (
|
||||
<Tr key={w.workflow_id}>
|
||||
<Td label="Name">
|
||||
<span className="font-medium text-text-primary">{w.name}</span>
|
||||
</Td>
|
||||
<Td label="Targets">
|
||||
<span className="text-text-secondary">
|
||||
{w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Td>
|
||||
<Td label="Steps">
|
||||
<span className="text-text-secondary">{w.steps.length}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/workflows/${w.workflow_id}/runs`}>
|
||||
<Button variant="ghost" size="sm">Runs</Button>
|
||||
</Link>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">Open →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-20 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
|
||||
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Workflows</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{workflows?.length ?? 0} workflow{workflows?.length !== 1 ? "s" : ""} · run reusable steps across servers
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" loading={isPending} onClick={() => create()}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New Workflow
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-secondary">No workflows yet.</p>
|
||||
<Button variant="primary" size="sm" className="mt-4" loading={isPending} onClick={() => create()}>
|
||||
Create your first workflow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load workflows. Is the backend running?</div>
|
||||
) : workflows && workflows.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Targets</Th>
|
||||
<Th>Steps</Th>
|
||||
<Th>Schedule</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{workflows.map((w: Workflow) => (
|
||||
<Tr key={w.workflow_id}>
|
||||
<Td label="Name">
|
||||
<span className="font-medium text-text-primary">{w.name}</span>
|
||||
</Td>
|
||||
<Td label="Targets">
|
||||
<span className="text-text-secondary">
|
||||
{w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Td>
|
||||
<Td label="Steps">
|
||||
<span className="text-text-secondary">{w.steps.length}</span>
|
||||
</Td>
|
||||
<Td label="Schedule">
|
||||
{w.schedule?.enabled && <span className="rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">{w.schedule.cron}</span>}
|
||||
{w.next_run_at && <span className="font-mono text-[11px] text-text-tertiary">next {new Date(w.next_run_at).toLocaleString()}</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/workflows/${w.workflow_id}/runs`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Runs
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Open →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-20 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
|
||||
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No workflows yet.</p>
|
||||
<Button variant="primary" size="sm" className="mt-4" loading={isPending} onClick={() => create()}>
|
||||
Create your first workflow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/*
|
||||
* A tag is key:value, so the chip shows both halves with the key dimmed — the
|
||||
* value is the part people scan for, the key is what disambiguates it.
|
||||
*/
|
||||
|
||||
export function TagChips({ serverId, tags, editable = false }: { serverId: string; tags?: Record<string, string>; editable?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<[string, string][]>(Object.entries(tags ?? {}));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The datalist id is scoped to the server: the fleet list renders one of
|
||||
// these per row, and a fixed id would have every editor read the first
|
||||
// one's options.
|
||||
const keysListId = `tag-keys-${serverId}`;
|
||||
|
||||
const { data: known } = useQuery({
|
||||
queryKey: ["server-tags"],
|
||||
queryFn: () => api.listKnownTags(),
|
||||
enabled: editing,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: () => api.setServerTags(serverId, Object.fromEntries(draft.filter(([k, v]) => k && v))),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["server-tags"] });
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const entries = Object.entries(tags ?? {});
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{entries.length === 0 && <span className="text-xs text-text-tertiary">No tags</span>}
|
||||
{entries.map(([k, v]) => (
|
||||
<span key={k} className="rounded-sm border border-border bg-surface-2 px-2 py-0.5 font-mono text-[11px]">
|
||||
<span className="text-text-tertiary">{k}:</span>
|
||||
<span className="text-text-primary">{v}</span>
|
||||
</span>
|
||||
))}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(entries);
|
||||
setEditing(true);
|
||||
}}
|
||||
className="rounded-sm px-1.5 py-0.5 text-[11px] text-text-secondary hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{entries.length === 0 ? "Add tags" : "Edit"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-3">
|
||||
<datalist id={keysListId}>{Object.keys(known ?? {}).map((k) => <option key={k} value={k} />)}</datalist>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{draft.map(([k, v], i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
list={keysListId}
|
||||
value={k}
|
||||
onChange={(e) => setDraft((d) => d.map((row, j): [string, string] => (j === i ? [e.target.value, row[1]] : row)))}
|
||||
placeholder="env"
|
||||
className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
<span className="font-mono text-text-tertiary">:</span>
|
||||
<input
|
||||
value={v}
|
||||
onChange={(e) => setDraft((d) => d.map((row, j): [string, string] => (j === i ? [row[0], e.target.value] : row)))}
|
||||
placeholder="prod"
|
||||
className="w-40 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((d) => d.filter((_, j) => j !== i))}
|
||||
className="text-xs text-text-tertiary hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-label={`Remove ${k || "tag"}`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft.length < 20 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((d) => [...d, ["", ""] as [string, string]])}
|
||||
className="mt-2 text-xs text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
Add tag
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" variant="primary" loading={isPending} onClick={() => save()}>
|
||||
Save tags
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditing(false); setError(null); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function TagFilterBar({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
|
||||
const { data: known } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
|
||||
|
||||
const keys = Object.keys(known ?? {}).sort();
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const active = Object.entries(value);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{keys.map((k) => (
|
||||
<select
|
||||
key={k}
|
||||
value={value[k] ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value };
|
||||
if (e.target.value) next[k] = e.target.value;
|
||||
else delete next[k];
|
||||
onChange(next);
|
||||
}}
|
||||
className="rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
|
||||
>
|
||||
<option value="">{k}: any</option>
|
||||
{(known?.[k] ?? []).map((v) => (
|
||||
<option key={v} value={v}>{`${k}: ${v}`}</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
{active.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({})}
|
||||
className="text-xs text-text-secondary hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
Clear {active.length} {active.length === 1 ? "filter" : "filters"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,73 +5,95 @@ import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
|
||||
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";
|
||||
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";
|
||||
|
||||
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(workflow.name);
|
||||
setTargets(workflow.target_server_ids);
|
||||
}
|
||||
}, [open, workflow]);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(workflow.name);
|
||||
setTargets(workflow.target_server_ids);
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
onSaved(updated); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
onSaved(updated);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
|
||||
catch (e) { setError((e as Error).message); setBusy(false); }
|
||||
};
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteWorkflow(workflow.workflow_id);
|
||||
router.push("/workflows");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<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>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<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>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onClick={del} loading={busy}>
|
||||
Delete workflow
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScheduleCard workflow={workflow} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/*
|
||||
* Presets write cron underneath rather than being their own storage format:
|
||||
* one representation, and the raw field is always the truth. The next three
|
||||
* occurrences come from the server so the browser cannot disagree with the
|
||||
* scheduler about what an expression means.
|
||||
*/
|
||||
|
||||
const PRESETS: { label: string; cron: string }[] = [
|
||||
{ label: "Hourly", cron: "0 * * * *" },
|
||||
{ label: "Nightly, 02:00", cron: "0 2 * * *" },
|
||||
{ label: "Weekly, Sun 02:00", cron: "0 2 * * 0" },
|
||||
{ label: "Monthly, 1st 02:00", cron: "0 2 1 * *" },
|
||||
];
|
||||
|
||||
const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "America/New_York", "America/Los_Angeles", "Asia/Singapore", "Australia/Sydney"];
|
||||
|
||||
export function ScheduleCard({ workflow }: { workflow: Workflow }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [enabled, setEnabled] = useState(workflow.schedule?.enabled ?? false);
|
||||
const [cron, setCron] = useState(workflow.schedule?.cron ?? "0 2 * * 0");
|
||||
const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: preview } = useQuery({
|
||||
queryKey: ["schedule-preview", workflow.workflow_id, cron, tz],
|
||||
queryFn: () => api.previewSchedule(workflow.workflow_id, cron, tz),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: () => api.setWorkflowSchedule(workflow.workflow_id, { enabled, cron, tz }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
<span className="block text-sm text-text-primary">Run on a schedule</span>
|
||||
<span className="mt-0.5 block text-xs text-text-tertiary">A scheduled run is skipped, not queued, while a previous run is still going.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.cron}
|
||||
type="button"
|
||||
onClick={() => setCron(p.cron)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
cron === p.cron ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Cron expression</span>
|
||||
<input
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<span className="mt-1.5 block font-mono text-[11px] text-text-tertiary">minute hour day month weekday</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Timezone</span>
|
||||
<select
|
||||
value={tz}
|
||||
onChange={(e) => setTz(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
|
||||
>
|
||||
{[...new Set([tz, ...ZONES])].map((z) => (
|
||||
<option key={z} value={z}>
|
||||
{z}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{workflow.last_skipped && (
|
||||
<p className="rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 text-xs text-warning">
|
||||
Skipped {new Date(workflow.last_skipped.due).toLocaleString()} —{" "}
|
||||
{workflow.last_skipped.reason === "already_running"
|
||||
? "previous run still active"
|
||||
: workflow.last_skipped.reason === "missed"
|
||||
? "the control plane was not running at the time"
|
||||
: workflow.last_skipped.reason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div>
|
||||
<Button variant="primary" loading={isPending} onClick={() => save()}>
|
||||
Save schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+41
-7
@@ -32,6 +32,7 @@ export interface Server {
|
||||
updates_checked_at?: string;
|
||||
console_protocols?: string[];
|
||||
inventory?: Inventory;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type MonitorType = "http" | "tcp" | "icmp" | "tls";
|
||||
@@ -248,7 +249,12 @@ export interface Workflow {
|
||||
workflow_id: string;
|
||||
name: string;
|
||||
target_server_ids: string[];
|
||||
target_tags?: Record<string, string>;
|
||||
steps: WorkflowStepRef[];
|
||||
schedule?: Schedule;
|
||||
next_run_at?: string;
|
||||
last_run_at?: string;
|
||||
last_skipped?: Skip;
|
||||
}
|
||||
|
||||
export interface StepRun {
|
||||
@@ -539,8 +545,20 @@ export const api = {
|
||||
return request<{ acknowledged: boolean }>(`/auth/providers/${id}/ack-notice`, { method: "POST" });
|
||||
},
|
||||
|
||||
listServers(): Promise<Server[]> {
|
||||
return request<Server[]>("/servers");
|
||||
listServers(tags?: Record<string, string>): Promise<Server[]> {
|
||||
const params = Object.entries(tags ?? {}).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`);
|
||||
return request<Server[]>(`/servers${params.length ? `?${params.join("&")}` : ""}`);
|
||||
},
|
||||
|
||||
listKnownTags(): Promise<Record<string, string[]>> {
|
||||
return request<Record<string, string[]>>("/servers/tags");
|
||||
},
|
||||
|
||||
setServerTags(serverId: string, tags: Record<string, string>): Promise<{ tags: Record<string, string> }> {
|
||||
return request<{ tags: Record<string, string> }>(`/servers/${serverId}/tags`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tags }),
|
||||
});
|
||||
},
|
||||
|
||||
getServer(serverId: string): Promise<ServerWithKeys> {
|
||||
@@ -642,11 +660,7 @@ export const api = {
|
||||
return request<Settings>("/settings");
|
||||
},
|
||||
|
||||
saveSettings(settings: {
|
||||
alerts: AlertSettings;
|
||||
workflow_log_retention_days?: number | null;
|
||||
local_login_enabled?: boolean;
|
||||
}): Promise<{ saved: boolean }> {
|
||||
saveSettings(settings: { alerts: AlertSettings; workflow_log_retention_days?: number | null; local_login_enabled?: boolean }): Promise<{ saved: boolean }> {
|
||||
return request<{ saved: boolean }>("/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
@@ -849,6 +863,14 @@ export const api = {
|
||||
serverRunLogStreamUrl(runId: string, serverId: string): string {
|
||||
return `/api/runs/${runId}/servers/${serverId}/logs/stream`;
|
||||
},
|
||||
|
||||
setWorkflowSchedule(workflowId: string, schedule: Schedule): Promise<{ schedule: Schedule; next_run_at: string | null }> {
|
||||
return request(`/workflows/${workflowId}/schedule`, { method: "PUT", body: JSON.stringify(schedule) });
|
||||
},
|
||||
|
||||
previewSchedule(workflowId: string, cron: string, tz: string): Promise<{ occurrences: string[] }> {
|
||||
return request(`/workflows/${workflowId}/schedule/preview?cron=${encodeURIComponent(cron)}&tz=${encodeURIComponent(tz)}`);
|
||||
},
|
||||
};
|
||||
|
||||
export type LicenseState = "valid" | "expired" | "invalid";
|
||||
@@ -889,3 +911,15 @@ export const licence = {
|
||||
return request("/license", { method: "POST", body: JSON.stringify({ blob }) });
|
||||
},
|
||||
};
|
||||
|
||||
export interface Schedule {
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
tz: string;
|
||||
}
|
||||
|
||||
export interface Skip {
|
||||
reason: string;
|
||||
due: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user