Compare commits

...
15 Commits
Author SHA1 Message Date
mrhid6 b21ac05547 feat: show workflow schedules in the list
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m34s
2026-08-04 14:16:13 +01:00
mrhid6 484b620867 feat: schedule editor on the workflow page 2026-08-04 14:13:31 +01:00
mrhid6 439bc2ed7d feat: schedule methods on the web api client 2026-08-04 14:10:04 +01:00
mrhid6 a1e6986a64 feat: fire scheduled workflow runs from the housekeeping leader 2026-08-04 13:53:42 +01:00
mrhid6 d0e1cc4ad6 feat: cron arithmetic and persisted workflow schedules 2026-08-04 13:51:10 +01:00
mrhid6 b877024365 docs: server tags and workflow tag targeting 2026-08-04 13:44:16 +01:00
mrhid6 2de7ac116b feat: filter the fleet by tag and target workflows by tag selector 2026-08-04 13:42:47 +01:00
mrhid6 fa1fd14ed1 feat: view and edit server tags 2026-08-04 13:38:42 +01:00
mrhid6 d1b3cd2f74 feat: target workflow runs by tag selector 2026-08-04 13:36:43 +01:00
mrhid6 e00a0da5d9 feat: tag endpoints for servers 2026-08-04 13:34:22 +01:00
mrhid6 fef0b7c7a1 feat: read and write server tags, resolve targets from the database 2026-08-04 13:33:00 +01:00
mrhid6 efd29dc259 feat: parse tag filters and resolve targets as ids union tag selector 2026-08-04 13:31:04 +01:00
mrhid6 13cd41d202 feat: validate server tags and add the model field 2026-08-04 13:30:06 +01:00
mrhid6 3530ce6cb7 docs: implementation plans for server tags and scheduled workflows 2026-08-04 13:26:04 +01:00
mrhid6 09522c2566 docs: design for server tags and scheduled workflows 2026-08-04 13:13:12 +01:00
29 changed files with 4133 additions and 208 deletions
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.
+46
View File
@@ -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
+44 -1
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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 -1
View File
@@ -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 {
+56
View File
@@ -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})
}
+21 -20
View File
@@ -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"`
}
+19
View File
@@ -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"`
}
+44
View File
@@ -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
}
+158
View File
@@ -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
}
+68
View File
@@ -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
}
+6 -13
View File
@@ -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{}})
}
+55
View File
@@ -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
}
+82
View File
@@ -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
}
+150
View File
@@ -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)
}
+1 -1
View File
@@ -22,7 +22,7 @@ function AssignModal({
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: api.listServers,
queryFn: () => api.listServers(),
});
const { mutate: assign, isPending, error } = useMutation({
+4
View File
@@ -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
+48 -3
View File
@@ -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>
);
}
+105 -1
View File
@@ -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);
+107 -98
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+83 -61
View File
@@ -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>
);
}
+138
View File
@@ -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
View File
@@ -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;
}