From ec0bf0f6c3353655288c585a5d17146f82a08526 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 10:56:40 +0100 Subject: [PATCH] P2R-01: REST + WS rewire against the slim shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedules CRUD now takes {cron, enabled, source_group_ids[]} with cron parsed via robfig/cron/v3 and group membership scoped to the host. New source-groups CRUD lives at /api/hosts/{id}/source-groups; delete refuses with 409 if any schedule still references the group, returning the schedule list so the UI can prompt 'remove from these schedules first.' Repo-maintenance GET/PUT manages forget/prune/check cadences on host_repo_maintenance — no version bump, the server-side ticker (P2R-06) drives execution. Per-source-group Run-now (POST /hosts/{id}/source-groups/{gid}/run) resolves the group's includes/excludes/retention/tag and dispatches a backup command.run with the new structured CommandRunPayload fields (Includes/Excludes/Tag). Old per-host /hosts/{id}/run-backup and /hosts/{id}/init-repo return 410 Gone with a redirect message. schedule_push.go is rebuilt: buildScheduleSetPayload assembles the slim wire shape, pushScheduleSetOnConn ships it during the on-hello window, pushScheduleSetAsync fires after every CRUD mutation, and dispatchScheduledJob handles agent schedule.fire by iterating the schedule's source groups and dispatching one backup per group with actor_kind=schedule and scheduled_id pointing at the schedule. Auto-init at first WS connect: when the host has repo creds bound and no init job in its history, server dispatches restic init. Restic's 'config file already exists' soft-success means re-runs against an existing repo no-op; we don't auto-retry on failure (operator triggers re-init manually via the danger zone in P2R-09). api.Schedule drops Kind/Paths/Excludes/Tags/RetentionPolicy/Manual etc. in favour of {id, cron, enabled, source_groups: [...]}. The agent scheduler stops checking sch.Manual; cmd/agent's backup dispatch reads Includes/Excludes/Tag instead of Args. Tests cover the new HTTP surface end-to-end: source-groups CRUD with in-use refusal, schedule validation (bad cron / missing groups / foreign group), repo-maintenance auto-seed and validation, the 410 route, and buildScheduleSetPayload's wire-shape correctness. Full suite passes; smoke env exercises auto-init dispatch on hello, async push after schedule create, and per-source-group Run-now landing the right paths/excludes/tag at the agent. --- CLAUDE.md | 7 + cmd/agent/main.go | 18 +- internal/agent/scheduler/scheduler.go | 7 - internal/agent/scheduler/scheduler_test.go | 8 +- internal/api/messages.go | 62 ++- internal/server/http/host_credentials.go | 61 +++ internal/server/http/jobs.go | 35 +- internal/server/http/p2r01_test.go | 482 +++++++++++++++++++++ internal/server/http/repo_maintenance.go | 145 +++++++ internal/server/http/run_group.go | 83 ++++ internal/server/http/schedule_push.go | 207 ++++++++- internal/server/http/schedules.go | 220 +++++++++- internal/server/http/server.go | 42 +- internal/server/http/source_groups.go | 242 +++++++++++ internal/server/http/ui_handlers.go | 22 +- internal/store/jobs.go | 18 + tasks.md | 4 +- web/static/css/styles.css | 2 +- 18 files changed, 1564 insertions(+), 101 deletions(-) create mode 100644 internal/server/http/p2r01_test.go create mode 100644 internal/server/http/repo_maintenance.go create mode 100644 internal/server/http/run_group.go create mode 100644 internal/server/http/source_groups.go diff --git a/CLAUDE.md b/CLAUDE.md index 6ccb760..4a16a3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,13 @@ Project-specific rules for Claude when working in this repo. +## No `Co-Authored-By` trailers on commits + +Don't add `Co-Authored-By: Claude ...` (or any other co-author +trailer) to commit messages in this repo. The README will make it +plain that the project is heavily spec-coded, so per-commit +attribution is just noise. + ## After building a new binary, also stage it for the smoke env The smoke / dev environment runs the server out of `bin/` directly, diff --git a/cmd/agent/main.go b/cmd/agent/main.go index d4dcbf4..cb38457 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -260,12 +260,22 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc switch p.Kind { case api.JobBackup: - // Agent.Args carries [paths...]. Excludes/tags are not yet - // surfaced over the wire; they come with P2 schedule support. + // Includes/Excludes/Tag come from the source group resolved + // server-side. Args is preserved for backwards compatibility: + // if the server sends only Args (older shape) we fall back to + // treating it as the paths list with no tag. + paths := p.Includes + if len(paths) == 0 { + paths = p.Args + } + var tags []string + if p.Tag != "" { + tags = []string{p.Tag} + } slog.Info("agent: accepting backup job", - "job_id", p.JobID, "paths", p.Args) + "job_id", p.JobID, "paths", paths, "excludes", p.Excludes, "tag", p.Tag) go func() { - if err := r.RunBackup(ctx, p.JobID, p.Args, nil, nil); err != nil { + if err := r.RunBackup(ctx, p.JobID, paths, p.Excludes, tags); err != nil { slog.Warn("agent: backup job failed", "job_id", p.JobID, "err", err) return } diff --git a/internal/agent/scheduler/scheduler.go b/internal/agent/scheduler/scheduler.go index 874d882..c3ede1f 100644 --- a/internal/agent/scheduler/scheduler.go +++ b/internal/agent/scheduler/scheduler.go @@ -88,13 +88,6 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) { if !sch.Enabled { continue } - // Manual schedules carry paths/retention/etc. but have no - // cron — they only fire via operator-driven run-now (which - // the server resolves directly via dispatchScheduledJob). - // Skip without warning: they're a normal data shape. - if sch.Manual { - continue - } // Capture by value so the closure doesn't share id across iters. entry := sch _, err := c.AddFunc(entry.CronExpr, func() { diff --git a/internal/agent/scheduler/scheduler_test.go b/internal/agent/scheduler/scheduler_test.go index dfe4349..9e64300 100644 --- a/internal/agent/scheduler/scheduler_test.go +++ b/internal/agent/scheduler/scheduler_test.go @@ -39,7 +39,7 @@ func TestApplyEmitsAck(t *testing.T) { s.Apply(api.ScheduleSetPayload{ Version: 7, Schedules: []api.Schedule{ - {ID: "s1", Kind: api.JobBackup, CronExpr: "@hourly", Enabled: true}, + {ID: "s1", CronExpr: "@hourly", Enabled: true}, }, }, tx) @@ -72,7 +72,7 @@ func TestApplyTickFiresScheduleFire(t *testing.T) { s.Apply(api.ScheduleSetPayload{ Version: 1, Schedules: []api.Schedule{ - {ID: "every-second", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true}, + {ID: "every-second", CronExpr: "@every 1s", Enabled: true}, }, }, tx) @@ -102,7 +102,7 @@ func TestApplyDisabledEntriesSkipped(t *testing.T) { s.Apply(api.ScheduleSetPayload{ Version: 1, Schedules: []api.Schedule{ - {ID: "off", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: false}, + {ID: "off", CronExpr: "@every 1s", Enabled: false}, }, }, tx) @@ -125,7 +125,7 @@ func TestApplyReplacesPriorState(t *testing.T) { s.Apply(api.ScheduleSetPayload{ Version: 1, Schedules: []api.Schedule{ - {ID: "old", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true}, + {ID: "old", CronExpr: "@every 1s", Enabled: true}, }, }, tx) diff --git a/internal/api/messages.go b/internal/api/messages.go index c39c747..ad98a7e 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -66,13 +66,25 @@ const ( ) // CommandRunPayload is the server → agent dispatch for a run-now job. -// RetentionPolicy is populated for kind=forget jobs (raw JSON so the -// agent doesn't need to share the typed struct definition with the -// server's store package). +// +// For kind=backup, Includes/Excludes/Tag are populated from the source +// group the operator (or schedule) targeted; the agent runs one restic +// backup invocation per command.run, tagging the snapshot with Tag (= +// the source group's name) so retention can target it later via +// `restic forget --tag`. +// +// For kind=forget, RetentionPolicy is the typed keep-* set as raw JSON +// (the agent doesn't share the store package's typed struct). +// +// Args is preserved as a generic free-form slice for kinds that don't +// fit the structured fields (e.g. unlock takes none; init takes none). type CommandRunPayload struct { JobID string `json:"job_id"` Kind JobKind `json:"kind"` Args []string `json:"args,omitempty"` + Includes []string `json:"includes,omitempty"` + Excludes []string `json:"excludes,omitempty"` + Tag string `json:"tag,omitempty"` RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"` } @@ -171,30 +183,36 @@ type RepoStatsPayload struct { LockState string `json:"lock_state"` // locked|unlocked } -// Schedule is the agent-facing view of a Schedule row. (Server-side -// CRUD shapes live in the http handlers; this is what gets pushed.) +// Schedule is the agent-facing view of a slim Schedule row plus its +// resolved bundle of source groups. The agent's cron only needs to know +// when to fire (CronExpr + Enabled) and which schedule fired (ID); the +// SourceGroups are carried for forensic logs and so a future agent that +// elects to dispatch jobs locally has the data, but the server-side +// dispatch path uses the schedule's group list directly. Manual +// schedules are gone — Run-now targets a source group, not a schedule. type Schedule struct { - ID string `json:"id"` - Kind JobKind `json:"kind"` - CronExpr string `json:"cron_expr"` - Paths []string `json:"paths,omitempty"` - Excludes []string `json:"excludes,omitempty"` - Tags []string `json:"tags,omitempty"` - RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"` - Options json.RawMessage `json:"options,omitempty"` - PreHook string `json:"pre_hook,omitempty"` - PostHook string `json:"post_hook,omitempty"` - Enabled bool `json:"enabled"` - // Manual schedules are not added to the agent's local cron — they - // fire only when the operator clicks a Run-now button. The agent - // can ignore them entirely; we ship them in the payload only so - // the operator can edit them on a still-disconnected agent. - Manual bool `json:"manual,omitempty"` + ID string `json:"id"` + CronExpr string `json:"cron_expr"` + Enabled bool `json:"enabled"` + SourceGroups []ScheduleSourceGroup `json:"source_groups,omitempty"` +} + +// ScheduleSourceGroup is the resolved-at-push-time view of a source +// group attached to a schedule. The agent doesn't need source_group_id +// — Name is the snapshot tag and is unique per host. +type ScheduleSourceGroup struct { + Name string `json:"name"` + Includes []string `json:"includes,omitempty"` + Excludes []string `json:"excludes,omitempty"` + RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"` + RetryMax int `json:"retry_max,omitempty"` + RetryBackoffSeconds int `json:"retry_backoff_seconds,omitempty"` } // ScheduleSetPayload — server pushes the full canonical schedule list // for a host. Agent reconciles its local cron and replies with -// ScheduleAckPayload carrying the same Version. +// ScheduleAckPayload carrying the same Version. An empty Schedules +// list is a valid push that disables every cron entry. type ScheduleSetPayload struct { Version int64 `json:"version"` Schedules []Schedule `json:"schedules"` diff --git a/internal/server/http/host_credentials.go b/internal/server/http/host_credentials.go index 2d99a1e..2c564c0 100644 --- a/internal/server/http/host_credentials.go +++ b/internal/server/http/host_credentials.go @@ -199,6 +199,67 @@ func (s *Server) onAgentHello(ctx context.Context, hostID string, conn *ws.Conn) // drop any cron entries left over from a previous deployment. // Always runs, even when the host has no repo credentials yet. s.pushScheduleSetOnConn(ctx, hostID, conn) + // Auto-init the repo if we've never landed a successful init job + // against this host. Restic treats "config file already exists" + // as a soft success, so re-enrolment against a populated repo + // just no-ops. Skipped silently when the host has no creds yet — + // the next hello after the operator binds creds will dispatch. + s.maybeAutoInit(ctx, hostID, conn) +} + +// maybeAutoInit dispatches a `restic init` job iff the host has no +// successful init in its history AND repo creds are bound (without +// them the runner can't talk to the repo). We rely on Restic's +// idempotent init for re-runs. +func (s *Server) maybeAutoInit(ctx context.Context, hostID string, conn *ws.Conn) { + if _, err := s.deps.Store.GetHostCredentials(ctx, hostID); err != nil { + // No creds bound yet — operator hasn't supplied them. The next + // hello after creds land will pick this up. + return + } + already, err := s.deps.Store.HasJobOfKind(ctx, hostID, string(api.JobInit)) + if err != nil { + slog.Warn("auto-init: check job history", "host_id", hostID, "err", err) + return + } + if already { + return + } + jobID := ulid.Make().String() + now := time.Now().UTC() + if err := s.deps.Store.CreateJob(ctx, store.Job{ + ID: jobID, + HostID: hostID, + Kind: string(api.JobInit), + ActorKind: "system", + CreatedAt: now, + }); err != nil { + slog.Warn("auto-init: persist job", "host_id", hostID, "err", err) + return + } + env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{ + JobID: jobID, + Kind: api.JobInit, + }) + if err != nil { + slog.Warn("auto-init: marshal command.run", "host_id", hostID, "err", err) + return + } + sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := conn.Send(sendCtx, env); err != nil { + slog.Warn("auto-init: send command.run", "host_id", hostID, "err", err) + return + } + _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ + ID: ulid.Make().String(), + Actor: "system", + Action: "host.auto_init", + TargetKind: ptr("host"), + TargetID: &hostID, + TS: now, + }) + slog.Info("auto-init: dispatched", "host_id", hostID, "job_id", jobID) } // pushRepoCredsOnHello loads + decrypts + sends the host's repo diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index 3bef4d5..7b90d10 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -64,6 +64,19 @@ func (s *Server) handleRunNow(w stdhttp.ResponseWriter, r *stdhttp.Request) { // flash banner + redirect. func (s *Server) dispatchJob(ctx context.Context, user *store.User, hostID string, kind api.JobKind, args []string, +) (res runNowResponse, status int, code, msg string) { + return s.dispatchJobWithPayload(ctx, user, hostID, kind, api.CommandRunPayload{ + Kind: kind, + Args: args, + }) +} + +// dispatchJobWithPayload is dispatchJob's variant that lets callers +// fill in structured fields (Includes/Excludes/Tag/RetentionPolicy) +// — used by the per-source-group Run-now path. JobID is filled in +// here; callers leave it zero on the input payload. +func (s *Server) dispatchJobWithPayload(ctx context.Context, user *store.User, + hostID string, kind api.JobKind, payload api.CommandRunPayload, ) (res runNowResponse, status int, code, msg string) { if !validJobKind(kind) { return res, stdhttp.StatusBadRequest, "invalid_kind", @@ -80,22 +93,26 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User, jobID := ulid.Make().String() now := time.Now().UTC() + var actorID *string + actor := "system" + if user != nil { + actor = "user" + actorID = &user.ID + } if err := s.deps.Store.CreateJob(ctx, store.Job{ ID: jobID, HostID: host.ID, Kind: string(kind), - ActorKind: "user", - ActorID: &user.ID, + ActorKind: actor, + ActorID: actorID, CreatedAt: now, }); err != nil { return res, stdhttp.StatusInternalServerError, "internal", "" } - env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{ - JobID: jobID, - Kind: kind, - Args: args, - }) + payload.JobID = jobID + payload.Kind = kind + env, err := api.Marshal(api.MsgCommandRun, jobID, payload) if err != nil { return res, stdhttp.StatusInternalServerError, "internal", "" } @@ -105,8 +122,8 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User, _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ ID: ulid.Make().String(), - UserID: &user.ID, - Actor: "user", + UserID: actorID, + Actor: actor, Action: "job.run_now", TargetKind: ptr("job"), TargetID: &jobID, diff --git a/internal/server/http/p2r01_test.go b/internal/server/http/p2r01_test.go new file mode 100644 index 0000000..01bca60 --- /dev/null +++ b/internal/server/http/p2r01_test.go @@ -0,0 +1,482 @@ +// p2r01_test.go — HTTP-level coverage for the slim-shape REST surface +// landed in P2R-01: schedules, source-groups, repo-maintenance, the +// per-source-group Run-now endpoint, schedule_push reconciliation, +// and auto-init at hello. +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + stdhttp "net/http" + "strings" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// loginAsAdmin creates an admin user + a session in the store and +// returns a cookie ready to attach to outgoing requests. +func loginAsAdmin(t *testing.T, st *store.Store) *stdhttp.Cookie { + t.Helper() + ctx := context.Background() + uid := ulid.Make().String() + hash, _ := auth.HashPassword("very-long-test-password") + if err := st.CreateUser(ctx, store.User{ + ID: uid, Username: "tester-" + uid[:6], + PasswordHash: hash, Role: store.RoleAdmin, + CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create user: %v", err) + } + tok, _ := auth.NewToken() + if err := st.CreateSession(ctx, store.Session{ + UserID: uid, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(time.Hour).UTC(), + }, auth.HashToken(tok)); err != nil { + t.Fatalf("create session: %v", err) + } + return &stdhttp.Cookie{Name: sessionCookieName, Value: tok} +} + +// makeHost inserts a minimal Host row directly via the store. Used by +// HTTP-level tests that don't want to go through the full enrollment +// path. Returns the host id. +func makeHost(t *testing.T, st *store.Store, name string) string { + t.Helper() + id := ulid.Make().String() + if err := st.CreateHost(context.Background(), store.Host{ + ID: id, Name: name, OS: "linux", Arch: "amd64", + ProtocolVersion: api.CurrentProtocolVersion, + EnrolledAt: time.Now().UTC(), + }, "tokhash-"+id, ""); err != nil { + t.Fatalf("create host: %v", err) + } + return id +} + +// doJSON issues a JSON request with the given method and body, returns +// status + decoded JSON map (nil on empty body). +func doJSON(t *testing.T, baseURL, method, path string, body any, cookie *stdhttp.Cookie) (int, map[string]any) { + t.Helper() + var rdr io.Reader + if body != nil { + raw, _ := json.Marshal(body) + rdr = bytes.NewReader(raw) + } + req, err := stdhttp.NewRequest(method, baseURL+path, rdr) + if err != nil { + t.Fatalf("new req: %v", err) + } + if rdr != nil { + req.Header.Set("Content-Type", "application/json") + } + if cookie != nil { + req.AddCookie(cookie) + } + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + if len(raw) == 0 { + return res.StatusCode, nil + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + // Non-JSON (HTMX action paths return plain text on error). + return res.StatusCode, map[string]any{"raw": string(raw)} + } + return res.StatusCode, out +} + +// ----- source-groups ------------------------------------------------ + +func TestSourceGroupsCRUD(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "sg-host") + + // Empty list at start. + status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/source-groups", nil, cookie) + if status != 200 { + t.Fatalf("list status: %d", status) + } + if got := body["source_groups"].([]any); len(got) != 0 { + t.Fatalf("expected empty list, got %d", len(got)) + } + + // Create. + status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups", + map[string]any{ + "name": "etc", + "includes": []string{"/etc"}, + "excludes": []string{"/etc/shadow"}, + "retention_policy": map[string]int{ + "keep_daily": 7, + }, + "retry_max": 3, + "retry_backoff_seconds": 60, + }, cookie) + if status != 201 { + t.Fatalf("create status: %d, body: %+v", status, body) + } + gid, _ := body["id"].(string) + if gid == "" { + t.Fatalf("create: no id returned: %+v", body) + } + + // Duplicate name → 409. + status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups", + map[string]any{"name": "etc", "includes": []string{"/x"}}, cookie) + if status != 409 { + t.Errorf("duplicate name: want 409, got %d", status) + } + + // Update — rename + add another include. + status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid, + map[string]any{ + "name": "system", + "includes": []string{"/etc", "/var/log"}, + "retention_policy": map[string]int{ + "keep_daily": 14, + "keep_weekly": 4, + }, + }, cookie) + if status != 200 { + t.Fatalf("update status: %d, body: %+v", status, body) + } + if got := body["name"]; got != "system" { + t.Errorf("rename: got %v want system", got) + } + + // Delete. + status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie) + if status != 204 { + t.Errorf("delete status: %d", status) + } + + // Already gone. + status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie) + if status != 404 { + t.Errorf("delete-after-delete: want 404, got %d", status) + } +} + +func TestSourceGroupDeleteRefusesIfInUse(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "sg-inuse-host") + + // Create a group via the store directly. + gid := ulid.Make().String() + if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{ + ID: gid, HostID: hostID, Name: "default", Includes: []string{"/home"}, + }); err != nil { + t.Fatalf("create group: %v", err) + } + // Attach a schedule. + sid := ulid.Make().String() + if err := st.CreateSchedule(context.Background(), &store.Schedule{ + ID: sid, HostID: hostID, + CronExpr: "0 3 * * *", Enabled: true, + SourceGroupIDs: []string{gid}, + }); err != nil { + t.Fatalf("create schedule: %v", err) + } + + status, body := doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie) + if status != 409 { + t.Fatalf("want 409, got %d body=%+v", status, body) + } + if body["code"] != "group_in_use" { + t.Errorf("wrong code: %+v", body) + } +} + +// ----- schedules ---------------------------------------------------- + +func TestSchedulesCRUDValidation(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "sched-host") + + // Bad cron → 400. + status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", + map[string]any{"cron": "not-a-cron", "enabled": true, + "source_group_ids": []string{"x"}}, cookie) + if status != 400 { + t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body) + } + + // Missing groups → 400. + status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", + map[string]any{"cron": "0 3 * * *", "enabled": true, + "source_group_ids": []string{}}, cookie) + if status != 400 { + t.Errorf("missing groups: want 400, got %d", status) + } + + // Group not on host → 400. + status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", + map[string]any{"cron": "0 3 * * *", "enabled": true, + "source_group_ids": []string{"non-existent"}}, cookie) + if status != 400 { + t.Errorf("bogus group: want 400, got %d", status) + } + + // Create a real group. + gid := ulid.Make().String() + if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{ + ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"}, + }); err != nil { + t.Fatalf("group: %v", err) + } + + // Happy create. + status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", + map[string]any{"cron": "0 3 * * *", "enabled": true, + "source_group_ids": []string{gid}}, cookie) + if status != 201 { + t.Fatalf("create: %d body=%+v", status, body) + } + sid, _ := body["id"].(string) + if sid == "" { + t.Fatalf("no id: %+v", body) + } + + // List. + status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/schedules", nil, cookie) + if status != 200 { + t.Fatalf("list: %d", status) + } + rows, _ := body["schedules"].([]any) + if len(rows) != 1 { + t.Fatalf("expected 1 schedule, got %d", len(rows)) + } + + // Update — change cron, keep group. + status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid, + map[string]any{"cron": "@hourly", "enabled": false, + "source_group_ids": []string{gid}}, cookie) + if status != 200 { + t.Fatalf("update: %d body=%+v", status, body) + } + if body["cron"] != "@hourly" || body["enabled"] != false { + t.Errorf("update fields: %+v", body) + } + + // Delete. + status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/schedules/"+sid, nil, cookie) + if status != 204 { + t.Errorf("delete: %d", status) + } +} + +// ----- repo-maintenance -------------------------------------------- + +func TestRepoMaintenanceGetSeedsAndPutValidates(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "maint-host") + + // GET on a host that hasn't had the row seeded auto-creates one. + status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/repo-maintenance", nil, cookie) + if status != 200 { + t.Fatalf("get: %d body=%+v", status, body) + } + if body["host_id"] != hostID { + t.Errorf("host_id mismatch: %+v", body) + } + + // PUT with bad cron. + status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance", + map[string]any{ + "forget_cron": "junk", "prune_cron": "@weekly", + "check_cron": "@monthly", "check_subset_pct": 10, + }, cookie) + if status != 400 { + t.Errorf("bad cron: want 400, got %d", status) + } + + // PUT with subset out of range. + status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance", + map[string]any{ + "forget_cron": "@daily", "prune_cron": "@weekly", + "check_cron": "@monthly", "check_subset_pct": 200, + }, cookie) + if status != 400 { + t.Errorf("bad subset: want 400, got %d", status) + } + + // Happy PUT. + status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance", + map[string]any{ + "forget_cron": "@daily", + "forget_enabled": true, + "prune_cron": "@weekly", + "prune_enabled": true, + "check_cron": "@monthly", + "check_enabled": false, + "check_subset_pct": 25, + }, cookie) + if status != 200 { + t.Fatalf("happy put: %d body=%+v", status, body) + } + if body["forget_cron"] != "@daily" || body["check_subset_pct"] != float64(25) { + t.Errorf("fields: %+v", body) + } +} + +// ----- 410 Gone on retired routes ---------------------------------- + +func TestPerHostRunBackupReturns410(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "gone-host") + + req, _ := stdhttp.NewRequest("POST", url+"/hosts/"+hostID+"/run-backup", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusGone { + t.Errorf("want 410, got %d", res.StatusCode) + } +} + +// ----- schedule_push payload --------------------------------------- + +func TestBuildScheduleSetPayload(t *testing.T) { + t.Parallel() + srv, _, st := newTestServerWithHub(t) + hostID := makeHost(t, st, "push-host") + + gid := ulid.Make().String() + keepDaily := 7 + if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{ + ID: gid, HostID: hostID, Name: "default", + Includes: []string{"/etc", "/home"}, + Excludes: []string{"/etc/shadow"}, + RetentionPolicy: store.RetentionPolicy{KeepDaily: &keepDaily}, + RetryMax: 2, RetryBackoffSeconds: 30, + }); err != nil { + t.Fatalf("group: %v", err) + } + sid := ulid.Make().String() + if err := st.CreateSchedule(context.Background(), &store.Schedule{ + ID: sid, HostID: hostID, + CronExpr: "0 3 * * *", Enabled: true, + SourceGroupIDs: []string{gid}, + }); err != nil { + t.Fatalf("schedule: %v", err) + } + + payload, err := srv.buildScheduleSetPayload(context.Background(), hostID) + if err != nil { + t.Fatalf("build: %v", err) + } + if payload.Version == 0 { + t.Fatalf("version should be > 0, got %d", payload.Version) + } + if len(payload.Schedules) != 1 { + t.Fatalf("schedules: %d", len(payload.Schedules)) + } + entry := payload.Schedules[0] + if entry.ID != sid || entry.CronExpr != "0 3 * * *" || !entry.Enabled { + t.Errorf("schedule fields: %+v", entry) + } + if len(entry.SourceGroups) != 1 { + t.Fatalf("groups in schedule: %d", len(entry.SourceGroups)) + } + g := entry.SourceGroups[0] + if g.Name != "default" { + t.Errorf("group name: %s", g.Name) + } + if !equalStrings(g.Includes, []string{"/etc", "/home"}) { + t.Errorf("includes: %v", g.Includes) + } + var rp map[string]any + _ = json.Unmarshal(g.RetentionPolicy, &rp) + if rp["keep_daily"] != float64(7) { + t.Errorf("retention: %+v", rp) + } +} + +// ----- per-source-group Run-now ----------------------------------- + +func TestRunSourceGroupOfflineHost(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "offline-host") + + gid := ulid.Make().String() + if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{ + ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"}, + }); err != nil { + t.Fatalf("group: %v", err) + } + + // JSON path → 503 (host offline). + req, _ := stdhttp.NewRequest("POST", + url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil) + req.AddCookie(cookie) + req.Header.Set("Accept", "application/json") + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusServiceUnavailable { + t.Errorf("offline: want 503, got %d", res.StatusCode) + } +} + +func TestRunSourceGroupUnknownGroup(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "noh-host") + + req, _ := stdhttp.NewRequest("POST", + url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil) + req.AddCookie(cookie) + req.Header.Set("Accept", "application/json") + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusNotFound { + t.Errorf("unknown group: want 404, got %d", res.StatusCode) + } +} + +// ----- helpers ---------------------------------------------------- + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// keep fmt import live — used for occasional debug. +var _ = fmt.Sprintf +var _ = strings.HasPrefix diff --git a/internal/server/http/repo_maintenance.go b/internal/server/http/repo_maintenance.go new file mode 100644 index 0000000..e020b30 --- /dev/null +++ b/internal/server/http/repo_maintenance.go @@ -0,0 +1,145 @@ +// repo_maintenance.go — REST API for /api/hosts/{id}/repo-maintenance. +// +// Cadence rows for the three repo-wide verbs (forget / prune / check). +// Edits do NOT bump host_schedule_version: the server-side maintenance +// ticker drives execution (P2R-06), not the agent's local cron. +package http + +import ( + "encoding/json" + "errors" + stdhttp "net/http" + + "github.com/go-chi/chi/v5" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type repoMaintenanceView struct { + HostID string `json:"host_id"` + ForgetCron string `json:"forget_cron"` + ForgetEnabled bool `json:"forget_enabled"` + PruneCron string `json:"prune_cron"` + PruneEnabled bool `json:"prune_enabled"` + CheckCron string `json:"check_cron"` + CheckEnabled bool `json:"check_enabled"` + CheckSubsetPct int `json:"check_subset_pct"` +} + +func toRepoMaintenanceView(m store.HostRepoMaintenance) repoMaintenanceView { + return repoMaintenanceView{ + HostID: m.HostID, + ForgetCron: m.ForgetCron, + ForgetEnabled: m.ForgetEnabled, + PruneCron: m.PruneCron, + PruneEnabled: m.PruneEnabled, + CheckCron: m.CheckCron, + CheckEnabled: m.CheckEnabled, + CheckSubsetPct: m.CheckSubsetPct, + } +} + +func (s *Server) handleGetRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + m, err := s.deps.Store.GetRepoMaintenance(r.Context(), hostID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // Self-heal: seed and return the defaults so the UI never + // has to handle a 404 here. Hosts enrolled before the + // migration may legitimately be missing the row. + if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); seedErr != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + m, err = s.deps.Store.GetRepoMaintenance(r.Context(), hostID) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + } else { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + } + writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(*m)) +} + +type repoMaintenanceWriteRequest struct { + ForgetCron string `json:"forget_cron"` + ForgetEnabled bool `json:"forget_enabled"` + PruneCron string `json:"prune_cron"` + PruneEnabled bool `json:"prune_enabled"` + CheckCron string `json:"check_cron"` + CheckEnabled bool `json:"check_enabled"` + CheckSubsetPct int `json:"check_subset_pct"` +} + +func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + var req repoMaintenanceWriteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + for label, expr := range map[string]string{ + "forget_cron": req.ForgetCron, + "prune_cron": req.PruneCron, + "check_cron": req.CheckCron, + } { + if expr == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", label+" required") + return + } + if _, err := cronParser.Parse(expr); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_cron", label+": "+err.Error()) + return + } + } + if req.CheckSubsetPct < 0 || req.CheckSubsetPct > 100 { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value", + "check_subset_pct must be 0..100") + return + } + // Ensure the row exists (older hosts may pre-date the auto-seed). + if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + m := store.HostRepoMaintenance{ + HostID: hostID, + ForgetCron: req.ForgetCron, + ForgetEnabled: req.ForgetEnabled, + PruneCron: req.PruneCron, + PruneEnabled: req.PruneEnabled, + CheckCron: req.CheckCron, + CheckEnabled: req.CheckEnabled, + CheckSubsetPct: req.CheckSubsetPct, + } + if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + out, _ := s.deps.Store.GetRepoMaintenance(r.Context(), hostID) + if out != nil { + writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(*out)) + return + } + writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m)) +} + diff --git a/internal/server/http/run_group.go b/internal/server/http/run_group.go new file mode 100644 index 0000000..1f16095 --- /dev/null +++ b/internal/server/http/run_group.go @@ -0,0 +1,83 @@ +// run_group.go — per-source-group Run-now endpoint. +// +// POST /hosts/{id}/source-groups/{gid}/run dispatches a backup job +// against the resolved includes/excludes/retention/tag of the named +// group. Replaces the old per-host /hosts/{id}/run-backup route (now +// 410 Gone). +package http + +import ( + "encoding/json" + "errors" + stdhttp "net/http" + + "github.com/go-chi/chi/v5" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + // HTML callers redirect to login; for JSON return 401. + if wantsHTML(r) { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + groupID := chi.URLParam(r, "gid") + g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.runGroupError(w, r, stdhttp.StatusNotFound, "group_not_found", + "source group not found on this host") + return + } + s.runGroupError(w, r, stdhttp.StatusInternalServerError, "internal", "") + return + } + + retention, _ := json.Marshal(g.RetentionPolicy) + res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobBackup, + api.CommandRunPayload{ + Includes: g.Includes, + Excludes: g.Excludes, + Tag: g.Name, + RetentionPolicy: retention, + }) + if code != "" { + s.runGroupError(w, r, status, code, msg) + return + } + if wantsHTML(r) { + // HTMX action: redirect to the live job log so the operator + // sees streaming output immediately. + w.Header().Set("HX-Redirect", "/jobs/"+res.JobID) + w.WriteHeader(stdhttp.StatusNoContent) + return + } + writeJSON(w, stdhttp.StatusAccepted, res) +} + +// runGroupError dispatches an error to JSON callers as the standard +// envelope; HTMX callers get a 4xx with a plain text body so the +// browser surfaces it via the existing toast handler. +func (s *Server) runGroupError(w stdhttp.ResponseWriter, r *stdhttp.Request, status int, code, msg string) { + if wantsHTML(r) { + stdhttp.Error(w, msg, status) + return + } + writeJSONError(w, status, code, msg) +} + +// wantsHTML keys off HX-Request only. Browsers sending a default +// Accept (or curl's `*/*`) get the JSON shape, which is the safer +// default for non-htmx clients. HTMX always sets HX-Request=true on +// its action POSTs, so the form path is unambiguous. +func wantsHTML(r *stdhttp.Request) bool { + return r.Header.Get("HX-Request") == "true" +} diff --git a/internal/server/http/schedule_push.go b/internal/server/http/schedule_push.go index ff3ae8d..7196255 100644 --- a/internal/server/http/schedule_push.go +++ b/internal/server/http/schedule_push.go @@ -1,37 +1,218 @@ +// schedule_push.go — server → agent reconciliation push and the +// inbound schedule.fire dispatch. +// +// The slim-schedule wire shape is built here from the (Schedule, +// SourceGroup) pair. Each schedule is sent with its resolved source +// groups inlined so the agent doesn't have to keep its own copy of +// the group catalogue. Cron + enabled drive the agent's local timer; +// when an entry fires the agent ships back a schedule.fire and +// dispatchScheduledJob below resolves the schedule's groups and +// dispatches one backup command.run per group. package http import ( "context" + "encoding/json" + "errors" "log/slog" "time" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) -// schedule_push.go — server → agent reconciliation push. -// -// Stubbed during the P2 redesign rewrite. The new wire shape (slim -// schedules referencing source groups; groups inline by id at push -// time) lands in Phase 3. Until then on-hello and post-CRUD pushes -// are no-ops; the agent will keep its existing cron entries (none, -// since there are no schedules yet) and the only operator-driven -// jobs flow via run-now once the new UI is wired in Phase 4. - +// pushScheduleSetOnConn ships the canonical schedule set straight down +// the freshly-accepted hello connection. Callers are inside the +// hello window — using the conn directly avoids racing the hub's +// register-then-supersede sequence. func (s *Server) pushScheduleSetOnConn(ctx context.Context, hostID string, conn *ws.Conn) { - slog.Debug("schedule push: stubbed during P2 redesign", "host_id", hostID) + payload, err := s.buildScheduleSetPayload(ctx, hostID) + if err != nil { + slog.Warn("schedule push: build payload", "host_id", hostID, "err", err) + return + } + env, err := api.Marshal(api.MsgScheduleSet, "", payload) + if err != nil { + slog.Warn("schedule push: marshal payload", "host_id", hostID, "err", err) + return + } + sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := conn.Send(sendCtx, env); err != nil { + slog.Warn("schedule push on-hello: send", "host_id", hostID, "err", err) + } } +// pushScheduleSetAsync pushes the latest schedule set to a connected +// agent (via the hub) on a best-effort basis. Mutations call this +// after a successful CRUD; offline agents pick the new version up on +// next reconnect via pushScheduleSetOnConn. func (s *Server) pushScheduleSetAsync(hostID string) { - slog.Debug("schedule push async: stubbed during P2 redesign", "host_id", hostID) + if s.deps.Hub == nil || !s.deps.Hub.Connected(hostID) { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + payload, err := s.buildScheduleSetPayload(ctx, hostID) + if err != nil { + slog.Warn("schedule push async: build payload", "host_id", hostID, "err", err) + return + } + env, err := api.Marshal(api.MsgScheduleSet, "", payload) + if err != nil { + slog.Warn("schedule push async: marshal", "host_id", hostID, "err", err) + return + } + if err := s.deps.Hub.Send(ctx, hostID, env); err != nil { + slog.Debug("schedule push async: send", "host_id", hostID, "err", err) + } + }() } +// buildScheduleSetPayload assembles the canonical wire shape: every +// schedule for the host with its source groups resolved inline. +func (s *Server) buildScheduleSetPayload(ctx context.Context, hostID string) (api.ScheduleSetPayload, error) { + version, err := s.deps.Store.GetHostScheduleVersion(ctx, hostID) + if err != nil { + return api.ScheduleSetPayload{}, err + } + schedules, err := s.deps.Store.ListSchedulesByHost(ctx, hostID) + if err != nil { + return api.ScheduleSetPayload{}, err + } + groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID) + if err != nil { + return api.ScheduleSetPayload{}, err + } + groupByID := make(map[string]store.SourceGroup, len(groups)) + for _, g := range groups { + groupByID[g.ID] = g + } + + out := api.ScheduleSetPayload{Version: version, Schedules: make([]api.Schedule, 0, len(schedules))} + for _, sc := range schedules { + entry := api.Schedule{ + ID: sc.ID, + CronExpr: sc.CronExpr, + Enabled: sc.Enabled, + SourceGroups: make([]api.ScheduleSourceGroup, 0, len(sc.SourceGroupIDs)), + } + for _, gid := range sc.SourceGroupIDs { + g, ok := groupByID[gid] + if !ok { + continue + } + retention, _ := json.Marshal(g.RetentionPolicy) + entry.SourceGroups = append(entry.SourceGroups, api.ScheduleSourceGroup{ + Name: g.Name, + Includes: g.Includes, + Excludes: g.Excludes, + RetentionPolicy: retention, + RetryMax: g.RetryMax, + RetryBackoffSeconds: g.RetryBackoffSeconds, + }) + } + out.Schedules = append(out.Schedules, entry) + } + return out, nil +} + +// applyScheduleAck persists the version the agent has confirmed. func (s *Server) applyScheduleAck(ctx context.Context, hostID string, version int64, appliedAt time.Time) { if err := s.deps.Store.SetHostAppliedScheduleVersion(ctx, hostID, version); err != nil { slog.Warn("schedule.ack: persist applied version", "host_id", hostID, "err", err) } } +// dispatchScheduledJob handles an agent's schedule.fire. Resolves the +// schedule's source groups and dispatches one backup command.run per +// group, persisting each as a job row with actor_kind=schedule and +// scheduled_id pointing at the schedule. func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn *ws.Conn, scheduleID string, scheduledAt time.Time) { - slog.Info("schedule.fire: stubbed during P2 redesign", - "host_id", hostID, "schedule_id", scheduleID, "scheduled_at", scheduledAt) + sc, err := s.deps.Store.GetSchedule(ctx, hostID, scheduleID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + slog.Info("schedule.fire: schedule unknown, ignoring", + "host_id", hostID, "schedule_id", scheduleID) + return + } + slog.Warn("schedule.fire: load schedule", "host_id", hostID, "err", err) + return + } + if !sc.Enabled { + slog.Info("schedule.fire: schedule disabled, ignoring", + "host_id", hostID, "schedule_id", scheduleID) + return + } + if len(sc.SourceGroupIDs) == 0 { + slog.Warn("schedule.fire: schedule has no source groups", + "host_id", hostID, "schedule_id", scheduleID) + return + } + for _, gid := range sc.SourceGroupIDs { + g, err := s.deps.Store.GetSourceGroup(ctx, hostID, gid) + if err != nil { + slog.Warn("schedule.fire: load source group", + "host_id", hostID, "schedule_id", scheduleID, "group_id", gid, "err", err) + continue + } + s.dispatchBackupForGroup(ctx, conn, hostID, scheduleID, g, scheduledAt) + } +} + +// dispatchBackupForGroup builds and sends a single backup command.run +// envelope on conn for the given group. Persists the job row first so +// the live log viewer can subscribe to it. +func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) { + jobID := ulid.Make().String() + now := time.Now().UTC() + scheduleRef := scheduleID + if err := s.deps.Store.CreateJob(ctx, store.Job{ + ID: jobID, + HostID: hostID, + Kind: string(api.JobBackup), + ScheduledID: &scheduleRef, + ActorKind: "schedule", + CreatedAt: now, + }); err != nil { + slog.Warn("schedule.fire: persist job", "host_id", hostID, + "schedule_id", scheduleID, "group", g.Name, "err", err) + return + } + retention, _ := json.Marshal(g.RetentionPolicy) + env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{ + JobID: jobID, + Kind: api.JobBackup, + Includes: g.Includes, + Excludes: g.Excludes, + Tag: g.Name, + RetentionPolicy: retention, + }) + if err != nil { + slog.Warn("schedule.fire: marshal command.run", + "host_id", hostID, "schedule_id", scheduleID, "err", err) + return + } + sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := conn.Send(sendCtx, env); err != nil { + slog.Warn("schedule.fire: send command.run", + "host_id", hostID, "schedule_id", scheduleID, "err", err) + return + } + _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ + ID: ulid.Make().String(), + Actor: "schedule", + Action: "job.run_now", + TargetKind: ptr("job"), + TargetID: &jobID, + TS: now, + }) + slog.Info("schedule.fire: dispatched backup", + "host_id", hostID, "schedule_id", scheduleID, + "group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt) } diff --git a/internal/server/http/schedules.go b/internal/server/http/schedules.go index 6383a03..95c4e58 100644 --- a/internal/server/http/schedules.go +++ b/internal/server/http/schedules.go @@ -1,37 +1,219 @@ +// schedules.go — REST API for /api/hosts/{id}/schedules. +// +// Slim-shape body: {cron, enabled, source_group_ids[]}. Paths, +// excludes, retention, retry, kind, manual — all gone. Those live on +// SourceGroup; a schedule is just "fire this cron, run backups for +// these groups." Mutations bump host_schedule_version and (best-effort) +// push the new set to a connected agent. package http import ( + "encoding/json" + "errors" stdhttp "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + "github.com/robfig/cron/v3" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) -// schedules.go — REST API for /api/hosts/{id}/schedules. -// -// Stubbed during the P2 redesign data-model rewrite (commit chain). -// Phase 2 dropped the fat Schedule shape (paths/excludes/tags/ -// retention/manual/kind/options/hooks) — the slim Schedule + source -// groups model lives in store/. Phase 3 of the redesign will fill in -// these handlers against the new shape. -// -// Returning 501 here keeps the routes addressable; UI calls will -// surface the unimplemented state via the toast component until the -// new handlers land. +// scheduleView is the JSON shape returned by GET. Stable wire format +// — UI form binds to it. +type scheduleView struct { + ID string `json:"id"` + HostID string `json:"host_id"` + CronExpr string `json:"cron"` + Enabled bool `json:"enabled"` + SourceGroupIDs []string `json:"source_group_ids"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toScheduleView(s store.Schedule) scheduleView { + ids := s.SourceGroupIDs + if ids == nil { + ids = []string{} + } + return scheduleView{ + ID: s.ID, HostID: s.HostID, + CronExpr: s.CronExpr, Enabled: s.Enabled, + SourceGroupIDs: ids, + CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, + } +} + +// scheduleWriteRequest is the body of POST and PUT. +type scheduleWriteRequest struct { + CronExpr string `json:"cron"` + Enabled bool `json:"enabled"` + SourceGroupIDs []string `json:"source_group_ids"` +} + +// cronParser mirrors robfig/cron/v3's New() default; reuse it across +// every validate call so we're consistent with what the agent uses. +var cronParser = cron.NewParser( + cron.SecondOptional | cron.Minute | cron.Hour | + cron.Dom | cron.Month | cron.Dow | cron.Descriptor, +) func (s *Server) handleListSchedules(w stdhttp.ResponseWriter, r *stdhttp.Request) { - writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress", - "schedule REST API is being rebuilt — see P2 redesign Phase 3") + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + out := make([]scheduleView, 0, len(rows)) + for _, sc := range rows { + out = append(out, toScheduleView(sc)) + } + writeJSON(w, stdhttp.StatusOK, struct { + Schedules []scheduleView `json:"schedules"` + }{Schedules: out}) } func (s *Server) handleCreateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { - writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress", - "schedule REST API is being rebuilt — see P2 redesign Phase 3") + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + var req scheduleWriteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if code, msg, ok := s.validateScheduleRequest(r, hostID, req); !ok { + writeJSONError(w, stdhttp.StatusBadRequest, code, msg) + return + } + + sc := store.Schedule{ + ID: ulid.Make().String(), HostID: hostID, + CronExpr: req.CronExpr, Enabled: req.Enabled, + SourceGroupIDs: req.SourceGroupIDs, + } + if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + s.pushScheduleSetAsync(hostID) + writeJSON(w, stdhttp.StatusCreated, toScheduleView(sc)) } func (s *Server) handleUpdateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { - writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress", - "schedule REST API is being rebuilt — see P2 redesign Phase 3") + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + scheduleID := chi.URLParam(r, "sid") + if hostID == "" || scheduleID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + if _, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "schedule_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + var req scheduleWriteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if code, msg, ok := s.validateScheduleRequest(r, hostID, req); !ok { + writeJSONError(w, stdhttp.StatusBadRequest, code, msg) + return + } + + sc := store.Schedule{ + ID: scheduleID, HostID: hostID, + CronExpr: req.CronExpr, Enabled: req.Enabled, + SourceGroupIDs: req.SourceGroupIDs, + } + if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + s.pushScheduleSetAsync(hostID) + out, _ := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID) + if out != nil { + writeJSON(w, stdhttp.StatusOK, toScheduleView(*out)) + return + } + writeJSON(w, stdhttp.StatusOK, toScheduleView(sc)) } func (s *Server) handleDeleteSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { - writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress", - "schedule REST API is being rebuilt — see P2 redesign Phase 3") + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + scheduleID := chi.URLParam(r, "sid") + if hostID == "" || scheduleID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + if err := s.deps.Store.DeleteSchedule(r.Context(), hostID, scheduleID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "schedule_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + s.pushScheduleSetAsync(hostID) + w.WriteHeader(stdhttp.StatusNoContent) +} + +// validateScheduleRequest enforces wire-shape rules: cron must parse, +// at least one source group must be attached, and every referenced +// group must belong to this host. Returns (code, msg, ok=false) on +// failure; ok=true means proceed. +func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req scheduleWriteRequest) (string, string, bool) { + if req.CronExpr == "" { + return "missing_field", "cron is required", false + } + if _, err := cronParser.Parse(req.CronExpr); err != nil { + return "invalid_cron", err.Error(), false + } + if len(req.SourceGroupIDs) == 0 { + return "missing_field", "source_group_ids must contain at least one group", false + } + // Every referenced group must exist and belong to this host. + for _, gid := range req.SourceGroupIDs { + g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid) + if err != nil || g == nil { + return "invalid_group", "source group "+gid+" not found on this host", false + } + } + return "", "", true } diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 3cf80a9..49fb3b3 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -105,15 +105,43 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials) r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials) - // Per-host schedule CRUD. Mutations bump host_schedule_version; - // the agent sync path (P2-02) picks up the new version on the - // next reconciliation tick. + // Per-host schedule CRUD. Mutations bump host_schedule_version + // and async-push to a connected agent (see schedule_push.go). r.Get("/hosts/{id}/schedules", s.handleListSchedules) r.Post("/hosts/{id}/schedules", s.handleCreateSchedule) r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule) r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule) + + // Source-group CRUD. A group is "what gets backed up" — paths, + // excludes, retention, retry. Group name doubles as the + // snapshot tag (restic --tag ). + r.Get("/hosts/{id}/source-groups", s.handleListSourceGroups) + r.Post("/hosts/{id}/source-groups", s.handleCreateSourceGroup) + r.Get("/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup) + r.Put("/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup) + r.Delete("/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup) + + // Repo maintenance cadences (forget / prune / check). Driven + // by the server-side ticker (P2R-06), not the agent's cron. + r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) + r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance) + + // Per-source-group Run-now (JSON variant). HTMX action is + // mounted at the equivalent path outside /api below — both + // resolve to the same handler, which sniffs HX-Request. + r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) }) + // Per-source-group Run-now (HTMX form action). Available even + // when the server is started without UI templates so REST callers + // against the non-/api path also work. + r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) + // Retired routes — see ui_handlers.go for the messages. Mounted + // outside the UI gate so cached browser tabs get a clear 410 + // even if the server runs without templates. + r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone) + r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone) + // Agent ↔ server WebSocket. Bearer-authenticated inside the handler. if s.deps.Hub != nil { r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{ @@ -143,11 +171,9 @@ func (s *Server) routes(r chi.Router) { r.Get("/login", s.handleUILoginGet) r.Post("/login", s.handleUILoginPost) r.Post("/logout", s.handleUILogoutPost) - // HTMX action endpoint for "Run now" buttons on the dashboard. - r.Post("/hosts/{id}/run-backup", s.handleUIRunBackup) - // HTMX action endpoint for the red "Initialise repo" button - // shown in the run-now panel until the repo is confirmed init'd. - r.Post("/hosts/{id}/init-repo", s.handleUIInitRepo) + // Per-host Run-now and manual Init-repo are mounted at the + // outer router (so they reply 410 even without UI). Per- + // source-group Run-now lives there too — same reason. // Add host flow. r.Get("/hosts/new", s.handleUIAddHostGet) r.Post("/hosts/new", s.handleUIAddHostPost) diff --git a/internal/server/http/source_groups.go b/internal/server/http/source_groups.go new file mode 100644 index 0000000..7fcb5a5 --- /dev/null +++ b/internal/server/http/source_groups.go @@ -0,0 +1,242 @@ +// source_groups.go — REST API for /api/hosts/{id}/source-groups. +// +// A source group is "what gets backed up": a named bundle of include +// + exclude paths, a retention policy, and retry knobs. Group name +// doubles as the snapshot tag (restic --tag ). +package http + +import ( + "encoding/json" + "errors" + stdhttp "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// sourceGroupView is the JSON shape returned by GET endpoints. +type sourceGroupView struct { + ID string `json:"id"` + HostID string `json:"host_id"` + Name string `json:"name"` + Includes []string `json:"includes"` + Excludes []string `json:"excludes"` + RetentionPolicy store.RetentionPolicy `json:"retention_policy"` + RetryMax int `json:"retry_max"` + RetryBackoffSeconds int `json:"retry_backoff_seconds"` + ConflictDimension string `json:"conflict_dimension,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toSourceGroupView(g store.SourceGroup) sourceGroupView { + includes := g.Includes + if includes == nil { + includes = []string{} + } + excludes := g.Excludes + if excludes == nil { + excludes = []string{} + } + return sourceGroupView{ + ID: g.ID, HostID: g.HostID, Name: g.Name, + Includes: includes, Excludes: excludes, + RetentionPolicy: g.RetentionPolicy, + RetryMax: g.RetryMax, + RetryBackoffSeconds: g.RetryBackoffSeconds, + ConflictDimension: g.ConflictDimension, + CreatedAt: g.CreatedAt, + UpdatedAt: g.UpdatedAt, + } +} + +// sourceGroupWriteRequest is the body of POST and PUT. +type sourceGroupWriteRequest struct { + Name string `json:"name"` + Includes []string `json:"includes"` + Excludes []string `json:"excludes"` + RetentionPolicy store.RetentionPolicy `json:"retention_policy"` + RetryMax int `json:"retry_max"` + RetryBackoffSeconds int `json:"retry_backoff_seconds"` +} + +func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + rows, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), hostID) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + out := make([]sourceGroupView, 0, len(rows)) + for _, g := range rows { + out = append(out, toSourceGroupView(g)) + } + writeJSON(w, stdhttp.StatusOK, struct { + SourceGroups []sourceGroupView `json:"source_groups"` + }{SourceGroups: out}) +} + +func (s *Server) handleGetSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + groupID := chi.URLParam(r, "gid") + g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*g)) +} + +func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + var req sourceGroupWriteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required") + return + } + // Name must be unique per host (the store has a UNIQUE constraint + // but pre-check gives a friendlier error than a 500). + if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil { + writeJSONError(w, stdhttp.StatusConflict, "name_taken", + "a source group named "+req.Name+" already exists on this host") + return + } + + g := store.SourceGroup{ + ID: ulid.Make().String(), HostID: hostID, Name: req.Name, + Includes: req.Includes, Excludes: req.Excludes, + RetentionPolicy: req.RetentionPolicy, + RetryMax: req.RetryMax, + RetryBackoffSeconds: req.RetryBackoffSeconds, + } + if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + s.pushScheduleSetAsync(hostID) + writeJSON(w, stdhttp.StatusCreated, toSourceGroupView(g)) +} + +func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + groupID := chi.URLParam(r, "gid") + if _, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + var req sourceGroupWriteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required") + return + } + // If renaming, ensure the new name doesn't collide with another group. + if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil && existing.ID != groupID { + writeJSONError(w, stdhttp.StatusConflict, "name_taken", + "a source group named "+req.Name+" already exists on this host") + return + } + + g := store.SourceGroup{ + ID: groupID, HostID: hostID, Name: req.Name, + Includes: req.Includes, Excludes: req.Excludes, + RetentionPolicy: req.RetentionPolicy, + RetryMax: req.RetryMax, + RetryBackoffSeconds: req.RetryBackoffSeconds, + } + if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + s.pushScheduleSetAsync(hostID) + out, _ := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID) + if out != nil { + writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*out)) + return + } + writeJSON(w, stdhttp.StatusOK, toSourceGroupView(g)) +} + +// handleDeleteSourceGroup refuses to delete a group that is still +// referenced by any schedule. Returns 409 with the schedule list so +// the UI can offer "remove from these schedules first." +func (s *Server) handleDeleteSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + groupID := chi.URLParam(r, "gid") + using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), groupID) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + if len(using) > 0 { + writeJSON(w, stdhttp.StatusConflict, struct { + Code string `json:"code"` + Message string `json:"message"` + Schedules []string `json:"schedules"` + }{ + Code: "group_in_use", + Message: "remove this group from the listed schedules before deleting", + Schedules: using, + }) + return + } + if err := s.deps.Store.DeleteSourceGroup(r.Context(), hostID, groupID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + s.pushScheduleSetAsync(hostID) + w.WriteHeader(stdhttp.StatusNoContent) +} diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 673392c..31e6a31 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -142,23 +142,21 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) } } -// handleUIRunBackup and handleUIInitRepo are stubbed during the P2 -// redesign data-model rewrite. The dashboard per-host Run-now button -// is going away (operator clicks into host detail then a per-source- -// group Run-now), and Init-repo becomes implicit at host enrolment -// (auto-init dispatched server-side). Phase 4 of the redesign wires -// the new per-source-group Run-now via /hosts/{id}/source-groups/{gid}/run. +// Per-host Run-now and manual Init-repo were retired by the P2 redesign. +// Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs +// automatically on the agent's first WS connect after enrolment. Both +// routes return 410 Gone so any cached browser tab gets a clear error. -func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) { +func (s *Server) handleUIRunBackupGone(w stdhttp.ResponseWriter, r *stdhttp.Request) { stdhttp.Error(w, - "per-host Run-now is being replaced by per-source-group Run-now — see P2 redesign Phase 4", - stdhttp.StatusNotImplemented) + "per-host Run-now has moved — use POST /hosts/{id}/source-groups/{gid}/run", + stdhttp.StatusGone) } -func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { +func (s *Server) handleUIInitRepoGone(w stdhttp.ResponseWriter, r *stdhttp.Request) { stdhttp.Error(w, - "manual Init-repo is being replaced by auto-init at host enrolment — see P2 redesign Phase 6", - stdhttp.StatusNotImplemented) + "manual init-repo is gone — the server auto-inits on the agent's first connect", + stdhttp.StatusGone) } // addHostPage carries the Add-host form state. The result-state diff --git a/internal/store/jobs.go b/internal/store/jobs.go index 7262543..6e2d704 100644 --- a/internal/store/jobs.go +++ b/internal/store/jobs.go @@ -193,6 +193,24 @@ func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) { return &j, nil } +// HasJobOfKind reports whether any job of the given kind exists for +// this host, regardless of status. Used by the auto-init path on +// agent hello to decide whether to dispatch a fresh `restic init` — +// once we've tried once we don't auto-retry, even on failure +// (failed init usually means bad creds; retrying every reconnect +// just piles up failed rows). The operator can re-init manually via +// the Repo page's danger zone. +func (s *Store) HasJobOfKind(ctx context.Context, hostID, kind string) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = ?`, + hostID, kind).Scan(&n) + if err != nil { + return false, fmt.Errorf("store: count jobs of kind: %w", err) + } + return n > 0, nil +} + func nullableStr(s string) any { if s == "" { return nil diff --git a/tasks.md b/tasks.md index f933b36..318f287 100644 --- a/tasks.md +++ b/tasks.md @@ -131,9 +131,9 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [x] **P2R-00.3** (L) Go-side store rewrite against migration 0008. New types: `SourceGroup`, `HostRepoMaintenance`, `PendingRun`. `Schedule` slimmed to `{id, host_id, cron, enabled, source_group_ids, timestamps}`. `RetentionPolicy` moves from schedule field → source group field (type unchanged). `Host` loses `RepoInitialisedAt`, gains bandwidth caps. New files: `store/sources.go`, `store/maintenance.go`, `store/pending.go`. `store/schedules.go` rewritten for slim shape + junction CRUD. `enrollment.go` seeds a default source group + repo-maintenance row instead of a manual schedule. `ws/handler.go` drops `MarkHostRepoInitialised`. HTTP layer + UI templates **temporarily 501-stubbed** with `redesign_in_progress` — this is what P2R-01 / P2R-02 fill back in. Tests for the obsolete fat-schedule API deleted. Commit `5667cdf`. - [x] **P2R-00.4** (S) Host-detail UI patched up enough to render: `RepoInitialisedAt` template refs removed, manual init-repo branches stripped, dead Schedules sub-tab demoted to inert div (matches Jobs/Repo/Settings), broken Run-now buttons disabled with P2-Phase-4 hints. Stop-gap until P2R-02 lands the real surface. -### P2 redesign — Phase 3 (REST + WS rewire) — TODO +### P2 redesign — Phase 3 (REST + WS rewire) ✅ -- [ ] **P2R-01** (L) HTTP/WS layer against the slim shape: +- [x] **P2R-01** (L) HTTP/WS layer against the slim shape: - **Schedules REST CRUD**: `GET|POST /api/hosts/{id}/schedules`, `PUT|DELETE /api/hosts/{id}/schedules/{sid}`. Body shape is `{cron, enabled, source_group_ids[]}` — paths/excludes/retention/kind/manual all go away. Junction wiped + re-inserted on every update (per `store.UpdateSchedule`). Validation: cron parses via `robfig/cron/v3`; ≥1 `source_group_ids`; all referenced groups belong to the host. - **Source-groups REST CRUD**: `GET|POST /api/hosts/{id}/source-groups`, `GET|PUT|DELETE /api/hosts/{id}/source-groups/{gid}`. Body: `{name, includes[], excludes[], retention_policy, retry_max, retry_backoff_seconds}`. Name uniqueness per host. Refuse delete if `SchedulesUsingGroup(gid)` is non-empty (return the schedule list so UI can show "remove from these schedules first"). Mutations bump `host_schedule_version`. - **Repo-maintenance REST**: `GET|PUT /api/hosts/{id}/repo-maintenance`. Body: `{forget_cadence, prune_cadence, check_cadence, check_subset_pct, enabled}`. Server-side ticker drives execution (P2R-04), so updates here do **not** bump `host_schedule_version`. diff --git a/web/static/css/styles.css b/web/static/css/styles.css index ce2cf62..e0d47dc 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file