diff --git a/internal/agent/scheduler/scheduler.go b/internal/agent/scheduler/scheduler.go index c3ede1f..874d882 100644 --- a/internal/agent/scheduler/scheduler.go +++ b/internal/agent/scheduler/scheduler.go @@ -88,6 +88,13 @@ 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/api/messages.go b/internal/api/messages.go index f5b20ca..38cb0dd 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -181,6 +181,11 @@ type Schedule struct { 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"` } // ScheduleSetPayload — server pushes the full canonical schedule list diff --git a/internal/server/http/enrollment.go b/internal/server/http/enrollment.go index be098db..cc1b542 100644 --- a/internal/server/http/enrollment.go +++ b/internal/server/http/enrollment.go @@ -56,12 +56,11 @@ type enrollOperatorRequest struct { RepoURL string `json:"repo_url"` RepoUsername string `json:"repo_username"` RepoPassword string `json:"repo_password"` - // DefaultPaths lands on the host row at consume time. Used by - // run-now buttons (the dashboard's per-row Run, the host - // detail's Run backup now). When schedules ship in P2-01 they - // supersede this — until then, this is the only source of paths - // for run-now jobs. - DefaultPaths []string `json:"default_paths,omitempty"` + // InitialPaths seeds the host's initial manual schedule on + // consume — operator can edit/extend from the host's Schedules + // tab afterwards. Empty list = no initial schedule (operator + // must add one before backups can run). + InitialPaths []string `json:"initial_paths,omitempty"` } type enrollOperatorResponse struct { @@ -134,7 +133,6 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) AgentVersion: req.AgentVersion, ResticVersion: req.ResticVersion, EnrolledAt: time.Now().UTC(), - DefaultPaths: attachments.DefaultPaths, } if err := s.deps.Store.CreateHost(r.Context(), host, auth.HashToken(agentToken), ""); err != nil { @@ -142,6 +140,28 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) return } + // Seed an initial manual schedule from whatever paths the + // operator typed into Add-host. The schedule is editable from + // the host's Schedules tab; the operator can add automated + // schedules alongside it later. We skip this when no paths + // were supplied — the host can still enrol; it just can't + // back up until the operator adds a schedule. + if len(attachments.InitialPaths) > 0 { + seed := store.Schedule{ + ID: ulid.Make().String(), + HostID: hostID, + Kind: string(api.JobBackup), + CronExpr: "", + Paths: attachments.InitialPaths, + Enabled: true, + Manual: true, + } + if err := s.deps.Store.CreateSchedule(r.Context(), &seed); err != nil { + slog.Warn("enrollment: seed manual schedule failed", + "host_id", hostID, "err", err) + } + } + // Promote the encrypted repo creds onto the freshly-created host // row. If this fails for any reason we log loudly but still // return the bearer — the operator recovers via PUT @@ -203,7 +223,7 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } - token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.DefaultPaths) + token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths) switch err { case nil: writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt}) @@ -226,7 +246,7 @@ var errMissingRepoCreds = errAuth("missing_repo_creds") // token (shown to the operator exactly once) and the expiry time. // // Shared by the JSON endpoint and the HTML "Add host" flow. -func (s *Server) mintEnrollmentToken(ctx context.Context, repoURL, repoUsername, repoPassword string, defaultPaths []string) (string, time.Time, error) { +func (s *Server) mintEnrollmentToken(ctx context.Context, repoURL, repoUsername, repoPassword string, initialPaths []string) (string, time.Time, error) { if repoURL == "" || repoPassword == "" { return "", time.Time{}, errMissingRepoCreds } @@ -243,12 +263,12 @@ func (s *Server) mintEnrollmentToken(ctx context.Context, repoURL, repoUsername, return "", time.Time{}, err } - if defaultPaths == nil { - defaultPaths = []string{} + if initialPaths == nil { + initialPaths = []string{} } - pathsJSON, err := json.Marshal(defaultPaths) + pathsJSON, err := json.Marshal(initialPaths) if err != nil { - return "", time.Time{}, fmt.Errorf("marshal default_paths: %w", err) + return "", time.Time{}, fmt.Errorf("marshal initial_paths: %w", err) } const ttl = time.Hour diff --git a/internal/server/http/schedule_push.go b/internal/server/http/schedule_push.go index 7b9fdd7..05e25b6 100644 --- a/internal/server/http/schedule_push.go +++ b/internal/server/http/schedule_push.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "log/slog" "time" @@ -46,6 +47,7 @@ func (s *Server) loadScheduleSetPayload(ctx context.Context, hostID string) (api PreHook: r.PreHook, PostHook: r.PostHook, Enabled: r.Enabled, + Manual: r.Manual, }) } return out, nil @@ -144,37 +146,42 @@ func (s *Server) applyScheduleAck(ctx context.Context, hostID string, version in } // dispatchScheduledJob is invoked when the agent reports a local -// cron fire via `schedule.fire`. We look up the schedule, build the -// CommandRunPayload from it, persist a job row (actor=schedule, -// linked back to scheduled_id), and write MsgCommandRun straight -// back on the same conn so the agent runs the job through its -// normal command dispatch path. -// -// On any error we log and bail — the agent's cron will fire again -// at the next tick. We deliberately don't try to retry: schedules -// are by definition repeating, and a missed tick is less bad than -// a confused operator-visible "phantom job" that never actually -// ran restic. -func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn *ws.Conn, scheduleID string, scheduledAt time.Time) { - sched, err := s.deps.Store.GetSchedule(ctx, hostID, scheduleID) +// cron fire via `schedule.fire`. Thin wrapper around the shared +// dispatcher; logs and discards the return values since the agent +// can't usefully act on them. +func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, _ *ws.Conn, scheduleID string, scheduledAt time.Time) { + jobID, err := s.dispatchScheduleNow(ctx, hostID, scheduleID, nil) if err != nil { - slog.Warn("schedule.fire: schedule not found", + slog.Warn("schedule.fire: dispatch failed", "host_id", hostID, "schedule_id", scheduleID, "err", err) return } + slog.Info("schedule.fire: dispatched", + "host_id", hostID, "schedule_id", scheduleID, + "job_id", jobID, "scheduled_at", scheduledAt) +} + +// dispatchScheduleNow looks up a schedule, builds a CommandRunPayload, +// persists a jobs row (actor_kind=schedule, scheduled_id linking +// back), and ships MsgCommandRun to the host. Used by both the +// agent-driven path (cron fire reaches us as schedule.fire) and the +// UI-driven path (operator clicks Run-now on a schedule row). +// +// conn is optional: when set we write directly through it (no race +// against an in-flight Register). When nil we fall back to Hub.Send. +// Returns the new job_id on success. +func (s *Server) dispatchScheduleNow(ctx context.Context, hostID, scheduleID string, conn *ws.Conn) (string, error) { + sched, err := s.deps.Store.GetSchedule(ctx, hostID, scheduleID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return "", errFmtf("schedule not found") + } + return "", errFmtf("internal: %s", err) + } if !sched.Enabled { - // The agent shouldn't be firing disabled schedules — its - // local cron is rebuilt from the canonical version after - // every push — but treat as belt-and-braces. - slog.Info("schedule.fire: ignoring disabled schedule", - "host_id", hostID, "schedule_id", scheduleID) - return + return "", errFmtf("schedule is disabled") } - // Args differ by kind. For backup we ship the schedule's paths; - // other kinds are still arg-less in Phase 2 (forget/prune/check - // take their parameters from RetentionPolicy / Options at exec - // time on the agent — handled when those job kinds land). var args []string if sched.Kind == string(api.JobBackup) { args = append(args, sched.Paths...) @@ -191,9 +198,7 @@ func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn * ActorID: &sched.ID, CreatedAt: now, }); err != nil { - slog.Warn("schedule.fire: create job", - "host_id", hostID, "schedule_id", scheduleID, "err", err) - return + return "", errFmtf("create job: %s", err) } env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{ @@ -202,16 +207,18 @@ func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn * Args: args, }) if err != nil { - slog.Error("schedule.fire: marshal command.run", - "host_id", hostID, "schedule_id", scheduleID, "err", err) - return + return "", errFmtf("marshal command.run: %s", err) } 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, "job_id", jobID, "err", err) - return + if conn != nil { + if err := conn.Send(sendCtx, env); err != nil { + return "", errFmtf("send command.run: %s", err) + } + } else { + if err := s.deps.Hub.Send(sendCtx, hostID, env); err != nil { + return "", errFmtf("send command.run: %s", err) + } } _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ @@ -222,9 +229,7 @@ func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn * TargetID: &jobID, TS: now, }) - slog.Info("schedule.fire: dispatched", - "host_id", hostID, "schedule_id", scheduleID, - "job_id", jobID, "kind", sched.Kind, "scheduled_at", scheduledAt) + return jobID, nil } // Compile-time guard that the store actually implements the methods diff --git a/internal/server/http/schedules.go b/internal/server/http/schedules.go index 71cadb8..cbee3b3 100644 --- a/internal/server/http/schedules.go +++ b/internal/server/http/schedules.go @@ -30,6 +30,9 @@ type scheduleAPI struct { PreHook string `json:"pre_hook,omitempty"` PostHook string `json:"post_hook,omitempty"` Enabled bool `json:"enabled"` + // Manual = no cron, fires only when the operator triggers a + // run-now. Cron expr is ignored when this is true. + Manual bool `json:"manual"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -254,11 +257,13 @@ func validateSchedule(s *scheduleAPI) (code, msg string) { default: return "invalid_kind", "kind must be one of backup|forget|prune|check" } - if strings.TrimSpace(s.CronExpr) == "" { - return "missing_cron_expr", "cron_expr is required" - } - if _, err := cronParser.Parse(s.CronExpr); err != nil { - return "invalid_cron_expr", err.Error() + if !s.Manual { + if strings.TrimSpace(s.CronExpr) == "" { + return "missing_cron_expr", "cron_expr is required (or set manual=true)" + } + if _, err := cronParser.Parse(s.CronExpr); err != nil { + return "invalid_cron_expr", err.Error() + } } if s.Kind == api.JobBackup && len(s.Paths) == 0 { return "missing_paths", "backup schedules require at least one path" @@ -283,6 +288,7 @@ func toScheduleAPI(s store.Schedule) scheduleAPI { PreHook: s.PreHook, PostHook: s.PostHook, Enabled: s.Enabled, + Manual: s.Manual, CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), } diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 004c006..ed8fd6f 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -160,6 +160,7 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet) r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave) r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete) + r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun) // Live job log. r.Get("/jobs/{id}", s.handleUIJobDetail) } diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index f88c011..0f95825 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -1,6 +1,7 @@ package http import ( + "context" "crypto/rand" "encoding/base64" "errors" @@ -170,23 +171,18 @@ func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } - if len(host.DefaultPaths) == 0 { - // Tell the user with HX-Redirect via a friendly toast — for - // now, just an HTTP error: HTMX surfaces the response body - // to the operator's console, and a future toast component - // will lift it into the UI. - stdhttp.Error(w, - "this host has no default backup paths set — edit the host or wait for schedules (P2)", - stdhttp.StatusBadRequest) - return - } if host.RepoInitialisedAt == nil { stdhttp.Error(w, "this host's repo hasn't been initialised yet — click Initialise repo first", stdhttp.StatusBadRequest) return } - res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, host.DefaultPaths) + pick, err := s.pickRunNowSchedule(r.Context(), hostID) + if err != nil { + stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest) + return + } + res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, pick.Paths) if code != "" { stdhttp.Error(w, msg, status) return @@ -205,6 +201,48 @@ func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther) } +// pickRunNowSchedule chooses which schedule a generic per-host +// "Run now" button should dispatch when the operator hasn't picked +// one explicitly. Picks in priority order: the host's only enabled +// manual schedule, then its only enabled schedule of any kind. +// Returns a friendly error if there's nothing to run, or if the +// operator needs to disambiguate. +func (s *Server) pickRunNowSchedule(ctx context.Context, hostID string) (*store.Schedule, error) { + rows, err := s.deps.Store.ListSchedulesByHost(ctx, hostID) + if err != nil { + return nil, errFmt("internal: %s", err) + } + enabled := make([]store.Schedule, 0, len(rows)) + for _, r := range rows { + if r.Enabled { + enabled = append(enabled, r) + } + } + if len(enabled) == 0 { + return nil, errFmt("this host has no enabled schedules — add one in the Schedules tab") + } + manuals := []store.Schedule{} + for _, r := range enabled { + if r.Manual { + manuals = append(manuals, r) + } + } + switch { + case len(manuals) == 1: + s := manuals[0] + return &s, nil + case len(enabled) == 1: + s := enabled[0] + return &s, nil + default: + return nil, errFmt("this host has %d schedules — pick one from the Schedules tab", len(enabled)) + } +} + +func errFmt(format string, args ...any) error { + return errFmtf(format, args...) +} + // handleUIInitRepo dispatches a one-shot `restic init` job for a // host. Surfaced in the run-now panel as a red "Initialise repo" // button when host.repo_initialised_at IS NULL. On success it diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index 2334bb4..5da9481 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -26,9 +26,9 @@ type schedulesListPage struct { // scheduleEditPage drives both the Create form (Schedule.ID empty) // and the Edit form (Schedule populated). Errors come back via Error -// to be rendered as a banner; FormValues holds the just-submitted -// raw fields so a failed POST can re-render with the operator's -// typed input still in place. +// to be rendered as a banner; the rest of the fields hold the just- +// submitted raw values so a failed POST can re-render with the +// operator's typed input still in place. type scheduleEditPage struct { Host store.Host IsNew bool @@ -49,6 +49,7 @@ type scheduleEditPage struct { LimitUpKBps string LimitDownKBps string Enabled bool + Manual bool } // handleUISchedulesList renders the Schedules sub-tab on a host. @@ -151,6 +152,7 @@ func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Re ExcludesRaw: strings.Join(sched.Excludes, "\n"), TagsRaw: strings.Join(sched.Tags, ", "), Enabled: sched.Enabled, + Manual: sched.Manual, } page.KeepLast = intStringPtr(sched.RetentionPolicy.KeepLast) page.KeepHourly = intStringPtr(sched.RetentionPolicy.KeepHourly) @@ -213,6 +215,7 @@ func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Reque LimitUpKBps: strings.TrimSpace(r.PostForm.Get("limit_up_kbps")), LimitDownKBps: strings.TrimSpace(r.PostForm.Get("limit_down_kbps")), Enabled: r.PostForm.Get("enabled") == "on", + Manual: r.PostForm.Get("manual") == "on", } // Convert the raw form values into store-shape data, surfacing @@ -234,13 +237,14 @@ func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Reque return } - // Validate against the same rules the JSON API uses (cron, paths, - // hooks-on-non-backup) — the UI only handles backup kind today, - // so we hardcode kind=backup here. + // Validate against the same rules the JSON API uses. Manual + // schedules skip the cron-expr requirement; everything else + // applies the same. apiShape := scheduleAPI{ Kind: api.JobBackup, CronExpr: page.CronExpr, Paths: paths, + Manual: page.Manual, } if code, msg := validateSchedule(&apiShape); code != "" { page.Error = uiErrorMessage(code, msg) @@ -260,6 +264,7 @@ func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Reque RetentionPolicy: retention, Options: options, Enabled: page.Enabled, + Manual: page.Manual, } if err := s.deps.Store.CreateSchedule(r.Context(), &row); err != nil { page.Error = "Couldn't save schedule — see server log." @@ -294,6 +299,7 @@ func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Reque existing.RetentionPolicy = retention existing.Options = options existing.Enabled = page.Enabled + existing.Manual = page.Manual if err := s.deps.Store.UpdateSchedule(r.Context(), existing); err != nil { page.Error = "Couldn't save schedule — see server log." slog.Error("ui schedule update", "err", err) @@ -315,6 +321,46 @@ func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Reque stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther) } +// handleUIScheduleRun is the POST target of per-schedule Run-now +// buttons. Reuses dispatchScheduledJob (the same code path used by +// the agent's local cron firing) so manual + automated runs flow +// through identical job lifecycle. Sets HX-Redirect to the live +// log on success. +func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + hostID := chi.URLParam(r, "id") + scheduleID := chi.URLParam(r, "sid") + host, err := s.deps.Store.GetHost(r.Context(), hostID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if !s.deps.Hub.Connected(hostID) { + stdhttp.Error(w, "agent is offline", stdhttp.StatusBadRequest) + return + } + _ = host + jobID, err := s.dispatchScheduleNow(r.Context(), hostID, scheduleID, nil) + if err != nil { + stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest) + return + } + target := "/jobs/" + jobID + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", target) + w.WriteHeader(stdhttp.StatusOK) + return + } + stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther) +} + // handleUIScheduleDelete is the POST target of the Delete buttons on // the list view. Confirm-then-redirect; no AJAX. func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { diff --git a/internal/store/enrollment.go b/internal/store/enrollment.go index 9f93a4e..41d7435 100644 --- a/internal/store/enrollment.go +++ b/internal/store/enrollment.go @@ -19,26 +19,25 @@ import ( // later via PUT /api/hosts/{id}/repo-credentials; the agent will // refuse backup jobs until that lands. // -// defaultPaths is the JSON-encoded path list (the agent invokes -// `restic backup` with these on a run-now without explicit paths). -// Empty string is treated as "[]". Not encrypted — paths aren't -// secret. -func (s *Store) CreateEnrollmentToken(ctx context.Context, tokenHash string, ttl time.Duration, encRepoCreds, defaultPaths string) error { +// initialPaths is the JSON-encoded path list seeded into the host's +// initial manual schedule on consume. Empty string is treated as +// "[]". Not encrypted — paths aren't secret. +func (s *Store) CreateEnrollmentToken(ctx context.Context, tokenHash string, ttl time.Duration, encRepoCreds, initialPaths string) error { now := time.Now().UTC() var enc any = nil if encRepoCreds != "" { enc = encRepoCreds } - if defaultPaths == "" { - defaultPaths = "[]" + if initialPaths == "" { + initialPaths = "[]" } _, err := s.db.ExecContext(ctx, - `INSERT INTO enrollment_tokens (token_hash, created_at, expires_at, enc_repo_creds, default_paths) + `INSERT INTO enrollment_tokens (token_hash, created_at, expires_at, enc_repo_creds, initial_paths) VALUES (?, ?, ?, ?, ?)`, tokenHash, now.Format(time.RFC3339Nano), now.Add(ttl).Format(time.RFC3339Nano), - enc, defaultPaths) + enc, initialPaths) if err != nil { return fmt.Errorf("store: create enrollment token: %w", err) } @@ -77,9 +76,10 @@ type EnrollmentTokenAttachments struct { // EncRepoCreds is the AEAD ciphertext bound (additional-data) to // "token:" + token_hash. Empty if no creds were stashed. EncRepoCreds string - // DefaultPaths is the operator's run-now path list. Always - // non-nil (empty slice if none were set). - DefaultPaths []string + // InitialPaths is the operator-supplied path list seeded into + // the host's initial manual schedule. Always non-nil (empty + // slice if none were set). + InitialPaths []string } // GetEnrollmentTokenAttachments returns the operator-supplied @@ -93,25 +93,25 @@ type EnrollmentTokenAttachments struct { func (s *Store) GetEnrollmentTokenAttachments(ctx context.Context, tokenHash string) (EnrollmentTokenAttachments, error) { now := time.Now().UTC().Format(time.RFC3339Nano) row := s.db.QueryRowContext(ctx, - `SELECT enc_repo_creds, default_paths FROM enrollment_tokens + `SELECT enc_repo_creds, initial_paths FROM enrollment_tokens WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?`, tokenHash, now) var ( enc sql.NullString - defaultPaths string + initialPaths string ) - if err := row.Scan(&enc, &defaultPaths); err != nil { + if err := row.Scan(&enc, &initialPaths); err != nil { if errors.Is(err, sql.ErrNoRows) { return EnrollmentTokenAttachments{}, ErrNotFound } return EnrollmentTokenAttachments{}, fmt.Errorf("store: get enrollment token attachments: %w", err) } - out := EnrollmentTokenAttachments{DefaultPaths: []string{}} + out := EnrollmentTokenAttachments{InitialPaths: []string{}} if enc.Valid { out.EncRepoCreds = enc.String } - if defaultPaths != "" { - _ = json.Unmarshal([]byte(defaultPaths), &out.DefaultPaths) + if initialPaths != "" { + _ = json.Unmarshal([]byte(initialPaths), &out.InitialPaths) } return out, nil } diff --git a/internal/store/hosts.go b/internal/store/hosts.go index 4d3eea3..cf542cd 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -17,25 +17,17 @@ func (s *Store) CreateHost(ctx context.Context, h Host, agentTokenHash, certPinS if err != nil { return fmt.Errorf("store: marshal tags: %w", err) } - if h.DefaultPaths == nil { - h.DefaultPaths = []string{} - } - defaultPaths, err := json.Marshal(h.DefaultPaths) - if err != nil { - return fmt.Errorf("store: marshal default_paths: %w", err) - } _, err = s.db.ExecContext(ctx, `INSERT INTO hosts ( id, name, os, arch, agent_version, restic_version, protocol_version, enrolled_at, status, tags, - agent_token_hash, cert_pin_sha256, default_paths - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'offline', ?, ?, ?, ?)`, + agent_token_hash, cert_pin_sha256 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'offline', ?, ?, ?)`, h.ID, h.Name, h.OS, h.Arch, h.AgentVersion, h.ResticVersion, h.ProtocolVersion, h.EnrolledAt.UTC().Format(time.RFC3339Nano), string(tags), - agentTokenHash, certPinSHA256, - string(defaultPaths)) + agentTokenHash, certPinSHA256) if err != nil { return fmt.Errorf("store: create host: %w", err) } @@ -50,7 +42,7 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (* enrolled_at, last_seen_at, status, repo_id, tags, current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, - applied_schedule_version, default_paths, repo_initialised_at + applied_schedule_version, repo_initialised_at FROM hosts WHERE agent_token_hash = ?`, tokenHash) return scanHost(row) @@ -63,7 +55,7 @@ func (s *Store) GetHost(ctx context.Context, id string) (*Host, error) { enrolled_at, last_seen_at, status, repo_id, tags, current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, - applied_schedule_version, default_paths, repo_initialised_at + applied_schedule_version, repo_initialised_at FROM hosts WHERE id = ?`, id) return scanHost(row) } @@ -124,7 +116,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) { enrolled_at, last_seen_at, status, repo_id, tags, current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, - applied_schedule_version, default_paths, repo_initialised_at + applied_schedule_version, repo_initialised_at FROM hosts ORDER BY name`) if err != nil { return nil, fmt.Errorf("store: list hosts: %w", err) @@ -162,7 +154,6 @@ func scanHostRow(s hostScanner) (*Host, error) { repoID, currentJob, lastBkSt sql.NullString enrolled string tags string - defaultPaths string repoInitAt sql.NullString ) err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, @@ -170,7 +161,7 @@ func scanHostRow(s hostScanner) (*Host, error) { &enrolled, &lastSeen, &h.Status, &repoID, &tags, ¤tJob, &lastBackupAt, &lastBkSt, &h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount, - &h.AppliedScheduleVersion, &defaultPaths, &repoInitAt) + &h.AppliedScheduleVersion, &repoInitAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -211,9 +202,6 @@ func scanHostRow(s hostScanner) (*Host, error) { if tags != "" { _ = json.Unmarshal([]byte(tags), &h.Tags) } - if defaultPaths != "" { - _ = json.Unmarshal([]byte(defaultPaths), &h.DefaultPaths) - } if repoInitAt.Valid { t, err := time.Parse(time.RFC3339Nano, repoInitAt.String) if err != nil { diff --git a/internal/store/migrations/0007_manual_schedules.sql b/internal/store/migrations/0007_manual_schedules.sql new file mode 100644 index 0000000..1b0b558 --- /dev/null +++ b/internal/store/migrations/0007_manual_schedules.sql @@ -0,0 +1,53 @@ +-- 0007_manual_schedules.sql +-- +-- Unify "what does this host back up?" under schedules. Drop the +-- legacy host.default_paths column in favour of a `manual` flag on +-- schedules: a manual schedule carries paths/excludes/tags/retention +-- like any other but has no cron expression — it only fires when +-- the operator clicks Run-now. +-- +-- Steps (each is a single ALTER, no table rebuilds): +-- 1. Add schedules.manual. +-- 2. For every host with non-empty default_paths, create a manual +-- schedule seeded with those paths and bump host_schedule_version +-- so the next push reaches the agent. +-- 3. ALTER TABLE hosts DROP COLUMN default_paths. +-- 4. ALTER TABLE enrollment_tokens RENAME COLUMN default_paths +-- TO initial_paths. +-- +-- The earlier draft of this migration rebuilt hosts via the +-- create-new + drop-old + rename pattern. With foreign_keys=ON +-- (which the connection DSN sets), DROP TABLE on the parent +-- triggered ON DELETE CASCADE on every child of hosts(id) — the +-- smoke env lost schedules / jobs / snapshots / host_credentials +-- as a result. SQLite 3.35+ supports column-level ALTERs, so we +-- skip the rebuild entirely and avoid the cascade trap. + +ALTER TABLE schedules ADD COLUMN manual INTEGER NOT NULL DEFAULT 0; + +INSERT INTO schedules ( + id, host_id, kind, cron_expr, + paths, excludes, tags, retention_policy, options, + pre_hook, post_hook, enabled, manual, created_at, updated_at +) +SELECT + lower(hex(randomblob(13))), + id, 'backup', '', + default_paths, '[]', '[]', '{}', '{}', + '', '', 1, 1, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +FROM hosts +WHERE default_paths IS NOT NULL + AND default_paths != '' + AND default_paths != '[]'; + +INSERT INTO host_schedule_version (host_id, version) + SELECT id, 1 FROM hosts + WHERE default_paths IS NOT NULL + AND default_paths != '' + AND default_paths != '[]' +ON CONFLICT(host_id) DO UPDATE SET version = version + 1; + +ALTER TABLE hosts DROP COLUMN default_paths; +ALTER TABLE enrollment_tokens RENAME COLUMN default_paths TO initial_paths; diff --git a/internal/store/schedules.go b/internal/store/schedules.go index 5688f88..c2fd79d 100644 --- a/internal/store/schedules.go +++ b/internal/store/schedules.go @@ -43,13 +43,13 @@ func (st *Store) CreateSchedule(ctx context.Context, s *Schedule) error { if _, err := tx.ExecContext(ctx, `INSERT INTO schedules ( id, host_id, kind, cron_expr, paths, excludes, tags, - retention_policy, options, pre_hook, post_hook, enabled, + retention_policy, options, pre_hook, post_hook, enabled, manual, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, s.ID, s.HostID, s.Kind, s.CronExpr, string(pathsJSON), string(excludesJSON), string(tagsJSON), string(retentionJSON), string(optionsJSON), - s.PreHook, s.PostHook, boolToInt(s.Enabled), + s.PreHook, s.PostHook, boolToInt(s.Enabled), boolToInt(s.Manual), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), ); err != nil { return fmt.Errorf("store: create schedule: %w", err) @@ -94,13 +94,13 @@ func (st *Store) UpdateSchedule(ctx context.Context, s *Schedule) error { `UPDATE schedules SET cron_expr = ?, paths = ?, excludes = ?, tags = ?, retention_policy = ?, options = ?, - pre_hook = ?, post_hook = ?, enabled = ?, + pre_hook = ?, post_hook = ?, enabled = ?, manual = ?, updated_at = ? WHERE id = ? AND host_id = ?`, s.CronExpr, string(pathsJSON), string(excludesJSON), string(tagsJSON), string(retentionJSON), string(optionsJSON), - s.PreHook, s.PostHook, boolToInt(s.Enabled), + s.PreHook, s.PostHook, boolToInt(s.Enabled), boolToInt(s.Manual), now.Format(time.RFC3339Nano), s.ID, s.HostID, ) @@ -148,7 +148,7 @@ func (st *Store) DeleteSchedule(ctx context.Context, hostID, scheduleID string) func (st *Store) GetSchedule(ctx context.Context, hostID, scheduleID string) (*Schedule, error) { row := st.db.QueryRowContext(ctx, `SELECT id, host_id, kind, cron_expr, paths, excludes, tags, - retention_policy, options, pre_hook, post_hook, enabled, + retention_policy, options, pre_hook, post_hook, enabled, manual, created_at, updated_at FROM schedules WHERE id = ? AND host_id = ?`, scheduleID, hostID) @@ -164,7 +164,7 @@ func (st *Store) GetSchedule(ctx context.Context, hostID, scheduleID string) (*S func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Schedule, error) { rows, err := st.db.QueryContext(ctx, `SELECT id, host_id, kind, cron_expr, paths, excludes, tags, - retention_policy, options, pre_hook, post_hook, enabled, + retention_policy, options, pre_hook, post_hook, enabled, manual, created_at, updated_at FROM schedules WHERE host_id = ? ORDER BY created_at`, hostID) @@ -238,11 +238,11 @@ func scanScheduleRow(s scheduleScanner) (*Schedule, error) { out Schedule paths, excludes, tags, retention, options string createdAt, updatedAt string - enabled int + enabled, manual int ) err := s.Scan(&out.ID, &out.HostID, &out.Kind, &out.CronExpr, &paths, &excludes, &tags, &retention, &options, - &out.PreHook, &out.PostHook, &enabled, + &out.PreHook, &out.PostHook, &enabled, &manual, &createdAt, &updatedAt) if err != nil { return nil, err @@ -263,6 +263,7 @@ func scanScheduleRow(s scheduleScanner) (*Schedule, error) { _ = json.Unmarshal([]byte(options), &out.Options) } out.Enabled = enabled != 0 + out.Manual = manual != 0 if t, err := time.Parse(time.RFC3339Nano, createdAt); err == nil { out.CreatedAt = t } diff --git a/internal/store/types.go b/internal/store/types.go index 7d6c417..ea65c1e 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -60,10 +60,6 @@ type Host struct { SnapshotCount int OpenAlertCount int AppliedScheduleVersion int64 - // DefaultPaths is what `restic backup` is invoked with when an - // operator hits "Run now" without supplying paths. Phase 1 - // interim — schedules (P2-01) supersede this. - DefaultPaths []string // RepoInitialisedAt is non-nil once we've confirmed the host's // repo has been initialised — either the operator clicked the // init button, or a backup succeeded, or snapshots.report came @@ -90,6 +86,12 @@ type Schedule struct { PreHook string PostHook string Enabled bool + // Manual schedules carry paths/excludes/tags/retention like any + // other but have no cron — they only fire when the operator + // clicks Run-now. Lets us keep one data shape for "what gets + // backed up" without forcing every host to have an automated + // schedule. Created by Add-host with the typed paths. + Manual bool CreatedAt time.Time UpdatedAt time.Time } diff --git a/tasks.md b/tasks.md index fda762e..34fc176 100644 --- a/tasks.md +++ b/tasks.md @@ -100,6 +100,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [x] **P2-01** (M) Schedule schema + CRUD API. `schedules` table was already laid down in 0001; this slice adds `store.Schedule`/`RetentionPolicy`/`ScheduleOptions` types, `CreateSchedule` / `GetSchedule` / `ListSchedulesByHost` / `UpdateSchedule` / `DeleteSchedule` / `GetHostScheduleVersion` / `SetHostAppliedScheduleVersion` (mutations bump `host_schedule_version` atomically in-tx), and REST endpoints `GET|POST /api/hosts/{id}/schedules` + `PUT|DELETE /api/hosts/{id}/schedules/{sid}`. Validation: cron-expr parses via `robfig/cron/v3` (same parser the agent will use, so anything that validates here will fire there); kind ∈ {backup, forget, prune, check} (init/unlock are operator-only); backup schedules require ≥1 path; hooks rejected on non-backup kinds (spec §14.3). Mutations audit-logged. Server + store tests cover the happy path, validation, and version bumps. - [x] **P2-02** (L) Server-pushed schedule reconciliation. `pushScheduleSet*` helpers (on-hello + async post-CRUD flavours), wiring in `onAgentHello` (always pushes, even when the host has no repo creds yet), `pushScheduleSetAsync` called from Create/Update/Delete handlers (no-op when the host is offline; on-hello catches up). `MsgScheduleAck` handled in the WS dispatcher: `OnScheduleAck` callback persists `applied_schedule_version`. Agent-side `schedule.set` handler ships in P2-03; the server side now has parity tests. - [x] **P2-03** (M) Agent local scheduler. New `internal/agent/scheduler` package wraps `robfig/cron/v3` — `Apply(ScheduleSetPayload, Sender)` stops the prior cron (waits for in-flight entries), rebuilds from scratch (skipping disabled entries + skipping bad cron exprs with a warn log), starts, and emits `schedule.ack`. On a tick the entry sends a new `schedule.fire` envelope to the server with `{schedule_id, scheduled_at}`. The server's `OnScheduleFire` callback (`dispatchScheduledJob`) looks up the schedule, builds args from kind, persists a `jobs` row with `actor_kind=schedule` + `scheduled_id`, and ships `command.run` back on the same conn — agent runs the job through the existing dispatcher. Tx is swapped on every Apply so reconnect is handled naturally (cron entries that fire against a dropped tx log + skip the tick). `CreateJob` now writes `scheduled_id`; this column was in the schema since 0001 but never populated. Tests: scheduler unit tests cover ack-on-apply, cron tick → fire envelope, disabled-entries silent, replace-prior-state stops the old cron. Server-side end-to-end test covers fire → command.run with the right job_id/kind/args, plus jobs row with actor_kind=schedule + scheduled_id linking back. **Deferred:** persistence of next-fire times across agent restarts (a missed fire window during downtime simply fires once on reconnect — desirable behaviour). +- [x] **P2-04.5** (S) Manual schedules — kill `host.default_paths`. Two independent path lists (host.default_paths fed Run-now while schedule.paths fed cron) was a real footgun that could put divergent file sets in the same repo. Replaced with a `manual` flag on schedules: same data shape, no cron, fires only via Run-now. Migration 0007 drops `host.default_paths` (ALTER TABLE DROP COLUMN — no rebuild dance, the original draft used the parent-table-rebuild pattern and FK cascade wiped every dependent table on the smoke env), seeds a manual schedule from any non-empty default_paths, and renames `enrollment_tokens.default_paths` → `initial_paths`. Add-host form retitled "Initial schedule · manual" so the operator knows where the paths land. Per-schedule Run-now button (`POST /hosts/{id}/schedules/{sid}/run`) reuses the same `dispatchScheduleNow` path used by `schedule.fire`. Dashboard's per-host Run-now picks the host's only enabled manual schedule, then falls back to the only enabled schedule, else returns "pick one in Schedules tab" — keeps one-click for the common case. Schedule edit form gains a "Manual schedule" toggle that hides the cron field when checked. Agent skips manual schedules in cron build. Validator allows missing cron when manual=true. - [x] **P2-04** (M) Schedule editor UI. New "Schedules" sub-tab on host detail (header + run-now panel preserved across the snapshot/schedule pages). List view shows status, cron, paths, retention summary (`store.RetentionPolicy.Summary()` renders "last=7, d=14, w=4"), tags, and edit/delete buttons. The header carries a "version N · agent in sync / agent at vM" indicator backed by `host_schedule_version` + `applied_schedule_version`. Create/edit form covers cron expr (with quick-pick presets), paths textarea, excludes textarea, tags (comma-separated), retention (six numeric inputs mirroring restic's `--keep-*` flags), bandwidth caps, enabled toggle. Form validation re-renders with the operator's typed input still in place. Each save fires `pushScheduleSetAsync` so an online agent re-arms within a few seconds. Hooks UI deferred to P2-15 (lands when the hook execution path does). - [ ] **P2-05** (M) `forget` command with retention policy (keep-last/daily/weekly/monthly/yearly) - [ ] **P2-06** (M) `prune` command (admin-only, uses non-append-only credential) diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 6c8ecf6..34129f7 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}.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}.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-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;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-1\.5{padding-left:.375rem;padding-right:.375rem}.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}.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}.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;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-1\.5{padding-left:.375rem;padding-right:.375rem}.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 diff --git a/web/templates/pages/add_host.html b/web/templates/pages/add_host.html index e624c91..92e2121 100644 --- a/web/templates/pages/add_host.html +++ b/web/templates/pages/add_host.html @@ -43,14 +43,14 @@
Free-form. Used for filtering and grouping on the dashboard.
-

Default backup paths

+

Initial schedule · manual

- What restic backup runs against when an operator hits “Run now”. Until schedules ship in Phase 2, this is the only source of paths for run-now jobs — leave it empty if you’ll dispatch via the JSON API instead. + These paths become an initial manual schedule on the new host — manual = no cron, only fires when you click Run now. You can edit this schedule (or add automated ones alongside it) from the host's Schedules tab. Leave blank to skip — the host will enrol but can't back up until you add a schedule.
@@ -187,6 +187,10 @@
{{$page.ExpiresAt.Format "15:04:05.000"}} server token minted · 1h ttl
awaiting POST /api/agents/enroll …
+

+ Enrolment will create a manual schedule from the paths above. Find it (and add automated ones) under + Host > Schedules once the agent connects. +