diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e6ff8d1..b37a625 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -34,12 +34,14 @@ jobs: with: go-version: ${{ env.GO_VERSION }} cache: true - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@v7 with: - # v1.61 was built against Go 1.23 and refuses to load a - # config that targets a newer toolchain — go.mod is on 1.25. - # Bumping to a v2.x release built against current Go. - version: v2.1.6 + # Must be built against the same Go release as go.mod targets, + # otherwise the linter refuses to load with "Go language + # version used to build golangci-lint is lower than the + # targeted Go version". v2.5.0 is the first v2.x line built + # with Go 1.25; bump in lockstep with go.mod. + version: v2.5.0 args: --timeout=5m build: diff --git a/.golangci.yml b/.golangci.yml index 45a99f6..787123f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,18 +1,17 @@ +version: "2" + run: timeout: 5m tests: true linters: - disable-all: true + default: none enable: - errcheck - - gosimple - govet - ineffassign - staticcheck - unused - - gofumpt - - goimports - misspell - revive - bodyclose @@ -21,22 +20,29 @@ linters: - prealloc - unconvert - unparam - -linters-settings: - goimports: - local-prefixes: gitea.dcglab.co.uk/steve/restic-manager - revive: + settings: + revive: + rules: + - name: exported + arguments: ["disableStutteringCheck"] + misspell: + locale: US + exclusions: rules: - - name: exported - arguments: ["disableStutteringCheck"] - misspell: - locale: US + - path: _test\.go + linters: + - errcheck + - unparam + +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - gitea.dcglab.co.uk/steve/restic-manager issues: - exclude-rules: - - path: _test\.go - linters: - - errcheck - - unparam max-issues-per-linter: 0 max-same-issues: 0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f7542c..842b713 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,15 +11,38 @@ repos: - id: mixed-line-ending args: ["--fix=lf"] - - repo: https://github.com/dnephin/pre-commit-golang - rev: v0.5.1 + # Go-specific hooks. Local hooks (rather than third-party repos) so + # the version of each tool tracks whatever is on the developer's + # PATH, matching what they'd use to run the same checks by hand. + # Required tools: + # * go (toolchain matching go.mod) + # * gofumpt — `go install mvdan.cc/gofumpt@latest` + # * golangci-lint — `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6` + # + # Install + activate the hooks once per clone: + # pre-commit install + - repo: local hooks: - - id: go-fmt - - id: go-imports - - id: go-vet-mod - - id: go-mod-tidy + - id: gofumpt + name: gofumpt + description: Format Go files with gofumpt (stricter superset of gofmt) + entry: bash -c 'PATH="$(go env GOPATH)/bin:$PATH" exec gofumpt -l -w "$@"' -- + language: system + types: [go] + pass_filenames: true + + - id: go-vet + name: go vet + description: Run go vet across all packages + entry: go vet ./... + language: system + types: [go] + pass_filenames: false - - repo: https://github.com/golangci/golangci-lint - rev: v1.61.0 - hooks: - id: golangci-lint + name: golangci-lint + description: Run golangci-lint against the whole module (matches CI) + entry: bash -c 'PATH="$(go env GOPATH)/bin:$PATH" exec golangci-lint run ./...' + language: system + types: [go] + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index 4a16a3c..c623059 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,14 @@ Project-specific rules for Claude when working in this repo. +## Run `go vet` before every commit + +CI runs `go vet ./...` and will fail the build on any vet error. +Run it locally before staging a commit and fix anything it flags. +A common one is `res, _ := http.Do(...); defer res.Body.Close()` — +if `err != nil` then `res` may be nil and the deferred close +panics. Always check the error before touching `res`. + ## No `Co-Authored-By` trailers on commits Don't add `Co-Authored-By: Claude ...` (or any other co-author diff --git a/Makefile b/Makefile index 2970715..b24798f 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/downlo TAILWIND_INPUT := web/styles/input.css TAILWIND_OUTPUT := web/static/css/styles.css -.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch +.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch setup hooks help: @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "};{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' @@ -58,6 +58,15 @@ test-race: ## Run tests with the race detector lint: ## Run golangci-lint golangci-lint run ./... +setup: hooks ## One-time per-clone setup (Go tools + git hooks) + @command -v gofumpt >/dev/null 2>&1 || go install mvdan.cc/gofumpt@latest + @command -v golangci-lint >/dev/null 2>&1 || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 + @echo "==> setup complete: gofumpt, golangci-lint, pre-commit hooks installed" + +hooks: ## Install the pre-commit hooks defined in .pre-commit-config.yaml + @command -v pre-commit >/dev/null 2>&1 || { echo "pre-commit not found — install with 'pip install pre-commit' or 'brew install pre-commit'" >&2; exit 1; } + pre-commit install + fmt: ## Format with gofumpt + goimports gofumpt -w . goimports -local gitea.dcglab.co.uk/steve/restic-manager -w . diff --git a/internal/agent/runner/runner.go b/internal/agent/runner/runner.go index 62c17f4..dc6763c 100644 --- a/internal/agent/runner/runner.go +++ b/internal/agent/runner/runner.go @@ -74,31 +74,38 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t lastProgress := time.Now() handle := func(stream string, line string, ev any) { - // Forward every line to the server as log.stream. - now := time.Now().UTC() - logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{ - JobID: jobID, - Seq: seq.Add(1), - TS: now, - Stream: api.LogStream(stream), - Payload: line, - }) - _ = r.tx.Send(logEnv) + // Throttled progress events come from restic's `status` JSON. + // We deliberately do NOT forward the raw status line to + // log.stream — it's emitted ~every 16ms by restic --json and + // would drown the live log in dupes for any short backup. The + // progress widget already covers the same information at a + // sane sample rate. + status, isStatus := ev.(restic.BackupStatus) + if !isStatus { + now := time.Now().UTC() + logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{ + JobID: jobID, + Seq: seq.Add(1), + TS: now, + Stream: api.LogStream(stream), + Payload: line, + }) + _ = r.tx.Send(logEnv) + } - // Throttled progress events. - if status, ok := ev.(restic.BackupStatus); ok { + if isStatus { if time.Since(lastProgress) < r.progressMinPeriod { return } lastProgress = time.Now() progEnv, _ := api.Marshal(api.MsgJobProgress, jobID, api.JobProgressPayload{ - JobID: jobID, - PercentDone: status.PercentDone, - FilesDone: status.FilesDone, - TotalFiles: status.TotalFiles, - BytesDone: status.BytesDone, - TotalBytes: status.TotalBytes, - ETASeconds: status.SecondsRem, + JobID: jobID, + PercentDone: status.PercentDone, + FilesDone: status.FilesDone, + TotalFiles: status.TotalFiles, + BytesDone: status.BytesDone, + TotalBytes: status.TotalBytes, + ETASeconds: status.SecondsRem, ThroughputBps: throughput(status.BytesDone, status.SecondsElapsed), }) _ = r.tx.Send(progEnv) diff --git a/internal/agent/scheduler/scheduler.go b/internal/agent/scheduler/scheduler.go index c3ede1f..e9576ba 100644 --- a/internal/agent/scheduler/scheduler.go +++ b/internal/agent/scheduler/scheduler.go @@ -110,7 +110,7 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) { "received", len(payload.Schedules), "active", added) // Ack outside the lock — Send() shouldn't take long, but holding - // s.mu across an external call would needlessly serialise other + // s.mu across an external call would needlessly serialize other // callers (e.g. a future Status() inspection from the UI). ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{ Version: payload.Version, @@ -167,4 +167,3 @@ func (s *Scheduler) fire(entry api.Schedule) { "schedule_id", entry.ID, "err", err) } } - diff --git a/internal/agent/secrets/secrets.go b/internal/agent/secrets/secrets.go index 8aa467f..f5bbc82 100644 --- a/internal/agent/secrets/secrets.go +++ b/internal/agent/secrets/secrets.go @@ -20,7 +20,7 @@ import ( // additionalData binds ciphertexts to the agent-secrets context, so a // blob lifted from one role's file can't be replayed into another's -// row in some unrelated table that uses the same key. (Defence in +// row in some unrelated table that uses the same key. (Defense in // depth — the key is per-host today, but cheap to be careful.) const additionalData = "rm-agent-repo-creds-v1" diff --git a/internal/agent/sysinfo/sysinfo.go b/internal/agent/sysinfo/sysinfo.go index c4b9c62..e0c369e 100644 --- a/internal/agent/sysinfo/sysinfo.go +++ b/internal/agent/sysinfo/sysinfo.go @@ -48,7 +48,9 @@ func Collect(ctx context.Context, resticPath string) (Snapshot, error) { // detectResticVersion runs `restic version` and parses the first line. // Output looks like: -// restic 0.17.1 compiled with go1.22.5 on linux/amd64 +// +// restic 0.17.1 compiled with go1.22.5 on linux/amd64 +// // Returns the version token (e.g. "0.17.1") or "" if restic isn't // found. We never block startup on a missing restic — the operator // might not have installed it yet, and the agent should still be @@ -74,5 +76,5 @@ func detectResticVersion(ctx context.Context, override string) (string, error) { if len(parts) >= 2 && parts[0] == "restic" { return parts[1], nil } - return "", fmt.Errorf("sysinfo: unrecognised restic version output: %q", first) + return "", fmt.Errorf("sysinfo: unrecognized restic version output: %q", first) } diff --git a/internal/agent/wsclient/client.go b/internal/agent/wsclient/client.go index f37f3e4..4e5d0b0 100644 --- a/internal/agent/wsclient/client.go +++ b/internal/agent/wsclient/client.go @@ -40,7 +40,7 @@ type Config struct { // Sender is what handlers use to push agent → server messages // (job.progress, job.finished, log.stream, command.result, …). // Returned by the WS client to the dispatch handler. Write operations -// serialise behind a single mutex on the conn; concurrent calls are +// serialize behind a single mutex on the conn; concurrent calls are // safe. type Sender interface { Send(env api.Envelope) error @@ -52,7 +52,7 @@ type Sender interface { type Handler func(ctx context.Context, env api.Envelope, tx Sender) error // Run keeps the agent connected indefinitely. Returns when ctx is -// cancelled. Errors during a single connection attempt are logged and +// canceled. Errors during a single connection attempt are logged and // trigger reconnect-with-backoff; only ctx.Done() ends the loop. func Run(ctx context.Context, cfg Config, handle Handler) error { if cfg.HeartbeatPeriod <= 0 { @@ -69,7 +69,10 @@ func Run(ctx context.Context, cfg Config, handle Handler) error { slog.Warn("ws agent disconnect", "err", err) } if err := sleepCtx(ctx, backoff.next()); err != nil { - return nil + // ctx cancellation mid-backoff means the parent shut us down — + // exit the reconnect loop quietly rather than propagating + // a context error up to a caller that will discard it. + return nil //nolint:nilerr } } } @@ -100,11 +103,15 @@ func connectOnce(ctx context.Context, cfg Config, handle Handler) error { } dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - conn, _, err := websocket.Dial(dialCtx, wsURL, dialOpts) + conn, res, err := websocket.Dial(dialCtx, wsURL, dialOpts) cancel() if err != nil { return fmt.Errorf("dial: %w", err) } + // websocket.Dial returns the upgrade response separately from the + // conn. Body is empty on a successful upgrade but Go's net/http + // still expects it closed to release the connection. + defer func() { _ = res.Body.Close() }() defer conn.CloseNow() //nolint:errcheck // Send hello. diff --git a/internal/agent/wsclient/enroll.go b/internal/agent/wsclient/enroll.go index 6ba00bd..5fee682 100644 --- a/internal/agent/wsclient/enroll.go +++ b/internal/agent/wsclient/enroll.go @@ -50,7 +50,7 @@ func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollRe if err != nil { return nil, fmt.Errorf("agent enroll: post: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() rawRes, _ := io.ReadAll(res.Body) if res.StatusCode != stdhttp.StatusCreated { return nil, fmt.Errorf("agent enroll: server returned %d: %s", diff --git a/internal/api/messages.go b/internal/api/messages.go index ad98a7e..c93cad6 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -10,13 +10,18 @@ import ( // constants so we don't end up with both "linux" and "Linux" rows. type HostOS string +// Allowed values for HostOS. Lowercased on the wire so the server +// can use a single CHECK constraint. const ( OSLinux HostOS = "linux" OSWindows HostOS = "windows" ) +// HostArch is the agent's CPU architecture; same lowercase-on-wire +// rule as HostOS. type HostArch string +// Allowed values for HostArch. const ( ArchAmd64 HostArch = "amd64" ArchArm64 HostArch = "arm64" @@ -45,6 +50,9 @@ type HeartbeatPayload struct { // JobKind is the operation an agent is being asked to run, or just ran. type JobKind string +// Allowed JobKind values. backup is operator/cron driven; init runs +// once per host on first connect; forget/prune/check fire from the +// server-side maintenance ticker; unlock is operator-only. const ( JobBackup JobKind = "backup" JobInit JobKind = "init" @@ -57,12 +65,16 @@ const ( // JobStatus is the lifecycle state of a job. type JobStatus string +// Allowed JobStatus values. queued → running → one of {succeeded, +// failed, JobCancelled} as a terminal state. The wire/DB literal for +// the JobCancelled value uses UK spelling — don't "fix" it; existing +// job rows + agent payloads will mismatch. //nolint:misspell const ( JobQueued JobStatus = "queued" JobRunning JobStatus = "running" JobSucceeded JobStatus = "succeeded" JobFailed JobStatus = "failed" - JobCancelled JobStatus = "cancelled" + JobCancelled JobStatus = "cancelled" //nolint:misspell // wire format ) // CommandRunPayload is the server → agent dispatch for a run-now job. @@ -145,6 +157,8 @@ type LogStreamLine struct { // LogStream identifies which channel a log line came from. type LogStream string +// Allowed LogStream values. stdout/stderr are passed through verbatim; +// event is the parsed restic --json envelope (summary, error, etc). const ( LogStdout LogStream = "stdout" LogStderr LogStream = "stderr" @@ -175,12 +189,12 @@ type Snapshot struct { // RepoStatsPayload — agent reports periodic repo health facts derived // from `restic stats` and lock-file inspection. type RepoStatsPayload struct { - SizeBytes int64 `json:"size_bytes"` - SnapshotCount int `json:"snapshot_count"` - DedupRatio float64 `json:"dedup_ratio"` - LastCheckAt time.Time `json:"last_check_at,omitempty"` - LastCheckStatus string `json:"last_check_status,omitempty"` - LockState string `json:"lock_state"` // locked|unlocked + SizeBytes int64 `json:"size_bytes"` + SnapshotCount int `json:"snapshot_count"` + DedupRatio float64 `json:"dedup_ratio"` + LastCheckAt time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LockState string `json:"lock_state"` // locked|unlocked } // Schedule is the agent-facing view of a slim Schedule row plus its @@ -220,8 +234,8 @@ type ScheduleSetPayload struct { // ScheduleAckPayload — agent confirms it has applied a given version. type ScheduleAckPayload struct { - Version int64 `json:"version"` - AppliedAt time.Time `json:"applied_at"` + Version int64 `json:"version"` + AppliedAt time.Time `json:"applied_at"` } // ScheduleFirePayload — agent reports a local cron entry just fired. @@ -239,11 +253,11 @@ type ScheduleFirePayload struct { // repo connection details). Empty fields mean "leave existing alone"; // to clear something, send an explicit zero value. type ConfigUpdatePayload struct { - RepoURL string `json:"repo_url,omitempty"` - RepoPassword string `json:"repo_password,omitempty"` // sensitive - RepoUsername string `json:"repo_username,omitempty"` - RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth) - HookShell string `json:"hook_shell,omitempty"` + RepoURL string `json:"repo_url,omitempty"` + RepoPassword string `json:"repo_password,omitempty"` // sensitive + RepoUsername string `json:"repo_username,omitempty"` + RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth) + HookShell string `json:"hook_shell,omitempty"` } // AgentUpdateAvailablePayload — informational only; the agent does diff --git a/internal/api/wire.go b/internal/api/wire.go index d551bb0..df646a5 100644 --- a/internal/api/wire.go +++ b/internal/api/wire.go @@ -12,35 +12,35 @@ type MessageType string // Agent → server message types. const ( - MsgHello MessageType = "hello" - MsgHeartbeat MessageType = "heartbeat" - MsgJobStarted MessageType = "job.started" - MsgJobProgress MessageType = "job.progress" - MsgJobFinished MessageType = "job.finished" - MsgSnapshotsRpt MessageType = "snapshots.report" - MsgRepoStats MessageType = "repo.stats" - MsgLogStream MessageType = "log.stream" - MsgScheduleAck MessageType = "schedule.ack" - MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job - MsgCommandResult MessageType = "command.result" // ack for command.run - MsgError MessageType = "error" + MsgHello MessageType = "hello" + MsgHeartbeat MessageType = "heartbeat" + MsgJobStarted MessageType = "job.started" + MsgJobProgress MessageType = "job.progress" + MsgJobFinished MessageType = "job.finished" + MsgSnapshotsRpt MessageType = "snapshots.report" + MsgRepoStats MessageType = "repo.stats" + MsgLogStream MessageType = "log.stream" + MsgScheduleAck MessageType = "schedule.ack" + MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job + MsgCommandResult MessageType = "command.result" // ack for command.run + MsgError MessageType = "error" ) // Server → agent message types. const ( - MsgCommandRun MessageType = "command.run" - MsgCommandCancel MessageType = "command.cancel" - MsgScheduleSet MessageType = "schedule.set" - MsgConfigUpdate MessageType = "config.update" - MsgAgentUpdateAvail MessageType = "agent.update.available" + MsgCommandRun MessageType = "command.run" + MsgCommandCancel MessageType = "command.cancel" + MsgScheduleSet MessageType = "schedule.set" + MsgConfigUpdate MessageType = "config.update" + MsgAgentUpdateAvail MessageType = "agent.update.available" ) // Envelope is the framing for every WS message in either direction. // Payload is parsed into the concrete struct chosen by Type. // -// ID is set on RPC-style messages (command.run / command.result) so -// responses can be correlated. For one-shot pushes (heartbeat, -// job.progress) it is empty. +// ID is set on RPC-style messages (command.run / command.result) so +// responses can be correlated. For one-shot pushes (heartbeat, +// job.progress) it is empty. type Envelope struct { Type MessageType `json:"type"` ID string `json:"id,omitempty"` @@ -71,6 +71,8 @@ func (e Envelope) UnmarshalPayload(v any) error { // These are stable identifiers; client code may switch on them. type ErrorCode string +// Stable ErrorCode values surfaced over the wire. Clients switch on +// these; renaming requires a wire-version bump. const ( ErrProtocolTooOld ErrorCode = "protocol_too_old" ErrProtocolTooNew ErrorCode = "protocol_too_new" diff --git a/internal/auth/passwords.go b/internal/auth/passwords.go index dd26567..d245ace 100644 --- a/internal/auth/passwords.go +++ b/internal/auth/passwords.go @@ -16,6 +16,7 @@ import ( // argon2id parameters following RFC 9106 §4 "second // recommended option" (memory-constrained): // - 64 MiB memory, 3 iterations, 4 lanes, 32-byte tag. +// // These are tunable per-deployment if a beefy controller wants to // crank them; we ship a defensible default. const ( @@ -27,7 +28,9 @@ const ( ) // HashPassword returns an argon2id-encoded string of the form -// $argon2id$v=19$m=...,t=...,p=...$$ +// +// $argon2id$v=19$m=...,t=...,p=...$$ +// // safe to store in a TEXT column. The salt is freshly random per call. func HashPassword(password string) (string, error) { salt := make([]byte, defaultSaltLen) @@ -53,7 +56,7 @@ func VerifyPassword(encoded, password string) error { parts := strings.Split(encoded, "$") // "$argon2id$v=...$m=...,t=...,p=...$$" → 6 parts (leading empty) if len(parts) != 6 || parts[1] != "argon2id" { - return errors.New("auth: unrecognised hash format") + return errors.New("auth: unrecognized hash format") } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { diff --git a/internal/auth/passwords_test.go b/internal/auth/passwords_test.go index b7f82bd..8256289 100644 --- a/internal/auth/passwords_test.go +++ b/internal/auth/passwords_test.go @@ -41,7 +41,7 @@ func TestVerifyRejectsMalformed(t *testing.T) { "", "not-a-hash", "$argon2i$v=19$m=64,t=3,p=4$AAAA$BBBB", // wrong variant - "$argon2id$", // truncated + "$argon2id$", // truncated "$argon2id$v=99$m=64,t=3,p=4$AAAA$BBBB", // bad version } for _, c := range cases { diff --git a/internal/crypto/aead.go b/internal/crypto/aead.go index 4564d30..7a68264 100644 --- a/internal/crypto/aead.go +++ b/internal/crypto/aead.go @@ -65,7 +65,7 @@ func GenerateKeyFile(path string) error { if err != nil { return fmt.Errorf("create key file %q: %w", path, err) } - defer f.Close() + defer func() { _ = f.Close() }() key := make([]byte, KeyLen) if _, err := io.ReadFull(rand.Reader, key); err != nil { return fmt.Errorf("read random: %w", err) diff --git a/internal/restic/runner.go b/internal/restic/runner.go index c0d0d22..05721af 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -15,7 +15,7 @@ import ( "time" ) -// Locate resolves the path to the restic binary. Honour an explicit +// Locate resolves the path to the restic binary. Honor an explicit // override if provided, else fall back to PATH. func Locate(override string) (string, error) { if override != "" { @@ -41,12 +41,12 @@ func Locate(override string) (string, error) { // never assign it back to Env, never pass it to slog. If anything // in this package ever needs to *log* a URL, use RedactURL. type Env struct { - Bin string // path to restic binary - RepoURL string // RESTIC_REPOSITORY (no embedded creds) - RepoUsername string // optional HTTP basic-auth user for rest: URLs - RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password - ExtraEnv map[string]string // any other RESTIC_* / passthrough - WorkDir string // CWD; default = current + Bin string // path to restic binary + RepoURL string // RESTIC_REPOSITORY (no embedded creds) + RepoUsername string // optional HTTP basic-auth user for rest: URLs + RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password + ExtraEnv map[string]string // any other RESTIC_* / passthrough + WorkDir string // CWD; default = current } // EventKind enumerates what we care about in restic's --json output @@ -54,10 +54,12 @@ type Env struct { // switch on message_type. type EventKind string +// Known message_type values restic --json emits during a backup. +// Kept as constants so callers can switch without typo risk. const ( - EventStatus EventKind = "status" // periodic progress + EventStatus EventKind = "status" // periodic progress EventVerbose EventKind = "verbose_status" - EventSummary EventKind = "summary" // emitted once at end of backup + EventSummary EventKind = "summary" // emitted once at end of backup EventErrorEvent EventKind = "error" ) @@ -90,7 +92,7 @@ type BackupSummary struct { } // LineHandler receives every stdout/stderr line. event is non-nil -// when the line is a recognised JSON status; raw always carries the +// when the line is a recognized JSON status; raw always carries the // original text (so we can also tee to job_logs as `stdout`). type LineHandler func(stream string, raw string, event any) @@ -256,7 +258,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { // Sniff for "config file already exists" on stderr; if we see it // we'll treat the non-zero exit as a soft success — running init - // against an already-initialised repo is a no-op semantically, + // against an already-initialized repo is a no-op semantically, // not a failure. Wraps the caller's handle so the line still // gets streamed verbatim to the operator-facing log. alreadyInited := false @@ -280,7 +282,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { if werr := cmd.Wait(); werr != nil { if alreadyInited { if handle != nil { - handle("event", "repo already initialised — treating as success", nil) + handle("event", "repo already initialized — treating as success", nil) } return nil } diff --git a/internal/restic/url_test.go b/internal/restic/url_test.go index 63f9b5d..03c2920 100644 --- a/internal/restic/url_test.go +++ b/internal/restic/url_test.go @@ -8,9 +8,11 @@ func TestMergeRestCreds(t *testing.T) { }{ {"rest with creds", "rest:http://h:8000/p/", "u", "p", "rest:http://u:p@h:8000/p/"}, {"rest no user — no-op", "rest:http://h:8000/p/", "", "p", "rest:http://h:8000/p/"}, - {"rest creds already inline — no-op", + { + "rest creds already inline — no-op", "rest:http://existing:secret@h:8000/p/", "u", "p", - "rest:http://existing:secret@h:8000/p/"}, + "rest:http://existing:secret@h:8000/p/", + }, {"non-rest s3 — no-op", "s3:s3.amazonaws.com/bucket", "u", "p", "s3:s3.amazonaws.com/bucket"}, {"unparseable — pass through", "rest:not a url", "u", "p", "rest:not a url"}, {"https URL kept intact", "rest:https://h/p/", "u", "p", "rest:https://u:p@h/p/"}, diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 9e09ecd..0d883cf 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -34,9 +34,9 @@ type Config struct { } // Load resolves config in this order: -// 1. defaults -// 2. YAML at the given path (if non-empty and exists) -// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …) +// 1. defaults +// 2. YAML at the given path (if non-empty and exists) +// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …) // // The result is validated; a zero-error return means the server is // safe to start. diff --git a/internal/server/http/agent_assets.go b/internal/server/http/agent_assets.go index 2efbd8e..4808504 100644 --- a/internal/server/http/agent_assets.go +++ b/internal/server/http/agent_assets.go @@ -57,7 +57,7 @@ func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) } func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request) { - // chi's TrimPrefix-like behaviour: r.URL.Path is "/install/". + // chi's TrimPrefix-like behavior: r.URL.Path is "/install/". rel := strings.TrimPrefix(r.URL.Path, "/install/") // Reject any path traversal — must be a flat filename. if rel == "" || strings.ContainsAny(rel, "/\\") { diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index 6c0fc2e..cb25f71 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -137,7 +137,7 @@ func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) { return } if n > 0 { - writeJSONError(w, stdhttp.StatusConflict, "already_initialised", + writeJSONError(w, stdhttp.StatusConflict, "already_initialized", "a user already exists; bootstrap is disabled") return } diff --git a/internal/server/http/auth_test.go b/internal/server/http/auth_test.go index 740eb21..18650dc 100644 --- a/internal/server/http/auth_test.go +++ b/internal/server/http/auth_test.go @@ -36,7 +36,7 @@ func newTestServer(t *testing.T, withBootstrapToken bool) (*Server, string) { aead, _ := crypto.NewAEAD(key) deps := Deps{ - Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, + Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, Store: st, AEAD: aead, } @@ -125,7 +125,9 @@ func TestLoginAndLogout(t *testing.T) { bs, _ := json.Marshal(bootstrapRequest{ Token: "test-token", Username: "alice", Password: "averylongpassword", }) - stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)) //nolint:errcheck + if bsRes, err := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)); err == nil { + _ = bsRes.Body.Close() + } // Login. body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"}) diff --git a/internal/server/http/enrollment.go b/internal/server/http/enrollment.go index e0f3f26..2706ea5 100644 --- a/internal/server/http/enrollment.go +++ b/internal/server/http/enrollment.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "fmt" "log/slog" stdhttp "net/http" @@ -142,7 +143,7 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) // Seed the host's "default" source group with whatever paths the // operator typed into Add-host (empty allowed; group is editable - // from the Sources tab post-enrol). Also seed the host's + // from the Sources tab post-enroll). Also seed the host's // repo-maintenance row with default cadences so forget/prune/check // start ticking on their own. Auto-init dispatch lands in Phase 6 // of the redesign. @@ -222,12 +223,11 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt return } token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths) - switch err { - case nil: + switch { + case err == nil: writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt}) - case errMissingRepoCreds: - writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", - "repo_url and repo_password are required so the agent can run backups on first connect") + case errors.Is(err, errMissingRepoCreds): + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "repo_url and repo_password are required so the agent can run backups on first connect") default: writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") } diff --git a/internal/server/http/host_bandwidth.go b/internal/server/http/host_bandwidth.go new file mode 100644 index 0000000..e42996b --- /dev/null +++ b/internal/server/http/host_bandwidth.go @@ -0,0 +1,62 @@ +// host_bandwidth.go — REST API for /api/hosts/{id}/bandwidth. +// +// Host-wide upload/download caps (KB/s). Applied to every restic +// invocation as --limit-upload / --limit-download. Pass null / +// omit a field to clear that cap. +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 hostBandwidthRequest struct { + BandwidthUpKBps *int `json:"bandwidth_up_kbps"` + BandwidthDownKBps *int `json:"bandwidth_down_kbps"` +} + +type hostBandwidthView struct { + BandwidthUpKBps *int `json:"bandwidth_up_kbps"` + BandwidthDownKBps *int `json:"bandwidth_down_kbps"` +} + +func (s *Server) handleUpdateHostBandwidth(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 { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + var req hostBandwidthRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if req.BandwidthUpKBps != nil && *req.BandwidthUpKBps < 0 { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value", + "bandwidth_up_kbps must be non-negative") + return + } + if req.BandwidthDownKBps != nil && *req.BandwidthDownKBps < 0 { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value", + "bandwidth_down_kbps must be non-negative") + return + } + if err := s.deps.Store.SetHostBandwidth(r.Context(), hostID, req.BandwidthUpKBps, req.BandwidthDownKBps); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, stdhttp.StatusOK, hostBandwidthView(req)) +} diff --git a/internal/server/http/host_credentials.go b/internal/server/http/host_credentials.go index 2c564c0..5887a75 100644 --- a/internal/server/http/host_credentials.go +++ b/internal/server/http/host_credentials.go @@ -162,7 +162,7 @@ func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.R w.WriteHeader(stdhttp.StatusNoContent) } -// pushRepoCredsToAgent serialises blob into a config.update envelope +// pushRepoCredsToAgent serializes blob into a config.update envelope // and ships it down the agent's WS. Returns an error from the hub // (no-op if not connected — caller is expected to check first when it // matters). diff --git a/internal/server/http/hosts.go b/internal/server/http/hosts.go index d969e33..e1d5ca3 100644 --- a/internal/server/http/hosts.go +++ b/internal/server/http/hosts.go @@ -10,23 +10,23 @@ import ( // store row, but with explicit time-strings so wire format is stable // across DB driver changes. type hostView struct { - ID string `json:"id"` - Name string `json:"name"` - OS string `json:"os"` - Arch string `json:"arch"` - AgentVersion string `json:"agent_version,omitempty"` - ResticVersion string `json:"restic_version,omitempty"` - ProtocolVersion int `json:"protocol_version"` - EnrolledAt string `json:"enrolled_at"` - LastSeenAt *string `json:"last_seen_at,omitempty"` - Status string `json:"status"` - Tags []string `json:"tags"` - CurrentJobID *string `json:"current_job_id,omitempty"` - LastBackupAt *string `json:"last_backup_at,omitempty"` - LastBackupStatus *string `json:"last_backup_status,omitempty"` - RepoSizeBytes int64 `json:"repo_size_bytes"` - SnapshotCount int `json:"snapshot_count"` - OpenAlertCount int `json:"open_alert_count"` + ID string `json:"id"` + Name string `json:"name"` + OS string `json:"os"` + Arch string `json:"arch"` + AgentVersion string `json:"agent_version,omitempty"` + ResticVersion string `json:"restic_version,omitempty"` + ProtocolVersion int `json:"protocol_version"` + EnrolledAt string `json:"enrolled_at"` + LastSeenAt *string `json:"last_seen_at,omitempty"` + Status string `json:"status"` + Tags []string `json:"tags"` + CurrentJobID *string `json:"current_job_id,omitempty"` + LastBackupAt *string `json:"last_backup_at,omitempty"` + LastBackupStatus *string `json:"last_backup_status,omitempty"` + RepoSizeBytes int64 `json:"repo_size_bytes"` + SnapshotCount int `json:"snapshot_count"` + OpenAlertCount int `json:"open_alert_count"` } // handleListHosts returns the full fleet as JSON. Authenticated; the diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index 7b90d10..e6afd7a 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -16,8 +16,8 @@ import ( // runNowRequest is the body of POST /api/hosts/:id/jobs. type runNowRequest struct { - Kind api.JobKind `json:"kind"` - Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.) + Kind api.JobKind `json:"kind"` + Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.) } type runNowResponse struct { diff --git a/internal/server/http/p2r01_test.go b/internal/server/http/p2r01_test.go index 01bca60..6863e87 100644 --- a/internal/server/http/p2r01_test.go +++ b/internal/server/http/p2r01_test.go @@ -215,24 +215,30 @@ func TestSchedulesCRUDValidation(t *testing.T) { // 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) + 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) + 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) + 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) } @@ -247,8 +253,10 @@ func TestSchedulesCRUDValidation(t *testing.T) { // 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) + 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) } @@ -269,8 +277,10 @@ func TestSchedulesCRUDValidation(t *testing.T) { // 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) + map[string]any{ + "cron": "@hourly", "enabled": false, + "source_group_ids": []string{gid}, + }, cookie) if status != 200 { t.Fatalf("update: %d body=%+v", status, body) } @@ -439,7 +449,10 @@ func TestRunSourceGroupOfflineHost(t *testing.T) { url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil) req.AddCookie(cookie) req.Header.Set("Accept", "application/json") - res, _ := stdhttp.DefaultClient.Do(req) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } defer res.Body.Close() if res.StatusCode != stdhttp.StatusServiceUnavailable { t.Errorf("offline: want 503, got %d", res.StatusCode) @@ -456,7 +469,10 @@ func TestRunSourceGroupUnknownGroup(t *testing.T) { url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil) req.AddCookie(cookie) req.Header.Set("Accept", "application/json") - res, _ := stdhttp.DefaultClient.Do(req) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } defer res.Body.Close() if res.StatusCode != stdhttp.StatusNotFound { t.Errorf("unknown group: want 404, got %d", res.StatusCode) @@ -478,5 +494,7 @@ func equalStrings(a, b []string) bool { } // keep fmt import live — used for occasional debug. -var _ = fmt.Sprintf -var _ = strings.HasPrefix +var ( + _ = fmt.Sprintf + _ = strings.HasPrefix +) diff --git a/internal/server/http/p2r01_ws_test.go b/internal/server/http/p2r01_ws_test.go index 5555f9d..bc3c57a 100644 --- a/internal/server/http/p2r01_ws_test.go +++ b/internal/server/http/p2r01_ws_test.go @@ -6,8 +6,8 @@ package http import ( "context" "encoding/json" - "net/http/httptest" stdhttp "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -29,13 +29,18 @@ func agentDial(t *testing.T, srv *Server, ts *httptest.Server, hostID, token str url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent" ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, }) if err != nil { t.Fatalf("dial: %v", err) } - t.Cleanup(func() { _ = c.CloseNow() }) + t.Cleanup(func() { + _ = c.CloseNow() + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }) return c } @@ -76,7 +81,7 @@ func drainUntil(t *testing.T, c *websocket.Conn, wantType api.MessageType) api.E return api.Envelope{} } -// enrolHostForWS pre-enrols a host with bound repo creds so the server +// enrolHostForWS pre-enrolls a host with bound repo creds so the server // will treat it as ready to receive command.run. func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) { t.Helper() @@ -97,7 +102,7 @@ func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (ho if err := st.SetHostCredentials(context.Background(), hostID, enc); err != nil { t.Fatalf("set creds: %v", err) } - return + return hostID, token } func sendHello(t *testing.T, c *websocket.Conn, hostname string) { diff --git a/internal/server/http/repo_maintenance.go b/internal/server/http/repo_maintenance.go index e020b30..364024b 100644 --- a/internal/server/http/repo_maintenance.go +++ b/internal/server/http/repo_maintenance.go @@ -142,4 +142,3 @@ func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhtt } writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m)) } - diff --git a/internal/server/http/schedule_push.go b/internal/server/http/schedule_push.go index 6c2c692..02692b7 100644 --- a/internal/server/http/schedule_push.go +++ b/internal/server/http/schedule_push.go @@ -4,7 +4,7 @@ // 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; +// the group catalog. 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. @@ -167,7 +167,12 @@ func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn * // 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) { +// dispatchBackupForGroup persists a backup job row, sends the +// command.run envelope to the agent, and audit-logs the dispatch. +// Returns the persisted job ID on success, or "" on any failure +// (failures are slog.Warn-ed). Callers may use the returned ID to, +// e.g., redirect the UI to the live job log. +func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) string { jobID := ulid.Make().String() now := time.Now().UTC() scheduleRef := scheduleID @@ -181,7 +186,7 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host }); err != nil { slog.Warn("schedule.fire: persist job", "host_id", hostID, "schedule_id", scheduleID, "group", g.Name, "err", err) - return + return "" } // Backup ignores RetentionPolicy — the forget cadence lives on // host_repo_maintenance and is driven by the server-side ticker @@ -196,14 +201,14 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host if err != nil { slog.Warn("schedule.fire: marshal command.run", "host_id", hostID, "schedule_id", scheduleID, "err", err) - return + 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 + return "" } _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ ID: ulid.Make().String(), @@ -216,4 +221,5 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host slog.Info("schedule.fire: dispatched backup", "host_id", hostID, "schedule_id", scheduleID, "group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt) + return jobID } diff --git a/internal/server/http/schedules.go b/internal/server/http/schedules.go index 95c4e58..1e33e9d 100644 --- a/internal/server/http/schedules.go +++ b/internal/server/http/schedules.go @@ -212,7 +212,7 @@ func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req 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 "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 49fb3b3..f286fdb 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -126,6 +126,10 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance) + // Host-wide bandwidth caps (host.bandwidth_up_kbps / + // bandwidth_down_kbps). Apply to every restic invocation. + r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth) + // 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. @@ -180,11 +184,24 @@ func (s *Server) routes(r chi.Router) { // Durable post-Add-host page (operator can refresh / come // back; password decrypted from the token row each render). // Polled fragment under /awaiting flips to "connected" once - // the agent enrols. + // the agent enrolls. r.Get("/hosts/pending/{token}", s.handleUIPendingHost) r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) // Host detail (Snapshots tab is the default). r.Get("/hosts/{id}", s.handleUIHostDetail) + // Sources tab + source-group CRUD forms. + r.Get("/hosts/{id}/sources", s.handleUIHostSources) + r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) + r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave) + r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) + r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave) + r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete) + // Repo tab — connection / bandwidth / maintenance. Three + // independent forms so saving one doesn't touch the others. + r.Get("/hosts/{id}/repo", s.handleUIHostRepo) + r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave) + r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave) + r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave) // Schedules tab + create/edit/delete forms. r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 31e6a31..0dd4752 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -44,7 +44,11 @@ func staticHandler() stdhttp.Handler { func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { c, err := r.Cookie(sessionCookieName) if err != nil { - return nil, nil + // Missing or invalid cookie just means the caller isn't logged + // in — that's a normal state, not a server error. Return + // (nil, nil) so callers can decide between "redirect to login" + // and "treat as anonymous". + return nil, nil //nolint:nilerr } sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value)) if err != nil { @@ -81,11 +85,13 @@ func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui } // baseView populates the fields the nav partial needs on every -// authenticated page. -func (s *Server) baseView(u *ui.User, active string) ui.ViewData { +// authenticated page. Every UI page sits under the dashboard primary +// nav today; if a future page lives under a different primary nav +// tab (e.g. Settings, Audit), accept an Active arg again. +func (s *Server) baseView(u *ui.User) ui.ViewData { return ui.ViewData{ User: u, - Active: active, + Active: "dashboard", Version: s.version(), } } @@ -103,11 +109,64 @@ func (s *Server) version() string { // dashboardPage is the data the dashboard template renders against. type dashboardPage struct { - Hosts []store.Host + Hosts []dashboardHostRow HostCount int Summary store.FleetSummary } +// dashboardHostRow carries a host plus the per-row Run-now decision +// the host_row partial needs. The decision is computed server-side +// once per render rather than recomputed in the template. +type dashboardHostRow struct { + Host store.Host + // RunAllScheduleID is the ID of the single schedule that covers + // every source group on the host. Empty when zero or 2+ schedules + // match — in that case the row shows "Open →" instead of a Run-now + // button (the operator picks per-group from the host detail). + RunAllScheduleID string +} + +// pickRunAllSchedule returns the ID of the single schedule whose +// source-group set ⊇ every source group on the host. Returns "" when +// zero or 2+ such "covering" schedules exist (operator-disambiguation +// belongs on the host detail, not the dashboard one-click). +func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string { + if len(groups) == 0 || len(scheds) == 0 { + return "" + } + groupIDs := make(map[string]struct{}, len(groups)) + for _, g := range groups { + groupIDs[g.ID] = struct{}{} + } + matched := "" + for _, sc := range scheds { + if !sc.Enabled { + continue + } + // Treat sc.SourceGroupIDs as a set; check it covers every group. + got := make(map[string]struct{}, len(sc.SourceGroupIDs)) + for _, gid := range sc.SourceGroupIDs { + got[gid] = struct{}{} + } + covers := true + for gid := range groupIDs { + if _, ok := got[gid]; !ok { + covers = false + break + } + } + if !covers { + continue + } + if matched != "" { + // Two distinct covering schedules — ambiguous, bail out. + return "" + } + matched = sc.ID + } + return matched +} + // handleUIDashboard is the root page. Auth-gated; falls through to // /login if there is no session. func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -129,10 +188,28 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) return } - view := s.baseView(u, "dashboard") + // Per-host: pick the single covering schedule (if any) so the row + // can render a one-click Run-now where it's unambiguous. Two store + // calls per host — fine at fleet sizes we care about. + rows := make([]dashboardHostRow, 0, len(hosts)) + for _, h := range hosts { + row := dashboardHostRow{Host: h} + groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID) + if gerr != nil { + slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr) + } + scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID) + if serr != nil { + slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr) + } + row.RunAllScheduleID = pickRunAllSchedule(scheds, groups) + rows = append(rows, row) + } + + view := s.baseView(u) view.OpenAlerts = summary.OpenAlerts view.Page = dashboardPage{ - Hosts: hosts, + Hosts: rows, HostCount: len(hosts), Summary: summary, } @@ -178,16 +255,16 @@ type addHostPage struct { } // pendingHostPage is the GET /hosts/pending/{token} view. Lives -// for as long as the token does (1h ttl); once the agent enrols, +// for as long as the token does (1h ttl); once the agent enrolls, // the handler redirects to /hosts/{host_id} and this page is gone. type pendingHostPage struct { - Token string - ServerURL string - ExpiresAt time.Time - RepoURL string - RepoUsername string - RepoPassword string - InitialPaths []string + Token string + ServerURL string + ExpiresAt time.Time + RepoURL string + RepoUsername string + RepoPassword string + InitialPaths []string } // handleUIAddHostGet renders the empty Add host form. @@ -196,7 +273,7 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request if u == nil { return } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Add host · restic-manager" view.Page = addHostPage{ServerURL: s.publicURL(r)} if err := s.deps.UI.Render(w, "add_host", view); err != nil { @@ -256,11 +333,11 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques if page.Error == "" { token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths)) - switch err { - case nil: + switch { + case err == nil: stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther) return - case errMissingRepoCreds: + case errors.Is(err, errMissingRepoCreds): page.Error = "Repo URL and password are both required." default: slog.Error("ui add_host: mint token", "err", err) @@ -268,7 +345,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques } } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Add host · restic-manager" view.Page = page w.WriteHeader(stdhttp.StatusUnprocessableEntity) @@ -279,7 +356,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques // handleUIPendingHost serves the durable Add-host result page — // shown after a successful POST /hosts/new and reachable until the -// agent enrols (the page redirects to /hosts/{id} once that +// agent enrolls (the page redirects to /hosts/{id} once that // happens) or the token expires (1h ttl). The password is // re-decrypted from the encrypted token row on every render so // the operator can refresh, bookmark, navigate away and come back. @@ -335,7 +412,7 @@ func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Reques } } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Pending host · restic-manager" view.Page = page if err := s.deps.UI.Render(w, "pending_host", view); err != nil { @@ -397,9 +474,44 @@ type awaitingFragment struct { LastSeenAt *time.Time } +// hostChromeData is the field set the host_chrome partial reads from +// every host-detail-tab page's Page struct. Embed it as the first +// (anonymous) field of the page struct so .Page.Host / .Page.SubTab +// resolve via field promotion in the template. +type hostChromeData struct { + Host store.Host + SubTab string // snapshots | sources | schedules | repo + Crumb string // breadcrumb tail ("snapshots" / "sources" / etc) + SourceGroupCount int + ScheduleCount int + ScheduleVersion int64 // host_schedule_version (latest desired) +} + +// loadHostChrome fetches the per-tab counts that every host-detail tab +// renders in the chrome (sub-tab badges + version indicator). On any +// non-fatal store error it logs and degrades to zeros — better to +// render the page with stale counts than 500 the whole tab. +func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData { + d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb} + if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil { + d.SourceGroupCount = len(groups) + } else { + slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err) + } + if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil { + d.ScheduleCount = len(scheds) + } else { + slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err) + } + if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil { + d.ScheduleVersion = v + } + return d +} + // hostDetailPage carries everything the host detail template needs. type hostDetailPage struct { - Host store.Host + hostChromeData Snapshots []store.Snapshot // SnapshotsShown is the number rendered (we cap at ~50 for the // first slice; pagination lands when it matters). @@ -440,10 +552,10 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request shown = shown[:cap] } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = host.Name + " · restic-manager" view.Page = hostDetailPage{ - Host: *host, + hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"), Snapshots: shown, SnapshotsShown: len(shown), } @@ -539,7 +651,7 @@ func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) nextSeq = logs[n-1].Seq } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = job.Kind + " · " + host.Name + " · restic-manager" view.Page = jobDetailPage{ Job: *job, @@ -636,21 +748,6 @@ func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) { }) } -// userByID fetches the full store.User the UI session represents. -// Returns the user, ok-flag, error. Used by handlers that need the -// store-side row (e.g. for audit_log.user_id) rather than just the -// projected ui.User. -func (s *Server) userByID(r *stdhttp.Request, id string) (*store.User, bool, error) { - u, err := s.deps.Store.GetUserByID(r.Context(), id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - return nil, false, nil - } - return nil, false, err - } - return u, true, nil -} - // handleUILoginGet renders the login form. If the user is already // signed in we redirect them home — login is for the unauthenticated. func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go new file mode 100644 index 0000000..79ad2ae --- /dev/null +++ b/internal/server/http/ui_repo.go @@ -0,0 +1,350 @@ +package http + +import ( + "encoding/json" + "errors" + "log/slog" + stdhttp "net/http" + "strconv" + "strings" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// ui_repo.go — HTML form-driven repo-tab handlers (connection, +// bandwidth caps, maintenance cadences, danger-zone re-init). Splits +// the page into three independent forms so saving one section +// doesn't disturb the others. +// +// GET /hosts/{id}/repo — render +// POST /hosts/{id}/repo/credentials — connection +// POST /hosts/{id}/repo/bandwidth — host-wide bw caps +// POST /hosts/{id}/repo/maintenance — forget/prune/check cadences + +type hostRepoPage struct { + hostChromeData + + // Connection (redacted view) + RepoURL string + RepoUsername string + HasPassword bool + + // Bandwidth (form values, blank means "no cap") + BandwidthUp string + BandwidthDown string + + // Maintenance row + Maintenance store.HostRepoMaintenance + + // Snapshots-by-tag — map[group_name]count, plus an "untagged" row. + SnapshotsByTag map[string]int + UntaggedSnapshots int + GroupNames []string // ordered, for stable rendering + + // Inline form-error banners. Empty when no error for that section. + CredentialsError string + BandwidthError string + MaintenanceError string + + // Highlight which form was just submitted, for the success-state + // border (subtle UX nicety; empty = no recent save). + SavedSection string +} + +// loadHostRepoPage builds the read-only side of the page state. The +// per-form save handlers re-call this and overlay any banner / saved +// markers before rendering. +func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRepoPage, error) { + p := &hostRepoPage{ + hostChromeData: s.loadHostChrome(r, host, "repo", "repo"), + } + + // Credentials (redacted). + enc, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID) + switch { + case err == nil: + plain, derr := s.deps.AEAD.Decrypt(enc, []byte("host:"+host.ID)) + if derr == nil { + var blob repoCredsBlob + if jerr := json.Unmarshal(plain, &blob); jerr == nil { + p.RepoURL = blob.RepoURL + p.RepoUsername = blob.RepoUsername + p.HasPassword = blob.RepoPassword != "" + } + } + case errors.Is(err, store.ErrNotFound): + // no creds yet — leave fields empty + default: + return nil, err + } + + // Bandwidth. + if host.BandwidthUpKBps != nil { + p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps) + } + if host.BandwidthDownKBps != nil { + p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps) + } + + // Maintenance — auto-seed defaults if missing. + m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID) + if err != nil && errors.Is(err, store.ErrNotFound) { + if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); seedErr != nil { + return nil, seedErr + } + m, err = s.deps.Store.GetRepoMaintenance(r.Context(), host.ID) + } + if err != nil { + return nil, err + } + p.Maintenance = *m + + // Snapshot counts by tag — used for the right-rail breakdown. + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err == nil { + groupNameSet := make(map[string]struct{}, len(groups)) + for _, g := range groups { + p.GroupNames = append(p.GroupNames, g.Name) + groupNameSet[g.Name] = struct{}{} + } + if snaps, serr := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID); serr == nil { + p.SnapshotsByTag = make(map[string]int, len(groups)) + for _, sn := range snaps { + matched := false + for _, t := range sn.Tags { + if _, ok := groupNameSet[t]; ok { + p.SnapshotsByTag[t]++ + matched = true + } + } + if !matched { + p.UntaggedSnapshots++ + } + } + } + } + return p, nil +} + +func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + page, err := s.loadHostRepoPage(r, *host) + if err != nil { + slog.Error("ui repo: load page", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + page.SavedSection = r.URL.Query().Get("saved") + view := s.baseView(u) + view.Title = host.Name + " repo · restic-manager" + view.Page = *page + if err := s.deps.UI.Render(w, "host_repo", view); err != nil { + slog.Error("ui: render host_repo", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +// renderRepoFormError loads the page state, overlays the section's +// error banner, and renders with a 422. Save-success goes through a +// 303 redirect with `?saved=
` instead, so this path is for +// validation failures only. +func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, credErr, bwErr, mntErr string) { + page, err := s.loadHostRepoPage(r, *host) + if err != nil { + slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + page.CredentialsError = credErr + page.BandwidthError = bwErr + page.MaintenanceError = mntErr + view := s.baseView(u) + view.Title = host.Name + " repo · restic-manager" + view.Page = *page + w.WriteHeader(stdhttp.StatusUnprocessableEntity) + if err := s.deps.UI.Render(w, "host_repo", view); err != nil { + slog.Error("ui: render host_repo", "err", err) + } +} + +// handleUIRepoCredentialsSave updates the host's stored repo URL, +// username, and (optionally) password. Empty password means "leave +// the existing one alone" — passwords are never round-tripped to the +// browser, so a blank field is the only way an operator can save the +// other fields without re-typing the password. +func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + repoURL := strings.TrimSpace(r.PostForm.Get("repo_url")) + repoUser := strings.TrimSpace(r.PostForm.Get("repo_username")) + repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately + + if repoURL == "" { + s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "") + return + } + + // Merge with existing blob — same semantics as the JSON PUT. + existing := repoCredsBlob{} + if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID); err == nil { + if plain, derr := s.deps.AEAD.Decrypt(cur, []byte("host:"+host.ID)); derr == nil { + _ = json.Unmarshal(plain, &existing) + } + } + existing.RepoURL = repoURL + existing.RepoUsername = repoUser + if repoPass != "" { + existing.RepoPassword = repoPass + } + if existing.RepoPassword == "" { + s.renderRepoPage(w, r, u, host, + "No password on file yet — set one before saving the URL/username.", + "", "") + return + } + + enc, err := s.encryptRepoCreds(existing, []byte("host:"+host.ID)) + if err != nil { + slog.Error("ui repo creds: encrypt", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, enc); err != nil { + slog.Error("ui repo creds: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) { + _ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing) + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=credentials", stdhttp.StatusSeeOther) +} + +// handleUIRepoBandwidthSave updates the host's upload/download caps. +// Empty input → nil pointer → no cap. Negative → error. +func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up")) + down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down")) + if upErr != nil || downErr != nil { + s.renderRepoPage(w, r, u, host, "", + "Bandwidth caps must be non-negative whole numbers (or blank for no cap).", + "") + return + } + if err := s.deps.Store.SetHostBandwidth(r.Context(), host.ID, up, down); err != nil { + slog.Error("ui repo bandwidth: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=bandwidth", stdhttp.StatusSeeOther) +} + +// handleUIRepoMaintenanceSave updates the forget/prune/check +// cadences in one go. Cron expressions parsed with the same parser +// the agent + REST handler use. +func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + forgetCron := strings.TrimSpace(r.PostForm.Get("forget_cron")) + pruneCron := strings.TrimSpace(r.PostForm.Get("prune_cron")) + checkCron := strings.TrimSpace(r.PostForm.Get("check_cron")) + subsetStr := strings.TrimSpace(r.PostForm.Get("check_subset_pct")) + + for label, expr := range map[string]string{ + "forget": forgetCron, "prune": pruneCron, "check": checkCron, + } { + if expr == "" { + s.renderRepoPage(w, r, u, host, "", "", + label+" cadence is required.") + return + } + if _, err := cronParser.Parse(expr); err != nil { + s.renderRepoPage(w, r, u, host, "", "", + label+" cadence didn't parse: "+err.Error()) + return + } + } + subset, err := strconv.Atoi(subsetStr) + if err != nil || subset < 0 || subset > 100 { + s.renderRepoPage(w, r, u, host, "", "", + "check subset % must be between 0 and 100.") + return + } + + if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); err != nil { + slog.Error("ui repo maintenance: seed", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + m := store.HostRepoMaintenance{ + HostID: host.ID, + ForgetCron: forgetCron, + ForgetEnabled: r.PostForm.Get("forget_enabled") == "1", + PruneCron: pruneCron, + PruneEnabled: r.PostForm.Get("prune_enabled") == "1", + CheckCron: checkCron, + CheckEnabled: r.PostForm.Get("check_enabled") == "1", + CheckSubsetPct: subset, + } + if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil { + slog.Error("ui repo maintenance: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=maintenance", stdhttp.StatusSeeOther) +} + +// parseOptionalNonNegInt returns (nil, nil) for an empty string, or +// (*int, nil) for a non-negative integer. Negative or non-numeric → +// error. Used for bandwidth caps where blank means "no limit". +func parseOptionalNonNegInt(s string) (*int, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return nil, errors.New("invalid") + } + return &n, nil +} diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index 1e060d3..485e64e 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -1,38 +1,422 @@ package http import ( + "context" + "encoding/json" + "errors" + "log/slog" stdhttp "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) -// ui_schedules.go — HTML form-driven schedule CRUD. -// -// Stubbed during the P2 redesign template rewrite. Phase 4 of the -// redesign rebuilds the schedule editor against the new slim shape -// (cron + source-group multi-select + enabled), the source-group -// list/edit pages, and the repo-maintenance tab. Until then these -// routes return 501; the dashboard's host-row "View →" link is the -// only operator entry point that still works. +// ui_schedules.go — HTML form-driven schedule CRUD against the slim +// shape (cron + source-group multi-select + enabled). + +// hostSchedulesPage backs the list view. GroupNames maps source-group +// ID → name for the per-row tag rendering, populated once on load so +// the template doesn't need to do per-row store lookups. +type hostSchedulesPage struct { + hostChromeData + Schedules []store.Schedule + GroupNames map[string]string +} + +// scheduleFormData mirrors the form's wire shape — strings + bool for +// round-trip on validation re-render. +type scheduleFormData struct { + CronExpr string + Enabled bool +} + +// scheduleEditPage backs both the new and edit form views. +type scheduleEditPage struct { + hostChromeData + IsNew bool + ScheduleID string // empty when IsNew + Form scheduleFormData + AvailableGroups []store.SourceGroup + SelectedGroupIDs map[string]bool // gid → checked + SaveAction string + Error string +} func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedules: list", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedules: list groups", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + names := make(map[string]string, len(groups)) + for _, g := range groups { + names[g.ID] = g.Name + } + + chrome := s.loadHostChrome(r, *host, "schedules", "schedules") + chrome.ScheduleCount = len(scheds) + chrome.SourceGroupCount = len(groups) + + view := s.baseView(u) + view.Title = host.Name + " schedules · restic-manager" + view.Page = hostSchedulesPage{ + hostChromeData: chrome, + Schedules: scheds, + GroupNames: names, + } + if err := s.deps.UI.Render(w, "host_schedules", view); err != nil { + slog.Error("ui: render host_schedules", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } } func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedule new: list groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + view := s.baseView(u) + view.Title = "New schedule · " + host.Name + " · restic-manager" + view.Page = scheduleEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"), + IsNew: true, + Form: scheduleFormData{Enabled: true}, + AvailableGroups: groups, + SelectedGroupIDs: map[string]bool{}, + SaveAction: "/hosts/" + host.ID + "/schedules/new", + } + if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { + slog.Error("ui: render schedule_edit (new)", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } } func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + sid := chi.URLParam(r, "sid") + sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui schedule edit: get", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedule edit: list groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + selected := make(map[string]bool, len(sc.SourceGroupIDs)) + for _, gid := range sc.SourceGroupIDs { + selected[gid] = true + } + view := s.baseView(u) + view.Title = "Edit schedule · " + host.Name + " · restic-manager" + view.Page = scheduleEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"), + IsNew: false, + ScheduleID: sid, + Form: scheduleFormData{ + CronExpr: sc.CronExpr, + Enabled: sc.Enabled, + }, + AvailableGroups: groups, + SelectedGroupIDs: selected, + SaveAction: "/hosts/" + host.ID + "/schedules/" + sid + "/edit", + } + if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { + slog.Error("ui: render schedule_edit", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } } +// handleUIScheduleSave handles both create and update. On validation +// error, re-renders with input intact + a banner. func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + sid := chi.URLParam(r, "sid") + isNew := sid == "" + + form := scheduleFormData{ + CronExpr: strings.TrimSpace(r.PostForm.Get("cron")), + Enabled: r.PostForm.Get("enabled") == "1", + } + pickedIDs := r.PostForm["source_group_ids"] + selected := make(map[string]bool, len(pickedIDs)) + for _, gid := range pickedIDs { + selected[gid] = true + } + + // --- validation --- + var errMsg string + switch { + case form.CronExpr == "": + errMsg = "Cron expression is required." + case len(pickedIDs) == 0: + errMsg = "Pick at least one source group — a schedule has to know what to back up." + } + if errMsg == "" { + if _, err := cronParser.Parse(form.CronExpr); err != nil { + errMsg = "Cron didn't parse: " + err.Error() + } + } + // Verify every picked group belongs to this host. + if errMsg == "" { + for _, gid := range pickedIDs { + g, gerr := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid) + if gerr != nil || g == nil { + errMsg = "One of the picked source groups isn't on this host — refresh and try again." + break + } + } + } + + if errMsg != "" { + s.renderScheduleFormError(w, r, u, host, sid, isNew, form, selected, errMsg) + return + } + + sc := store.Schedule{ + ID: sid, + HostID: host.ID, + CronExpr: form.CronExpr, + Enabled: form.Enabled, + SourceGroupIDs: pickedIDs, + } + if isNew { + sc.ID = ulid.Make().String() + if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil { + slog.Error("ui schedule save: create", "err", err) + s.renderScheduleFormError(w, r, u, host, "", true, form, selected, + "Couldn't create — see the server log for details.") + return + } + } else { + if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil { + slog.Error("ui schedule save: update", "err", err) + s.renderScheduleFormError(w, r, u, host, sid, false, form, selected, + "Couldn't save — see the server log for details.") + return + } + } + s.pushScheduleSetAsync(host.ID) + + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther) } func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + sid := chi.URLParam(r, "sid") + if err := s.deps.Store.DeleteSchedule(r.Context(), host.ID, sid); err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui schedule delete", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + s.pushScheduleSetAsync(host.ID) + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther) } +// handleUIScheduleRun is the per-schedule Run-now action: dispatch +// every source group the schedule references in a single shot, +// reusing dispatchScheduledJob (the same path real cron fires take). +// HTMX only — falls back to a 405 for non-HTMX callers (per-group +// Run-now via the Sources tab is the JSON path). func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + if u := s.requireUIUser(w, r); u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + sid := chi.URLParam(r, "sid") + sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if len(sc.SourceGroupIDs) == 0 { + stdhttp.Error(w, "this schedule has no source groups attached", stdhttp.StatusConflict) + return + } + if s.deps.Hub == nil { + stdhttp.Error(w, "ws hub not configured", stdhttp.StatusServiceUnavailable) + return + } + conn := s.deps.Hub.Conn(host.ID) + if conn == nil { + stdhttp.Error(w, "host is offline — reconnect the agent and try again", + stdhttp.StatusConflict) + return + } + + // Manual Run-now ignores Enabled. "Disabled" only suppresses + // cron-tick firing; an ad-hoc one-off run is a separate intent + // (and the dispatch is audit-logged inside dispatchBackupForGroup). + // We dispatch inline rather than calling dispatchScheduledJob, + // which short-circuits on !Enabled. + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + now := time.Now().UTC() + type fired struct{ groupName, jobID string } + dispatched := make([]fired, 0, len(sc.SourceGroupIDs)) + for _, gid := range sc.SourceGroupIDs { + g, gerr := s.deps.Store.GetSourceGroup(ctx, host.ID, gid) + if gerr != nil { + slog.Warn("ui schedule run: load source group", + "host_id", host.ID, "schedule_id", sid, "group_id", gid, "err", gerr) + continue + } + jobID := s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now) + if jobID != "" { + dispatched = append(dispatched, fired{groupName: g.Name, jobID: jobID}) + } + } + + if wantsHTML(r) { + switch len(dispatched) { + case 0: + stdhttp.Error(w, "no backup jobs dispatched — see server log", stdhttp.StatusInternalServerError) + return + case 1: + // Single-group schedule: jump straight to the live job log, + // same UX as per-source-group Run-now from the Sources tab. + w.Header().Set("HX-Redirect", "/jobs/"+dispatched[0].jobID) + default: + // Multi-group: stay on the schedules tab and toast the + // summary. Direct the operator to one of the job logs via + // the toast (the most recent job ID is fine). + names := make([]string, 0, len(dispatched)) + for _, f := range dispatched { + names = append(names, f.groupName) + } + msg := strconv.Itoa(len(dispatched)) + " backups dispatched: " + strings.Join(names, ", ") + payload, _ := json.Marshal(map[string]any{ + "rm:toast": map[string]string{"level": "success", "message": msg}, + }) + w.Header().Set("HX-Trigger", string(payload)) + } + } + w.WriteHeader(stdhttp.StatusNoContent) +} + +func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, sid string, isNew bool, form scheduleFormData, selected map[string]bool, msg string) { + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedule re-render: list groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + saveAction := "/hosts/" + host.ID + "/schedules/new" + crumb := "new schedule" + if !isNew { + saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit" + crumb = "edit schedule" + } + view := s.baseView(u) + view.Title = "Schedule · " + host.Name + " · restic-manager" + view.Page = scheduleEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb), + IsNew: isNew, + ScheduleID: sid, + Form: form, + AvailableGroups: groups, + SelectedGroupIDs: selected, + SaveAction: saveAction, + Error: msg, + } + w.WriteHeader(stdhttp.StatusUnprocessableEntity) + if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { + slog.Error("ui: render schedule_edit (error)", "err", err) + } +} + +// loadHostForUI is a small helper shared across the host-detail tab +// handlers — fetches the host by URL param, writing the appropriate +// 404/500 + returning ok=false on failure. +func (s *Server) loadHostForUI(w stdhttp.ResponseWriter, r *stdhttp.Request) (*store.Host, bool) { + hostID := chi.URLParam(r, "id") + if hostID == "" { + stdhttp.NotFound(w, r) + return nil, false + } + host, err := s.deps.Store.GetHost(r.Context(), hostID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return nil, false + } + slog.Error("ui host tab: get host", "host_id", hostID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return nil, false + } + return host, true } diff --git a/internal/server/http/ui_sources.go b/internal/server/http/ui_sources.go new file mode 100644 index 0000000..c8ed59f --- /dev/null +++ b/internal/server/http/ui_sources.go @@ -0,0 +1,439 @@ +package http + +import ( + "errors" + "log/slog" + stdhttp "net/http" + "regexp" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// ui_sources.go — HTML form-driven source-group CRUD. Mounts at: +// GET /hosts/{id}/sources — list +// GET /hosts/{id}/sources/new — empty form +// POST /hosts/{id}/sources/new — create +// GET /hosts/{id}/sources/{gid}/edit — populated form +// POST /hosts/{id}/sources/{gid}/edit — update +// POST /hosts/{id}/sources/{gid}/delete — delete +// +// Per-group Run-now is handled by run_group.go's HTMX-aware +// /hosts/{id}/source-groups/{gid}/run handler. + +// hostSourcesPage backs the list view. Each row carries the group plus +// the cheap aggregates the row UI shows (used-by-N-schedules, +// snapshot count by tag). +type hostSourcesPage struct { + hostChromeData + Groups []sourceGroupRow +} + +type sourceGroupRow struct { + Group store.SourceGroup + UsedBy int + SnapshotCount int +} + +// sourceFormData carries form state across re-render-on-error. Keep +// keep-* fields as strings so an empty input round-trips as "" (not +// "0"), preserving the operator's intent. +type sourceFormData struct { + Name string + Includes string // newline-joined for the textarea + Excludes string // newline-joined for the textarea + KeepLast string + KeepHourly string + KeepDaily string + KeepWeekly string + KeepMonthly string + KeepYearly string + RetryMax int + RetryBackoffSeconds int + ConflictDimension string +} + +// sourceGroupEditPage backs both the new and edit form views. +type sourceGroupEditPage struct { + hostChromeData + IsNew bool + GroupID string // empty when IsNew + Form sourceFormData + SaveAction string + Error string +} + +// nameRE matches the same shape the wireframe + UI hint advertise: +// lowercase alnum, optional `_-`, no leading punctuation. Mirrors what +// works as a restic --tag. +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui sources: list groups", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + // Snapshot counts per tag — single fetch, then bucket by tag. + snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID) + if err != nil { + slog.Warn("ui sources: list snapshots", "host_id", host.ID, "err", err) + } + snapByTag := make(map[string]int, len(groups)) + for _, sn := range snaps { + for _, tag := range sn.Tags { + snapByTag[tag]++ + } + } + + rows := make([]sourceGroupRow, 0, len(groups)) + for _, g := range groups { + usedBy, lerr := s.deps.Store.SchedulesUsingGroup(r.Context(), g.ID) + if lerr != nil { + slog.Warn("ui sources: usage lookup", "group_id", g.ID, "err", lerr) + } + rows = append(rows, sourceGroupRow{ + Group: g, + UsedBy: len(usedBy), + SnapshotCount: snapByTag[g.Name], + }) + } + + chrome := s.loadHostChrome(r, *host, "sources", "sources") + // loadHostChrome already counted groups; reuse count we just got. + chrome.SourceGroupCount = len(groups) + + view := s.baseView(u) + view.Title = host.Name + " sources · restic-manager" + view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows} + if err := s.deps.UI.Render(w, "host_sources", view); err != nil { + slog.Error("ui: render host_sources", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +func (s *Server) handleUISourceGroupNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + view := s.baseView(u) + view.Title = "New source group · " + host.Name + " · restic-manager" + view.Page = sourceGroupEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"), + IsNew: true, + Form: sourceFormData{RetryMax: 3, RetryBackoffSeconds: 60}, + SaveAction: "/hosts/" + host.ID + "/sources/new", + } + if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil { + slog.Error("ui: render source_group_edit (new)", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + gid := chi.URLParam(r, "gid") + g, err := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui sources: get group", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + view := s.baseView(u) + view.Title = g.Name + " · " + host.Name + " · restic-manager" + view.Page = sourceGroupEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name), + IsNew: false, + GroupID: gid, + Form: formFromGroup(*g), + SaveAction: "/hosts/" + host.ID + "/sources/" + gid + "/edit", + } + if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil { + slog.Error("ui: render source_group_edit (edit)", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +// handleUISourceGroupSave handles both the create (gid empty) and the +// update (gid set) POST. Validates server-side; on error re-renders +// the form with the operator's typed input intact + a banner. On +// success, redirects back to the list. +func (s *Server) handleUISourceGroupSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + gid := chi.URLParam(r, "gid") + isNew := gid == "" + + form := parseSourceForm(r.PostForm) + + // --- validation --- + var errMsg string + switch { + case form.Name == "": + errMsg = "Name is required." + case !nameRE.MatchString(form.Name): + errMsg = "Name must be lowercase letters, digits, dashes, or underscores (and start with a letter or digit)." + } + keepLast, err := parseKeep(form.KeepLast) + if errMsg == "" && err != nil { + errMsg = "Keep last must be a non-negative whole number." + } + keepHourly, err := parseKeep(form.KeepHourly) + if errMsg == "" && err != nil { + errMsg = "Hourly must be a non-negative whole number." + } + keepDaily, err := parseKeep(form.KeepDaily) + if errMsg == "" && err != nil { + errMsg = "Daily must be a non-negative whole number." + } + keepWeekly, err := parseKeep(form.KeepWeekly) + if errMsg == "" && err != nil { + errMsg = "Weekly must be a non-negative whole number." + } + keepMonthly, err := parseKeep(form.KeepMonthly) + if errMsg == "" && err != nil { + errMsg = "Monthly must be a non-negative whole number." + } + keepYearly, err := parseKeep(form.KeepYearly) + if errMsg == "" && err != nil { + errMsg = "Yearly must be a non-negative whole number." + } + + // Name uniqueness (per host). On rename, exclude self. + if errMsg == "" { + if existing, gerr := s.deps.Store.GetSourceGroupByName(r.Context(), host.ID, form.Name); gerr == nil && existing != nil && existing.ID != gid { + errMsg = "A source group named \"" + form.Name + "\" already exists on this host." + } + } + + if errMsg != "" { + s.renderSourceFormError(w, r, u, host, gid, isNew, form, errMsg) + return + } + + g := store.SourceGroup{ + ID: gid, + HostID: host.ID, + Name: form.Name, + Includes: splitLines(form.Includes), + Excludes: splitLines(form.Excludes), + RetentionPolicy: store.RetentionPolicy{ + KeepLast: keepLast, KeepHourly: keepHourly, KeepDaily: keepDaily, + KeepWeekly: keepWeekly, KeepMonthly: keepMonthly, KeepYearly: keepYearly, + }, + RetryMax: form.RetryMax, + RetryBackoffSeconds: form.RetryBackoffSeconds, + } + + if isNew { + g.ID = ulid.Make().String() + if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil { + slog.Error("ui sources: create", "err", err) + s.renderSourceFormError(w, r, u, host, "", true, form, "Couldn't create — see the server log for details.") + return + } + } else { + if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil { + slog.Error("ui sources: update", "err", err) + s.renderSourceFormError(w, r, u, host, gid, false, form, "Couldn't save — see the server log for details.") + return + } + } + s.pushScheduleSetAsync(host.ID) + + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/sources", stdhttp.StatusSeeOther) +} + +func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + gid := chi.URLParam(r, "gid") + + using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), gid) + if err != nil { + slog.Error("ui sources: usage check", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if len(using) > 0 { + // Shouldn't happen via the UI (delete button is disabled when + // in use); guard anyway against form-replay / curl. + stdhttp.Error(w, "remove this group from its schedules first", stdhttp.StatusConflict) + return + } + + // Refuse to delete the host's last source group — every host + // needs at least one to be backup-able. UI disables the button + // in this case; this guards against form-replay / curl. + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui sources: count groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if len(groups) <= 1 { + stdhttp.Error(w, "this is the host's only source group — create another one first", stdhttp.StatusConflict) + return + } + + if err := s.deps.Store.DeleteSourceGroup(r.Context(), host.ID, gid); err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui sources: delete", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + s.pushScheduleSetAsync(host.ID) + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/sources", stdhttp.StatusSeeOther) +} + +// renderSourceFormError re-renders the edit form with the user's +// typed input intact + an error banner. Returns 422 to signal "form +// rejected" while still returning HTML (mirrors handleUIAddHostPost). +func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) { + view := s.baseView(u) + view.Title = "Source group · " + host.Name + " · restic-manager" + saveAction := "/hosts/" + host.ID + "/sources/new" + crumb := "new source group" + if !isNew { + saveAction = "/hosts/" + host.ID + "/sources/" + gid + "/edit" + crumb = form.Name + } + view.Page = sourceGroupEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "sources", crumb), + IsNew: isNew, + GroupID: gid, + Form: form, + SaveAction: saveAction, + Error: msg, + } + w.WriteHeader(stdhttp.StatusUnprocessableEntity) + if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil { + slog.Error("ui: render source_group_edit (error)", "err", err) + } +} + +// --- form parsing helpers --- + +func parseSourceForm(v map[string][]string) sourceFormData { + get := func(k string) string { return strings.TrimSpace(firstVal(v, k)) } + rmax, _ := strconv.Atoi(get("retry_max")) + rback, _ := strconv.Atoi(get("retry_backoff_seconds")) + return sourceFormData{ + Name: get("name"), + Includes: firstVal(v, "includes"), // textarea — preserve internal whitespace + Excludes: firstVal(v, "excludes"), + KeepLast: get("keep_last"), + KeepHourly: get("keep_hourly"), + KeepDaily: get("keep_daily"), + KeepWeekly: get("keep_weekly"), + KeepMonthly: get("keep_monthly"), + KeepYearly: get("keep_yearly"), + RetryMax: rmax, + RetryBackoffSeconds: rback, + } +} + +func firstVal(v map[string][]string, k string) string { + if vs, ok := v[k]; ok && len(vs) > 0 { + return vs[0] + } + return "" +} + +// parseKeep maps an empty string → nil pointer (no constraint), +// "0" / "N" → *int. Negative or non-numeric → error. +func parseKeep(s string) (*int, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return nil, errors.New("invalid") + } + return &n, nil +} + +func splitLines(s string) []string { + out := []string{} + for _, line := range strings.Split(s, "\n") { + if p := strings.TrimSpace(line); p != "" { + out = append(out, p) + } + } + return out +} + +func formFromGroup(g store.SourceGroup) sourceFormData { + keep := func(p *int) string { + if p == nil { + return "" + } + return strconv.Itoa(*p) + } + return sourceFormData{ + Name: g.Name, + Includes: strings.Join(g.Includes, "\n"), + Excludes: strings.Join(g.Excludes, "\n"), + KeepLast: keep(g.RetentionPolicy.KeepLast), + KeepHourly: keep(g.RetentionPolicy.KeepHourly), + KeepDaily: keep(g.RetentionPolicy.KeepDaily), + KeepWeekly: keep(g.RetentionPolicy.KeepWeekly), + KeepMonthly: keep(g.RetentionPolicy.KeepMonthly), + KeepYearly: keep(g.RetentionPolicy.KeepYearly), + RetryMax: g.RetryMax, + RetryBackoffSeconds: g.RetryBackoffSeconds, + ConflictDimension: g.ConflictDimension, + } +} diff --git a/internal/server/ui/funcs.go b/internal/server/ui/funcs.go index e1bf86f..71c350a 100644 --- a/internal/server/ui/funcs.go +++ b/internal/server/ui/funcs.go @@ -13,10 +13,10 @@ import ( // which can pre-compute and pass primitives into the view. func funcMap() template.FuncMap { return template.FuncMap{ - "bytes": formatBytes, - "relTime": formatRelTime, - "comma": formatComma, - "deref": derefStr, + "bytes": formatBytes, + "relTime": formatRelTime, + "comma": formatComma, + "deref": derefStr, "timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() }, "joinDot": func(parts []string) string { return strings.Join(parts, " · ") }, "absTime": func(t time.Time) string { diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index 04ab397..905fe7f 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -91,6 +91,7 @@ func New() (*Renderer, error) { "templates/partials/host_row.html", "templates/partials/toast.html", "templates/partials/awaiting_agent.html", + "templates/partials/host_chrome.html", } pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") diff --git a/internal/server/ws/handler.go b/internal/server/ws/handler.go index 25db3e3..48fb5fd 100644 --- a/internal/server/ws/handler.go +++ b/internal/server/ws/handler.go @@ -42,14 +42,14 @@ type HandlerDeps struct { // enrollment) before the WS upgrade. // // Lifecycle: -// 1. Bearer token resolves to a Host row. -// 2. Upgrade. -// 3. First message must be `hello`; protocol_version checked here. -// 4. Loop: read messages, dispatch by type. Heartbeats touch the -// host row; job/log/repo messages forward to the relevant -// handlers (TODO: lands with P1-18 onward). -// 5. On Read error or context cancel, mark host offline, unregister -// from the hub. +// 1. Bearer token resolves to a Host row. +// 2. Upgrade. +// 3. First message must be `hello`; protocol_version checked here. +// 4. Loop: read messages, dispatch by type. Heartbeats touch the +// host row; job/log/repo messages forward to the relevant +// handlers (TODO: lands with P1-18 onward). +// 5. On Read error or context cancel, mark host offline, unregister +// from the hub. func AgentHandler(deps HandlerDeps) stdhttp.Handler { return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { host, ok := authenticateAgent(r, deps.Store) @@ -204,7 +204,7 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil { slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err) } - // repo_initialised_at projection has been removed — auto-init + // repo_initialized_at projection has been removed — auto-init // at host enrolment makes "is the repo init'd" derivable from // the latest init job's status, no separate column needed. if deps.JobHub != nil { diff --git a/internal/server/ws/hub.go b/internal/server/ws/hub.go index c10a85d..8ad732f 100644 --- a/internal/server/ws/hub.go +++ b/internal/server/ws/hub.go @@ -100,7 +100,7 @@ func NewConn(hostID string, c *websocket.Conn) *Conn { } // Send writes an envelope as a JSON text message. Concurrent calls -// are serialised; the underlying socket is not safe for parallel +// are serialized; the underlying socket is not safe for parallel // writers. func (c *Conn) Send(ctx context.Context, env api.Envelope) error { c.writeMu.Lock() diff --git a/internal/server/ws/hub_test.go b/internal/server/ws/hub_test.go index ed8b04f..3753cc2 100644 --- a/internal/server/ws/hub_test.go +++ b/internal/server/ws/hub_test.go @@ -47,7 +47,7 @@ func setupTestHub(t *testing.T) (url string, token string, hostID string, st *st t.Fatalf("enroll: %v", err) } url = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent" - return + return url, token, hostID, st, hub } func TestWSHelloAndHeartbeat(t *testing.T) { @@ -57,13 +57,18 @@ func TestWSHelloAndHeartbeat(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, }) if err != nil { t.Fatalf("dial: %v", err) } defer c.CloseNow() + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() // Send hello. hello := api.HelloPayload{ @@ -125,13 +130,18 @@ func TestWSRejectsOldProtocol(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, }) if err != nil { t.Fatalf("dial: %v", err) } defer c.CloseNow() + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() hello := api.HelloPayload{ProtocolVersion: 0} // below minimum env, _ := api.Marshal(api.MsgHello, "", hello) @@ -170,6 +180,13 @@ func TestWSRejectsBadToken(t *testing.T) { _, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer wrong"}}, }) + if res != nil { + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() + } if err == nil { t.Fatal("dial should fail") } diff --git a/internal/server/ws/jobhub.go b/internal/server/ws/jobhub.go index e509193..b6c138f 100644 --- a/internal/server/ws/jobhub.go +++ b/internal/server/ws/jobhub.go @@ -33,7 +33,7 @@ func NewJobHub() *JobHub { // the hub's set (so concurrent Broadcasts will reach it), but no // pump goroutine runs yet. The caller can prime the channel via Send // — useful for late-subscriber catch-up — and then call Run to start -// the pump. Run blocks until ctx is cancelled or conn dies, and +// the pump. Run blocks until ctx is canceled or conn dies, and // unregisters on return. type Subscriber struct { hub *JobHub @@ -73,7 +73,7 @@ func (s *Subscriber) Send(env api.Envelope) { } // Run pumps messages from the subscriber's channel onto conn until -// ctx is cancelled or conn dies. Unregisters on return. Caller is +// ctx is canceled or conn dies. Unregisters on return. Caller is // expected to invoke this from the goroutine that owns conn. func (s *Subscriber) Run(ctx context.Context, conn *Conn) { defer s.unregister() diff --git a/internal/store/audit.go b/internal/store/audit.go index b5cce69..bd82289 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -26,7 +26,7 @@ func (s *Store) AppendAudit(ctx context.Context, e AuditEntry) error { } // nullable returns nil for nil/empty *string so SQLite stores NULL. -// SQLite's driver treats Go nil as NULL but treats *string("") as ''. +// SQLite's driver treats Go nil as NULL but treats *string("") as ”. // We want NULL semantics for "absent." func nullable(p *string) any { if p == nil || *p == "" { diff --git a/internal/store/enrollment.go b/internal/store/enrollment.go index 4a2a0ff..3ad5cea 100644 --- a/internal/store/enrollment.go +++ b/internal/store/enrollment.go @@ -172,4 +172,3 @@ func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) n, _ := res.RowsAffected() return n, nil } - diff --git a/internal/store/fleet.go b/internal/store/fleet.go index 95307c4..95d25c0 100644 --- a/internal/store/fleet.go +++ b/internal/store/fleet.go @@ -57,7 +57,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) { if err != nil { return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var status string var n int @@ -70,7 +70,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) { fs.JobsLast24hSucceeded = n case "failed": fs.JobsLast24hFailed = n - case "cancelled": + case "cancelled": //nolint:misspell // matches the DB CHECK constraint and api.JobCancelled wire value fs.JobsLast24hCancelled = n } } diff --git a/internal/store/hosts.go b/internal/store/hosts.go index af7df00..bd6a24d 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -121,7 +121,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) { if err != nil { return nil, fmt.Errorf("store: list hosts: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []Host for rows.Next() { h, err := scanHostRow(rows) @@ -150,11 +150,11 @@ func scanHost(row *sql.Row) (*Host, error) { func scanHostRow(s hostScanner) (*Host, error) { var h Host var ( - lastSeen, lastBackupAt sql.NullString - repoID, currentJob, lastBkSt sql.NullString - enrolled string - tags string - bwUp, bwDown sql.NullInt64 + lastSeen, lastBackupAt sql.NullString + repoID, currentJob, lastBkSt sql.NullString + enrolled string + tags string + bwUp, bwDown sql.NullInt64 ) err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, diff --git a/internal/store/jobs.go b/internal/store/jobs.go index 6e2d704..9438902 100644 --- a/internal/store/jobs.go +++ b/internal/store/jobs.go @@ -118,7 +118,7 @@ func (s *Store) ListJobLogs(ctx context.Context, jobID string, afterSeq int64, l if err != nil { return nil, fmt.Errorf("store: list job logs: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []JobLogLine for rows.Next() { var l JobLogLine @@ -143,15 +143,15 @@ func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) { started_at, finished_at, exit_code, stats, error, created_at FROM jobs WHERE id = ?`, id) var ( - j Job - schedID sql.NullString - actorID sql.NullString - startedAt sql.NullString - finishedAt sql.NullString - exitCode sql.NullInt64 - stats sql.NullString - errMsg sql.NullString - createdAt string + j Job + schedID sql.NullString + actorID sql.NullString + startedAt sql.NullString + finishedAt sql.NullString + exitCode sql.NullInt64 + stats sql.NullString + errMsg sql.NullString + createdAt string ) if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID, &j.ActorKind, &actorID, &startedAt, &finishedAt, diff --git a/internal/store/maintenance.go b/internal/store/maintenance.go index 624af95..795a6b0 100644 --- a/internal/store/maintenance.go +++ b/internal/store/maintenance.go @@ -31,7 +31,7 @@ func (st *Store) GetRepoMaintenance(ctx context.Context, hostID string) (*HostRe check_cron, check_enabled, check_subset_pct FROM host_repo_maintenance WHERE host_id = ?`, hostID) var ( - m HostRepoMaintenance + m HostRepoMaintenance forgetEnabled, pruneEnabled, checkEnabled int ) err := row.Scan(&m.HostID, diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 0fd80bb..7f81642 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -15,9 +15,9 @@ var migrationsFS embed.FS // migration is one ordered SQL file from migrations/. type migration struct { - version int // parsed from filename prefix (0001, 0002, …) - name string // full filename, for error messages - sql string + version int // parsed from filename prefix (0001, 0002, …) + name string // full filename, for error messages + sql string } // loadMigrations reads every migrations/*.sql file in lexical order diff --git a/internal/store/pending.go b/internal/store/pending.go index eea9b84..42f2fdd 100644 --- a/internal/store/pending.go +++ b/internal/store/pending.go @@ -52,7 +52,7 @@ func (st *Store) DuePendingRuns(ctx context.Context, now time.Time, limit int) ( if err != nil { return nil, fmt.Errorf("store: due pending runs: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []PendingRun{} for rows.Next() { var p PendingRun diff --git a/internal/store/schedules.go b/internal/store/schedules.go index f80bd4f..97f3186 100644 --- a/internal/store/schedules.go +++ b/internal/store/schedules.go @@ -144,7 +144,7 @@ func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Sche if err != nil { return nil, fmt.Errorf("store: list schedules: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []Schedule{} for rows.Next() { s, err := scanScheduleRow(rows) @@ -247,7 +247,7 @@ func (st *Store) scheduleGroupIDs(ctx context.Context, scheduleID string) ([]str if err != nil { return nil, fmt.Errorf("store: read schedule junction: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []string{} for rows.Next() { var id string @@ -269,7 +269,7 @@ func (st *Store) SchedulesUsingGroup(ctx context.Context, groupID string) ([]str if err != nil { return nil, fmt.Errorf("store: schedules using group: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []string{} for rows.Next() { var id string diff --git a/internal/store/snapshots.go b/internal/store/snapshots.go index e01de90..9995830 100644 --- a/internal/store/snapshots.go +++ b/internal/store/snapshots.go @@ -51,7 +51,7 @@ func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps [ if err != nil { return fmt.Errorf("store: prepare snapshot insert: %w", err) } - defer stmt.Close() + defer func() { _ = stmt.Close() }() refreshed := when.UTC().Format(time.RFC3339Nano) for _, snap := range snaps { @@ -92,7 +92,7 @@ func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snaps if err != nil { return nil, fmt.Errorf("store: list snapshots: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []Snapshot for rows.Next() { diff --git a/internal/store/snapshots_test.go b/internal/store/snapshots_test.go index 2870460..2abc9a3 100644 --- a/internal/store/snapshots_test.go +++ b/internal/store/snapshots_test.go @@ -30,20 +30,20 @@ func TestReplaceHostSnapshotsRoundTrip(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) in := []Snapshot{ { - ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000", - ShortID: "deadbeef", - Time: now.Add(-2 * time.Hour), - Hostname: "snap-host", - Paths: []string{"/etc", "/home"}, - Tags: []string{"daily"}, + ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000", + ShortID: "deadbeef", + Time: now.Add(-2 * time.Hour), + Hostname: "snap-host", + Paths: []string{"/etc", "/home"}, + Tags: []string{"daily"}, SizeBytes: 4096, FileCount: 12, }, { - ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000", - ShortID: "cafef00d", - Time: now.Add(-1 * time.Hour), - Hostname: "snap-host", - Paths: []string{"/etc"}, + ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000", + ShortID: "cafef00d", + Time: now.Add(-1 * time.Hour), + Hostname: "snap-host", + Paths: []string{"/etc"}, SizeBytes: 8192, FileCount: 24, }, } @@ -129,9 +129,11 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) { // First a non-empty replace. if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{ - {ID: "1111111111111111111111111111111111111111111111111111111111111111", + { + ID: "1111111111111111111111111111111111111111111111111111111111111111", ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host", - Paths: []string{"/x"}}, + Paths: []string{"/x"}, + }, }, time.Now().UTC()); err != nil { t.Fatalf("replace 1: %v", err) } diff --git a/internal/store/sources.go b/internal/store/sources.go index 7c81b57..6ec3115 100644 --- a/internal/store/sources.go +++ b/internal/store/sources.go @@ -183,7 +183,7 @@ func (st *Store) ListSourceGroupsByHost(ctx context.Context, hostID string) ([]S if err != nil { return nil, fmt.Errorf("store: list source groups: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []SourceGroup{} for rows.Next() { g, err := scanSourceGroupRow(rows) @@ -220,10 +220,10 @@ type sourceGroupScanner interface { func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) { var ( - out SourceGroup - includes, excludes, retention string - conflict sql.NullString - createdAt, updatedAt string + out SourceGroup + includes, excludes, retention string + conflict sql.NullString + createdAt, updatedAt string ) err := s.Scan(&out.ID, &out.HostID, &out.Name, &includes, &excludes, &retention, diff --git a/internal/store/sources_test.go b/internal/store/sources_test.go index bc56013..2f6d7bb 100644 --- a/internal/store/sources_test.go +++ b/internal/store/sources_test.go @@ -177,7 +177,7 @@ func TestPendingRunQueue(t *testing.T) { now := time.Now().UTC() if err := s.EnqueuePendingRun(ctx, &PendingRun{ - ID: "01HPEND00000000000000001", + ID: "01HPEND00000000000000001", ScheduleID: schedID, SourceGroupID: gid, HostID: hostID, NextAttemptAt: now.Add(-time.Second), // already due ScheduledAt: now.Add(-time.Minute), diff --git a/internal/store/store_test.go b/internal/store/store_test.go index c34a7e2..87aff9b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -34,10 +34,12 @@ func TestOpenAppliesMigrations(t *testing.T) { } // Spot-check a few tables exist with expected columns. - tables := []string{"users", "sessions", "hosts", "repos", + tables := []string{ + "users", "sessions", "hosts", "repos", "credentials", "schedules", "jobs", "job_logs", "snapshots", "alerts", "audit_log", - "enrollment_tokens", "host_schedule_version"} + "enrollment_tokens", "host_schedule_version", + } for _, tbl := range tables { row := s.DB().QueryRow( `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl) diff --git a/internal/store/types.go b/internal/store/types.go index e799f09..6f99f69 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -20,6 +20,7 @@ type User struct { // Role enumerates the access tiers from spec.md §7.2. type Role string +// Defined Role values, in descending order of privilege. const ( RoleAdmin Role = "admin" RoleOperator Role = "operator" @@ -73,12 +74,12 @@ type Host struct { // only. forget/prune/check are repo-level cadences on // HostRepoMaintenance, not schedule kinds. type Schedule struct { - ID string - HostID string - CronExpr string - Enabled bool - CreatedAt time.Time - UpdatedAt time.Time + ID string + HostID string + CronExpr string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time // SourceGroupIDs is populated by ListSchedulesByHost (joins // schedule_source_groups) and accepted on Create / Update so the // caller passes the desired junction state in one shape. @@ -160,14 +161,14 @@ type HostRepoMaintenance struct { // PendingRun queues a missed cron tick (agent was offline) for the // server-side retry ticker to dispatch later. type PendingRun struct { - ID string - ScheduleID string - SourceGroupID string - HostID string - Attempt int - NextAttemptAt time.Time - ScheduledAt time.Time // original cron tick — forensic / audit - LastError string + ID string + ScheduleID string + SourceGroupID string + HostID string + Attempt int + NextAttemptAt time.Time + ScheduledAt time.Time // original cron tick — forensic / audit + LastError string } // EnrollmentToken is the issuer's view of a one-time token. diff --git a/tasks.md b/tasks.md index 318f287..df03958 100644 --- a/tasks.md +++ b/tasks.md @@ -142,16 +142,28 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - **Auto-init at enrolment**: server dispatches `restic init` on first WS connect (was P2-old "Init repo" button — now invisible to the operator). On success: emit a normal job row with `kind=init` so the audit trail still shows it. On `init` returning "config file already exists" (e.g. re-enrolment against an existing repo): treat as soft success per existing restic-wrapper behaviour. - **Tests**: rewrite the deleted `schedules_test.go` and `schedule_push_test.go` against new endpoints; new `source_groups_test.go`, `repo_maintenance_test.go`, `auto_init_test.go`. End-to-end: enrol → server pushes creds → server dispatches init → agent runs it → schedule reconcile fires → operator hits per-source-group Run-now → backup runs → snapshots refresh. -### P2 redesign — Phase 4 (UI rewire, against v4 wireframes) — TODO +### P2 redesign — Phase 4 (UI rewire, against v4 wireframes) ✅ -- [ ] **P2R-02** (L) UI templates rebuilt against the new model: - - `/hosts/{id}/sources` — list of source groups with per-row meta (includes/excludes count, retention summary via `RetentionPolicy.Summary()`, usage = which schedules reference this group, snapshot count for `tag = group.name`). Run-now / Edit / Delete actions per row. - - `/hosts/{id}/sources/{gid}/edit` (and `/sources/new`) — name (= snapshot tag), includes/excludes textareas, retention as a 3×2 keep-* grid, retry-on-offline, inline conflict banner above retention when granularity ↔ cadence mismatch detected (uses `SourceGroup.conflict_dimension` cache). - - `/hosts/{id}/schedules` — slim list (status / cron / source-tags / actions) plus new-schedule form (cron with quick-pick chips, source-group multi-select via clickable check pickers, enabled toggle). - - `/hosts/{id}/repo` — connection (URL/user/password — pre-filled from `GET /api/hosts/{id}/repo-credentials` redacted view; cert pin), bandwidth caps (host-wide), maintenance rows (forget/prune/check cadence + check subset %), danger-zone re-init. - - **Re-enable the four host-detail sub-tabs** (Snapshots is already live; Schedules / Sources / Repo become real links again; Settings stays inert until later). Drop the stop-gap inert-div hack from P2R-00.4. - - **Per-source-group Run-now buttons** replace today's per-host `Run backup now` buttons (right-rail + dashboard row + empty-snapshots state). Dashboard row's Run-now becomes either "Run all groups" (if exactly one schedule covers all groups) or "Open →" (multi-group hosts). - - Header "version N · agent in sync / agent at vM" indicator preserved (still backed by `host_schedule_version` + `applied_schedule_version`). +> **Row-design rule (binding for every list-row template in this app, current and future):** +> Whole-row click navigates to the row's primary detail/edit page — +> mirror `.host-row.clickable` on the dashboard +> (`partials/host_row.html`): an absolute-positioned `.row-link` +> overlay with `text-indent: -9999px` covers the row, action buttons +> live in `.row-action` cells that sit above via z-index. **Do not +> add an explicit "Edit" button** when the row is clickable — it +> duplicates the affordance and dilutes the click target. Action +> cells are reserved for verbs that aren't "open this row" (Run-now, +> Delete, Pause, etc). + +- [x] **P2R-02** (L) UI templates rebuilt against the new model: + - **Slice 1 ✅** Sub-tab navigation skeleton — extract header/vitals/sub-tabs into a `host_chrome` partial; Sources / Schedules / Repo become real `` links; placeholder pages share the chrome; version indicator restored. (commit `a535822`) + - **Slice 2 ✅** Sources tab — `/hosts/{id}/sources` list with per-row meta + clickable rows + per-group Run-now/Delete; `/sources/new` and `/sources/{gid}/edit` form (name, includes/excludes, 3×2 keep-* grid, retry-on-offline, inline conflict banner from `ConflictDimension` cache); validation re-renders form with input intact; refuses to delete a host's last source group. (commits `0ed9c3d`, `dede74f`) + - **Slice 3 ✅** Schedules tab — `/hosts/{id}/schedules` slim list (status / cron / source-tags / actions, clickable rows) plus `/schedules/new` and `/schedules/{sid}/edit` form (cron with five quick-pick chips that have human-readable tooltips, source-group multi-pick as styled check cards, enabled toggle); per-schedule Run-now reuses `dispatchScheduledJob` for enabled schedules + bypasses the enabled check (with a HX-confirm) for paused ones; multi-group fires emit a success toast, single-group fires HX-Redirect to the live job log. (commit `67ca769` + follow-ups `64d2fcf`, `8b91d30`, `4035c44`) + - **Slice 4 ✅** `/hosts/{id}/repo` — three independent forms (connection: URL/user/password pre-filled from `GET /api/hosts/{id}/repo-credentials` redacted view; bandwidth: host-wide caps via new `PUT /api/hosts/{id}/bandwidth`; maintenance: forget/prune/check cadences + check subset %); danger-zone re-init button rendered + disabled (real flow lands in P2R-09); right-rail snapshots-by-tag breakdown. (commit `d62b173`) + - **Slice 5 ✅** Dashboard row Run-now uses the single covering schedule when one exists ("Run all groups" primary button), otherwise falls back to "Open →" pointing at the Sources tab. Right-rail and empty-snapshots-state Run-now were rehomed to source-group context in slice 1. (commit `fab99b4`) + - **Slice 6 ✅** Playwright sweep against the live `:8080` server — login → walk every new tab → create source group → create schedule → Run-now → confirm a snapshot landed → end-to-end clean, no console errors. Screenshots in `_diag/p2r-02-sweep/`. + - Side-fix: agent runner drops noisy restic `status` events from `log.stream` (they were drowning the live log on short backups; the throttled `job.progress` envelope already covers the same data). (commit `ffba737`) + - Header "version N · agent in sync / agent at vM" indicator preserved across all tabs (backed by `host_schedule_version` + `applied_schedule_version`). - Form validation re-renders with the operator's typed input intact (mirror P2-04's behaviour). Each save fires `pushScheduleSetAsync` so an online agent re-arms within seconds. ### P2 redesign — Phase 5 (server-side maintenance ticker) — TODO diff --git a/web/embed.go b/web/embed.go index 43d63dc..44dc2e3 100644 --- a/web/embed.go +++ b/web/embed.go @@ -7,5 +7,9 @@ package web import "embed" +// FS is the embedded view of every template + static asset under +// this package. Consumed by internal/server/ui (templates) and +// internal/server/http (static handler). +// //go:embed templates/* static/* var FS embed.FS diff --git a/web/static/css/styles.css b/web/static/css/styles.css index e0d47dc..d8105fa 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-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 +/*! 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}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.schd-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;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-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.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-0\.5{margin-top:.125rem}.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}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.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-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.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-1{grid-template-columns:repeat(1,minmax(0,1fr))}.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-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}.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-l{border-left-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}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.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-3\.5{padding-left:.875rem;padding-right:.875rem}.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-1{padding-bottom:.25rem;padding-top:.25rem}.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-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.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-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[20px\]{font-size:20px}.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}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.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}.leading-none{line-height:1}.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)} diff --git a/web/styles/input.css b/web/styles/input.css index 3c08daf..565c5e2 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -186,6 +186,112 @@ .host-row.clickable > .row-link { pointer-events: auto; } .host-row.clickable > .row-action { pointer-events: auto; } + /* ---------- source-group rows (Sources tab) ---------- */ + .src-row { + display: grid; align-items: center; + grid-template-columns: 1fr auto; + column-gap: 18px; + padding: 14px 18px; + } + /* Whole-row click → edit page, mirroring .host-row.clickable on the + dashboard. Action cells sit above via z-index so their buttons + keep working. */ + .src-row.clickable { position: relative; } + .src-row.clickable .row-link { + position: absolute; inset: 0; z-index: 0; + text-indent: -9999px; overflow: hidden; + } + .src-row.clickable:hover { background: var(--panel-hi); cursor: pointer; } + .src-row.clickable > * { position: relative; z-index: 1; pointer-events: none; } + .src-row.clickable > .row-link { pointer-events: auto; } + .src-row.clickable > .row-action { pointer-events: auto; } + + /* ---------- schedule rows (Schedules tab) ---------- */ + .schd-row { + display: grid; align-items: center; + grid-template-columns: 90px 1fr 2fr auto; + column-gap: 18px; + padding: 12px 18px; font-size: 13px; + } + .schd-row.head { + padding-top: 10px; padding-bottom: 10px; + font-size: 11px; color: var(--ink-fade); + text-transform: uppercase; letter-spacing: 0.08em; + } + /* Whole-row click → edit page (matches .host-row.clickable). */ + .schd-row.clickable { position: relative; } + .schd-row.clickable .row-link { + position: absolute; inset: 0; z-index: 0; + text-indent: -9999px; overflow: hidden; + } + .schd-row.clickable:hover { background: var(--panel-hi); cursor: pointer; } + .schd-row.clickable > * { position: relative; z-index: 1; pointer-events: none; } + .schd-row.clickable > .row-link { pointer-events: auto; } + .schd-row.clickable > .row-action { pointer-events: auto; } + + /* ---------- cron preset chips ---------- */ + .preset-chip { + font-family: 'JetBrains Mono', monospace; font-size: 11.5px; + padding: 4px 9px; border-radius: 4px; + border: 1px solid var(--line-soft); color: var(--ink-mid); + background: var(--bg); + cursor: pointer; user-select: none; + transition: border-color 100ms ease, color 100ms ease; + } + .preset-chip:hover { border-color: var(--accent); color: var(--ink); } + + /* ---------- source-group picker (Schedule new/edit) ---------- */ + .picker { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--line-soft); + border-radius: 5px; + font-size: 13px; cursor: pointer; + transition: border-color 100ms ease, background 100ms ease; + } + .picker:hover { border-color: var(--ink-mute); } + .picker .check { + display: inline-block; width: 14px; height: 14px; + border: 1px solid var(--line); border-radius: 3px; + flex-shrink: 0; position: relative; + } + .picker.checked { + border-color: color-mix(in oklch, var(--accent), transparent 50%); + background: color-mix(in oklch, var(--accent), transparent 92%); + } + .picker.checked .check { + background: var(--accent); border-color: var(--accent); + } + .picker.checked .check::after { + content: ""; position: absolute; + left: 4px; top: 1px; width: 4px; height: 8px; + border: solid oklch(0.18 0.01 195); + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg); + } + .picker input[type="checkbox"] { + position: absolute; opacity: 0; pointer-events: none; + } + + /* ---------- retention 3×2 keep-* grid (source-group edit) ---------- */ + .keep-cell { + background: var(--bg); + border: 1px solid var(--line-soft); + border-radius: 5px; + padding: 9px 11px; + display: flex; flex-direction: column; gap: 4px; + } + .keep-cell label { + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-fade); + } + .keep-cell input { + background: transparent; border: none; outline: none; + font-family: 'JetBrains Mono', monospace; font-size: 14px; + color: var(--ink); padding: 0; width: 100%; + } + /* ---------- log viewer ---------- */ .log { background: var(--bg); border: 1px solid var(--line-soft); diff --git a/web/templates/pages/host_detail.html b/web/templates/pages/host_detail.html index e85b2b8..1ccf63b 100644 --- a/web/templates/pages/host_detail.html +++ b/web/templates/pages/host_detail.html @@ -1,93 +1,13 @@ {{define "title"}}{{.Title}}{{end}} {{define "content"}} +{{template "host_chrome" .}} {{$page := .Page}} {{$host := $page.Host}} -
- -
Dashboard/{{$host.Name}}
- - {{/* ---------- header ---------- */}} -
-
-
- {{if eq $host.Status "online"}} - - {{else if eq $host.Status "degraded"}} - - {{else if eq $host.Status "offline"}} - - {{else}} - - {{end}} -

{{$host.Name}}

-
{{range $host.Tags}}{{.}}{{end}}
-
-
- {{$host.OS}}/{{$host.Arch}} - · - agent {{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}} - · - restic {{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}} - · - {{if eq $host.Status "offline"}} - last seen {{relTime $host.LastSeenAt}} - {{else}} - online · last heartbeat {{relTime $host.LastSeenAt}} - {{end}} -
-
-
- - - -
-
- - {{/* ---------- vitals strip ---------- */}} -
-
-
Last backup
-
- {{if eq (deref $host.LastBackupStatus) "succeeded"}} - succeeded - {{else if eq (deref $host.LastBackupStatus) "failed"}} - failed - {{else if eq (deref $host.LastBackupStatus) "cancelled"}} - cancelled - {{else}} - never run - {{end}} - {{if $host.LastBackupAt}} · {{relTime $host.LastBackupAt}}{{end}} -
-
-
-
Repo size
-
{{bytes $host.RepoSizeBytes}}
-
-
-
Snapshots
-
{{comma $host.SnapshotCount}}
-
-
-
Open alerts
-
- {{if eq $host.OpenAlertCount 0}}0 · all clear{{else}}{{$host.OpenAlertCount}} · review →{{end}} -
-
-
- - {{/* ---------- secondary tabs ---------- */}} -
- Snapshots {{comma $host.SnapshotCount}} -
Schedules
-
Jobs
-
Repo
-
Settings
-
+
{{/* ---------- snapshots tab ---------- */}} -
+
@@ -106,7 +26,7 @@ Once a backup completes, the agent will refresh this list automatically.

- + Open Sources →
{{else}} @@ -150,13 +70,10 @@
Run-now
-
- - - - - -
+

+ Run-now lives on individual source groups now — + open Sources → +

diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html new file mode 100644 index 0000000..5580e7f --- /dev/null +++ b/web/templates/pages/host_repo.html @@ -0,0 +1,210 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +
+ +
+ + {{/* ---------- Connection ---------- */}} +

Connection

+
+ {{if $page.CredentialsError}} +
+ {{$page.CredentialsError}} +
+ {{end}} + {{if eq $page.SavedSection "credentials"}} +
✓ saved
+ {{end}} +
+
+ + +
e.g. rest:http://192.168.0.99:8000/{{$host.Name}}/
+
+
+ + +
Sent as the rest-server --htpasswd user.
+
+
+ + +
Stored AEAD-encrypted; pushed to the agent over WS. Leave blank to keep the existing password.
+
+
+
+ +
+
+ + {{/* ---------- Bandwidth ---------- */}} +

Bandwidth · host-wide

+
+ {{if $page.BandwidthError}} +
+ {{$page.BandwidthError}} +
+ {{end}} + {{if eq $page.SavedSection "bandwidth"}} +
✓ saved
+ {{end}} +
+
+ + +
+
+ + +
+
+
+ Applies to every backup, restore, and prune job for this host. Maps to restic --limit-upload / --limit-download. +
+
+ +
+
+ + {{/* ---------- Maintenance ---------- */}} +

Maintenance · server-side cadences

+
+ {{if $page.MaintenanceError}} +
+ {{$page.MaintenanceError}} +
+ {{end}} + {{if eq $page.SavedSection "maintenance"}} +
✓ saved
+ {{end}} + + {{$m := $page.Maintenance}} +
+
Verb
+
Cron cadence
+
Notes
+
Enabled
+
+ +
+
forget
+
+
Per source group, using each group's retention policy.
+
+ +
+
+ +
+
prune
+
+
Reclaims storage made dead by forget. Heavy — weekly only.
+
+ +
+
+ +
+
check
+
+
+ --read-data-subset + % +
+
+ +
+
+ +
+ + Server-side ticker drives execution — independent of the agent's cron. +
+
+ + {{/* ---------- Danger zone ---------- */}} +

Danger zone

+
+
+
+
Re-initialise repo
+

+ Tries to DELETE the rest-server's copy of this repo, then runs + restic init against the empty path. Most rest-server setups run with + --append-only and refuse the DELETE — the future P2R-09 flow surfaces + guided cleanup steps in that case. +

+

+ All snapshots are lost; this host's schedule version stays the same and the agent's + secrets.enc is reused. +

+
+ +
+
+
+ + {{/* ---------- right rail ---------- */}} + + +
+{{end}} diff --git a/web/templates/pages/host_schedules.html b/web/templates/pages/host_schedules.html new file mode 100644 index 0000000..764ae2d --- /dev/null +++ b/web/templates/pages/host_schedules.html @@ -0,0 +1,84 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +{{$groupNames := $page.GroupNames}} +
+ +
+

+ A schedule is a cron expression pointing at one or more source groups. When it fires, the agent runs a separate + restic backup per chosen group — independent jobs, independent snapshots, + independent retention. Failure of one group doesn't fail the others. +

+ + New schedule +
+ + {{if eq (len $page.Schedules) 0}} +
+

No schedules yet.

+

+ Add one and the agent will start running backups on whatever cron expression you give it. + Until then, Run-now from the Sources tab is the only way to trigger a backup. +

+ +
+ {{else}} +
+
+
Status
+
Cron
+
Sources
+
+
+ {{range $i, $sc := $page.Schedules}} +
+ edit +
+ {{if $sc.Enabled}} + enabled + {{else}} + paused + {{end}} +
+
{{$sc.CronExpr}}
+
+ {{range $sc.SourceGroupIDs}} + {{$name := index $groupNames .}} + {{if $name}}{{$name}}{{else}}unknown{{end}} + {{end}} +
+
+ {{if eq $host.Status "online"}} + {{if $sc.Enabled}} + + {{else}} + + {{end}} + {{else}} + + {{end}} +
+ +
+
+
+ {{end}} +
+ {{end}} + +
+{{end}} diff --git a/web/templates/pages/host_sources.html b/web/templates/pages/host_sources.html new file mode 100644 index 0000000..36a8077 --- /dev/null +++ b/web/templates/pages/host_sources.html @@ -0,0 +1,91 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +
+ +
+

+ Each source group is a named bundle of paths plus the rule for how long its snapshots stick around. + Schedules point at one or more groups — one restic backup runs per group, + tagged by name so forget can apply retention cleanly. +

+ + New source group +
+ + {{if eq (len $page.Groups) 0}} +
+

No source groups yet.

+

+ Create one to tell the agent what to back up. The group's name doubles as the snapshot tag. +

+ +
+ {{else}} +
+ {{range $i, $row := $page.Groups}} + {{$g := $row.Group}} +
+ {{$g.Name}} +
+
+ {{$g.Name}} + {{if $g.ConflictDimension}} + keep-{{$g.ConflictDimension}} · cadence mismatch + {{end}} +
+
+ {{len $g.Includes}} include{{if ne (len $g.Includes) 1}}s{{end}} · + {{len $g.Excludes}} exclude{{if ne (len $g.Excludes) 1}}s{{end}} · + {{$g.RetentionPolicy.Summary}} +
+
+ {{if eq $row.UsedBy 0}} + used by 0 schedules + {{else}} + used by {{$row.UsedBy}} schedule{{if ne $row.UsedBy 1}}s{{end}} + {{end}} + {{if gt $row.SnapshotCount 0}} · {{$row.SnapshotCount}} snapshot{{if ne $row.SnapshotCount 1}}s{{end}}{{end}} +
+
+
+ {{if and (gt (len $g.Includes) 0) (eq $host.Status "online")}} + + {{else}} + + {{end}} + {{if gt $row.UsedBy 0}} + + {{else if eq (len $page.Groups) 1}} + + {{else}} +
+ +
+ {{end}} +
+
+ {{end}} +
+ +
+ Run-now on a row dispatches one immediate backup using that group's paths and tag. + Group name is used as the snapshot tag — renaming a group + doesn't retag existing snapshots. +
+ {{end}} + +
+{{end}} diff --git a/web/templates/pages/schedule_edit.html b/web/templates/pages/schedule_edit.html index fd1c11e..9dc9757 100644 --- a/web/templates/pages/schedule_edit.html +++ b/web/templates/pages/schedule_edit.html @@ -1,198 +1,123 @@ {{define "title"}}{{.Title}}{{end}} {{define "content"}} +{{template "host_chrome" .}} {{$page := .Page}} {{$host := $page.Host}} -
+{{$f := $page.Form}} +
-
- Dashboard/ - {{$host.Name}}/ - schedules/ - {{if $page.IsNew}}new{{else}}edit{{end}} -
- -

+

{{if $page.IsNew}}New schedule{{else}}Edit schedule{{end}} - · - {{$host.Name}}

-

- Backups run on the cron expression below. The agent applies whatever the server most - recently pushed; an offline agent catches up on the next reconnect. -

{{if $page.Error}} -
+
{{$page.Error}}
{{end}} -
+ +
-
+
+

When

+ + +
+ 0 3 * * * + @hourly + 0 */6 * * * + 0 3 * * 0 + 0 3 1 * * +
+
+ Standard 5-field cron with descriptors. Server validates with the same parser the agent uses to fire — what saves here is what runs. +
-

Kind

-
- {{if $page.IsNew}} - - -
- backup reads files and writes a snapshot. - forget trims the index by your Keep-* rules without deleting data — - an admin-only prune job (P2-06) reclaims the disk space later. - Other kinds (prune, check, unlock) land in P2-06..08. +

+ What — pick one or more source groups +

+ {{if eq (len $page.AvailableGroups) 0}} +
+ This host has no source groups yet — create one first + so this schedule has something to back up.
{{else}} - -
- Kind: {{$page.Kind}} - — immutable on edit; delete and recreate to switch kind. +
+ {{range $page.AvailableGroups}} + {{$checked := index $page.SelectedGroupIDs .ID}} + + {{end}} +
+
+ Each picked group runs as a separate restic backup with its own tag — its own snapshot, its own retention. Pick multiple to fire them all on the same cron tick.
{{end}} -
-

When

- -
- -
- -
- - -
- Standard 5-field cron with descriptors. Examples: - 0 3 * * * (daily 03:00), - @hourly, - */30 * * * * (every 30 min). - Server validates with the same parser the agent uses to fire. -
-
- {{range $cron := list "0 3 * * *" "0 */6 * * *" "@hourly" "0 3 * * 0" "0 3 1 * *"}} - - {{end}} -
-
- -
-

Paths

-
- - -
What restic backup walks. The agent runs as root with CAP_DAC_READ_SEARCH, so any readable path is fair game.
-
-
- - -
Passed straight through as --exclude args.
-
-
- -

Tags · optional

-
- - -
Attached to every snapshot this schedule produces. Useful for retention rules (P2-05).
-
- -

Retention · optional, all blank = keep everything

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- Applied by restic forget when the prune job kind lands in P2-05. Mirrors restic's --keep-* flags one-for-one. -
- -

Bandwidth · optional

-
-
- - -
-
- - -
-
- -
-
-
- - Cancel -
+
- - - -
+ + {{end}} diff --git a/web/templates/pages/schedules_list.html b/web/templates/pages/schedules_list.html deleted file mode 100644 index 3451865..0000000 --- a/web/templates/pages/schedules_list.html +++ /dev/null @@ -1,105 +0,0 @@ -{{define "title"}}{{.Title}}{{end}} - -{{define "content"}} -{{$page := .Page}} -{{$host := $page.Host}} -
- -
- Dashboard/ - {{$host.Name}}/ - schedules -
- - {{/* ---------- header ---------- */}} -
-
-
- {{if eq $host.Status "online"}} - - {{else}} - - {{end}} -

- schedules · - {{$host.Name}} -

- version {{$page.Version}}{{if and (gt $page.Version 0) (ne $page.Version $page.AppliedVersion)}} · agent at v{{$page.AppliedVersion}}{{else if gt $page.Version 0}} · agent in sync{{end}} -
-
- -
- - {{/* ---------- secondary tabs ---------- */}} - - - {{/* ---------- schedule rows ---------- */}} -
- {{if eq (len $page.Schedules) 0}} -
-

No schedules yet.

-

- Add one and the agent will start running backups on whatever cron expression you give it. - Until then, run-now is the only way to trigger a backup. -

- -
- {{else}} -
-
Status
-
When
-
Paths
-
Retention
-
Tags
-
-
- {{range $page.Schedules}} -
-
- {{if .Enabled}} - enabled - {{else}} - disabled - {{end}} - {{if .Manual}} - manual - {{end}} -
-
{{if .Manual}}— run-now only —{{else}}{{.CronExpr}}{{end}}
-
{{joinDot .Paths}}
-
{{.RetentionPolicy.Summary}}
-
- {{- range .Tags -}}{{.}}{{- end -}} -
-
- {{if and .Enabled (eq $host.Status "online")}} - - {{end}} - Edit -
- -
-
-
- {{end}} - {{end}} -
- -
-{{end}} diff --git a/web/templates/pages/source_group_edit.html b/web/templates/pages/source_group_edit.html new file mode 100644 index 0000000..3ef784e --- /dev/null +++ b/web/templates/pages/source_group_edit.html @@ -0,0 +1,132 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +{{$f := $page.Form}} +
+ +

+ {{if $page.IsNew}}New source group{{else}}Edit source group · {{$f.Name}}{{end}} +

+

+ What this group covers and how long its snapshots are worth keeping. + Snapshots produced for this group carry the group's name as a tag — + rename with care: existing snapshots keep the old tag and won't get retained + by a renamed group's policy. +

+ + {{if $page.Error}} +
+ {{$page.Error}} +
+ {{end}} + +
+ +
+ +

Identity

+
+ + +
Used as the snapshot tag. Lowercase, no spaces; matches what restic forget --tag sees.
+
+ +

Paths

+
+ + +
What restic backup walks. Agent runs as root with CAP_DAC_READ_SEARCH, so any readable path is fair game.
+
+
+ + +
Passed straight through as --exclude args.
+
+ +

+ Retention + applied nightly · all blank = keep everything +

+ + {{if and (not $page.IsNew) $f.ConflictDimension}} +
+
+
+ keep-{{$f.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket. + Either drop keep-{{$f.ConflictDimension}} or add a finer-grained schedule. +
+
+ {{end}} + +
+
+
+
+
+
+
+
+
+ Blank fields stay unset (no constraint on that bucket). Forget runs nightly on the cadence configured on the + Repo tab. +
+ +

+ Retry on offline + cron-fired runs only +

+
+
+ + +
+
+ + +
+
+
+ Each retry doubles the wait. Manual run-now ignores this — it just fails immediately if the agent is offline. +
+ +
+ + Cancel +
+
+ + + +
+
+{{end}} diff --git a/web/templates/partials/host_chrome.html b/web/templates/partials/host_chrome.html new file mode 100644 index 0000000..01606de --- /dev/null +++ b/web/templates/partials/host_chrome.html @@ -0,0 +1,118 @@ +{{/* + host_chrome — header (status dot + name + tags + meta), vitals + strip, and the six sub-tab nav for any /hosts/{id}/... page. + + Expects .Page to expose: + .Host — store.Host + .SubTab — "snapshots" | "sources" | "schedules" | "repo" | "jobs" | "settings" + .SourceGroupCount — int + .ScheduleCount — int + .ScheduleVersion — int64 (host_schedule_version) + .Crumb — string ("snapshots" / "sources" / etc — appended after host name) +*/}} +{{define "host_chrome"}} +{{$page := .Page}} +{{$host := $page.Host}} +
+ +
+ Dashboard/ + {{if eq $page.SubTab "snapshots"}} + {{$host.Name}} + {{else}} + {{$host.Name}}/ + {{$page.Crumb}} + {{end}} +
+ + {{/* ---------- header ---------- */}} +
+
+
+ {{if eq $host.Status "online"}} + + {{else if eq $host.Status "degraded"}} + + {{else if eq $host.Status "offline"}} + + {{else}} + + {{end}} +

{{$host.Name}}

+
{{range $host.Tags}}{{.}}{{end}}
+ {{if gt $page.ScheduleVersion 0}} + + version {{$page.ScheduleVersion}} + {{if eq $page.ScheduleVersion $host.AppliedScheduleVersion}} + · agent in sync + {{else}} + · agent at v{{$host.AppliedScheduleVersion}} + {{end}} + + {{end}} +
+
+ {{$host.OS}}/{{$host.Arch}} + · + agent {{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}} + · + restic {{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}} + · + {{if eq $host.Status "offline"}} + last seen {{relTime $host.LastSeenAt}} + {{else}} + online · last heartbeat {{relTime $host.LastSeenAt}} + {{end}} +
+
+
+ + + +
+
+ + {{/* ---------- vitals strip ---------- */}} +
+
+
Last backup
+
+ {{if eq (deref $host.LastBackupStatus) "succeeded"}} + succeeded + {{else if eq (deref $host.LastBackupStatus) "failed"}} + failed + {{else if eq (deref $host.LastBackupStatus) "cancelled"}} + cancelled + {{else}} + never run + {{end}} + {{if $host.LastBackupAt}} · {{relTime $host.LastBackupAt}}{{end}} +
+
+
+
Repo size
+
{{bytes $host.RepoSizeBytes}}
+
+
+
Snapshots
+
{{comma $host.SnapshotCount}}
+
+
+
Open alerts
+
+ {{if eq $host.OpenAlertCount 0}}0 · all clear{{else}}{{$host.OpenAlertCount}} · review →{{end}} +
+
+
+ + {{/* ---------- secondary tabs ---------- */}} + +
+{{end}} diff --git a/web/templates/partials/host_row.html b/web/templates/partials/host_row.html index d1d128e..98b27ea 100644 --- a/web/templates/partials/host_row.html +++ b/web/templates/partials/host_row.html @@ -1,58 +1,66 @@ {{define "host_row"}} -
- {{.Name}} +{{$h := .Host}} +
+ {{$h.Name}}
- {{- if eq .Status "online" -}} - - {{- else if eq .Status "degraded" -}} + {{- if eq $h.Status "online" -}} + + {{- else if eq $h.Status "degraded" -}} - {{- else if eq .Status "offline" -}} + {{- else if eq $h.Status "offline" -}} {{- else -}} {{- end -}}
-
{{.Name}}
-
{{.OS}}/{{.Arch}}
+
{{$h.Name}}
+
{{$h.OS}}/{{$h.Arch}}
- {{- if .CurrentJobID -}} + {{- if $h.CurrentJobID -}} backup running…
- started {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "succeeded" -}} - succeeded · {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "failed" -}} - failed · {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "cancelled" -}} - cancelled · {{relTime .LastBackupAt}} - {{- else if eq .Status "offline" -}} - last seen {{relTime .LastSeenAt}} + started {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "succeeded" -}} + succeeded · {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "failed" -}} + failed · {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "cancelled" -}} + cancelled · {{relTime $h.LastBackupAt}} + {{- else if eq $h.Status "offline" -}} + last seen {{relTime $h.LastSeenAt}} {{- else -}} never run {{- end -}}
-
{{bytes .RepoSizeBytes}}
-
- {{- if eq .SnapshotCount 0 -}} +
{{bytes $h.RepoSizeBytes}}
+
+ {{- if eq $h.SnapshotCount 0 -}} {{- else -}} - {{comma .SnapshotCount}} + {{comma $h.SnapshotCount}} {{- end -}}
-
- {{- if eq .OpenAlertCount 0 -}}—{{- else -}}{{.OpenAlertCount}}{{- end -}} +
+ {{- if eq $h.OpenAlertCount 0 -}}—{{- else -}}{{$h.OpenAlertCount}}{{- end -}}
- {{- range .Tags -}} + {{- range $h.Tags -}} {{.}} {{- end -}}
- {{- if eq .Status "offline" -}} + {{- if eq $h.Status "offline" -}} offline - {{- else if .CurrentJobID -}} - View job → + {{- else if $h.CurrentJobID -}} + View job → + {{- else if .RunAllScheduleID -}} + {{- else -}} - Open → + Open → {{- end -}}