Merge pull request 'P2R-02: UI rewire against the slim-schedule + source-group model' (#2) from p2r-02-ui-rebuild into main

Reviewed-on: #2
This commit is contained in:
2026-05-03 20:34:02 +00:00
71 changed files with 2706 additions and 717 deletions
+7 -5
View File
@@ -34,12 +34,14 @@ jobs:
with: with:
go-version: ${{ env.GO_VERSION }} go-version: ${{ env.GO_VERSION }}
cache: true cache: true
- uses: golangci/golangci-lint-action@v6 - uses: golangci/golangci-lint-action@v7
with: with:
# v1.61 was built against Go 1.23 and refuses to load a # Must be built against the same Go release as go.mod targets,
# config that targets a newer toolchain — go.mod is on 1.25. # otherwise the linter refuses to load with "Go language
# Bumping to a v2.x release built against current Go. # version used to build golangci-lint is lower than the
version: v2.1.6 # 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 args: --timeout=5m
build: build:
+24 -18
View File
@@ -1,18 +1,17 @@
version: "2"
run: run:
timeout: 5m timeout: 5m
tests: true tests: true
linters: linters:
disable-all: true default: none
enable: enable:
- errcheck - errcheck
- gosimple
- govet - govet
- ineffassign - ineffassign
- staticcheck - staticcheck
- unused - unused
- gofumpt
- goimports
- misspell - misspell
- revive - revive
- bodyclose - bodyclose
@@ -21,22 +20,29 @@ linters:
- prealloc - prealloc
- unconvert - unconvert
- unparam - unparam
settings:
linters-settings: revive:
goimports: rules:
local-prefixes: gitea.dcglab.co.uk/steve/restic-manager - name: exported
revive: arguments: ["disableStutteringCheck"]
misspell:
locale: US
exclusions:
rules: rules:
- name: exported - path: _test\.go
arguments: ["disableStutteringCheck"] linters:
misspell: - errcheck
locale: US - unparam
formatters:
enable:
- gofumpt
- goimports
settings:
goimports:
local-prefixes:
- gitea.dcglab.co.uk/steve/restic-manager
issues: issues:
exclude-rules:
- path: _test\.go
linters:
- errcheck
- unparam
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
+32 -9
View File
@@ -11,15 +11,38 @@ repos:
- id: mixed-line-ending - id: mixed-line-ending
args: ["--fix=lf"] args: ["--fix=lf"]
- repo: https://github.com/dnephin/pre-commit-golang # Go-specific hooks. Local hooks (rather than third-party repos) so
rev: v0.5.1 # 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: hooks:
- id: go-fmt - id: gofumpt
- id: go-imports name: gofumpt
- id: go-vet-mod description: Format Go files with gofumpt (stricter superset of gofmt)
- id: go-mod-tidy 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 - 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
+8
View File
@@ -2,6 +2,14 @@
Project-specific rules for Claude when working in this repo. 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 ## No `Co-Authored-By` trailers on commits
Don't add `Co-Authored-By: Claude ...` (or any other co-author Don't add `Co-Authored-By: Claude ...` (or any other co-author
+10 -1
View File
@@ -20,7 +20,7 @@ TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/downlo
TAILWIND_INPUT := web/styles/input.css TAILWIND_INPUT := web/styles/input.css
TAILWIND_OUTPUT := web/static/css/styles.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: help:
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "};{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' @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 lint: ## Run golangci-lint
golangci-lint run ./... 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 fmt: ## Format with gofumpt + goimports
gofumpt -w . gofumpt -w .
goimports -local gitea.dcglab.co.uk/steve/restic-manager -w . goimports -local gitea.dcglab.co.uk/steve/restic-manager -w .
+26 -19
View File
@@ -74,31 +74,38 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t
lastProgress := time.Now() lastProgress := time.Now()
handle := func(stream string, line string, ev any) { handle := func(stream string, line string, ev any) {
// Forward every line to the server as log.stream. // Throttled progress events come from restic's `status` JSON.
now := time.Now().UTC() // We deliberately do NOT forward the raw status line to
logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{ // log.stream — it's emitted ~every 16ms by restic --json and
JobID: jobID, // would drown the live log in dupes for any short backup. The
Seq: seq.Add(1), // progress widget already covers the same information at a
TS: now, // sane sample rate.
Stream: api.LogStream(stream), status, isStatus := ev.(restic.BackupStatus)
Payload: line, if !isStatus {
}) now := time.Now().UTC()
_ = r.tx.Send(logEnv) 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 isStatus {
if status, ok := ev.(restic.BackupStatus); ok {
if time.Since(lastProgress) < r.progressMinPeriod { if time.Since(lastProgress) < r.progressMinPeriod {
return return
} }
lastProgress = time.Now() lastProgress = time.Now()
progEnv, _ := api.Marshal(api.MsgJobProgress, jobID, api.JobProgressPayload{ progEnv, _ := api.Marshal(api.MsgJobProgress, jobID, api.JobProgressPayload{
JobID: jobID, JobID: jobID,
PercentDone: status.PercentDone, PercentDone: status.PercentDone,
FilesDone: status.FilesDone, FilesDone: status.FilesDone,
TotalFiles: status.TotalFiles, TotalFiles: status.TotalFiles,
BytesDone: status.BytesDone, BytesDone: status.BytesDone,
TotalBytes: status.TotalBytes, TotalBytes: status.TotalBytes,
ETASeconds: status.SecondsRem, ETASeconds: status.SecondsRem,
ThroughputBps: throughput(status.BytesDone, status.SecondsElapsed), ThroughputBps: throughput(status.BytesDone, status.SecondsElapsed),
}) })
_ = r.tx.Send(progEnv) _ = r.tx.Send(progEnv)
+1 -2
View File
@@ -110,7 +110,7 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) {
"received", len(payload.Schedules), "active", added) "received", len(payload.Schedules), "active", added)
// Ack outside the lock — Send() shouldn't take long, but holding // 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). // callers (e.g. a future Status() inspection from the UI).
ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{ ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{
Version: payload.Version, Version: payload.Version,
@@ -167,4 +167,3 @@ func (s *Scheduler) fire(entry api.Schedule) {
"schedule_id", entry.ID, "err", err) "schedule_id", entry.ID, "err", err)
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ import (
// additionalData binds ciphertexts to the agent-secrets context, so a // additionalData binds ciphertexts to the agent-secrets context, so a
// blob lifted from one role's file can't be replayed into another's // 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.) // depth — the key is per-host today, but cheap to be careful.)
const additionalData = "rm-agent-repo-creds-v1" const additionalData = "rm-agent-repo-creds-v1"
+4 -2
View File
@@ -48,7 +48,9 @@ func Collect(ctx context.Context, resticPath string) (Snapshot, error) {
// detectResticVersion runs `restic version` and parses the first line. // detectResticVersion runs `restic version` and parses the first line.
// Output looks like: // 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 // 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 // found. We never block startup on a missing restic — the operator
// might not have installed it yet, and the agent should still be // 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" { if len(parts) >= 2 && parts[0] == "restic" {
return parts[1], nil return parts[1], nil
} }
return "", fmt.Errorf("sysinfo: unrecognised restic version output: %q", first) return "", fmt.Errorf("sysinfo: unrecognized restic version output: %q", first)
} }
+11 -4
View File
@@ -40,7 +40,7 @@ type Config struct {
// Sender is what handlers use to push agent → server messages // Sender is what handlers use to push agent → server messages
// (job.progress, job.finished, log.stream, command.result, …). // (job.progress, job.finished, log.stream, command.result, …).
// Returned by the WS client to the dispatch handler. Write operations // 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. // safe.
type Sender interface { type Sender interface {
Send(env api.Envelope) error Send(env api.Envelope) error
@@ -52,7 +52,7 @@ type Sender interface {
type Handler func(ctx context.Context, env api.Envelope, tx Sender) error type Handler func(ctx context.Context, env api.Envelope, tx Sender) error
// Run keeps the agent connected indefinitely. Returns when ctx is // 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. // trigger reconnect-with-backoff; only ctx.Done() ends the loop.
func Run(ctx context.Context, cfg Config, handle Handler) error { func Run(ctx context.Context, cfg Config, handle Handler) error {
if cfg.HeartbeatPeriod <= 0 { 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) slog.Warn("ws agent disconnect", "err", err)
} }
if err := sleepCtx(ctx, backoff.next()); err != nil { 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) dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
conn, _, err := websocket.Dial(dialCtx, wsURL, dialOpts) conn, res, err := websocket.Dial(dialCtx, wsURL, dialOpts)
cancel() cancel()
if err != nil { if err != nil {
return fmt.Errorf("dial: %w", err) 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 defer conn.CloseNow() //nolint:errcheck
// Send hello. // Send hello.
+1 -1
View File
@@ -50,7 +50,7 @@ func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollRe
if err != nil { if err != nil {
return nil, fmt.Errorf("agent enroll: post: %w", err) return nil, fmt.Errorf("agent enroll: post: %w", err)
} }
defer res.Body.Close() defer func() { _ = res.Body.Close() }()
rawRes, _ := io.ReadAll(res.Body) rawRes, _ := io.ReadAll(res.Body)
if res.StatusCode != stdhttp.StatusCreated { if res.StatusCode != stdhttp.StatusCreated {
return nil, fmt.Errorf("agent enroll: server returned %d: %s", return nil, fmt.Errorf("agent enroll: server returned %d: %s",
+28 -14
View File
@@ -10,13 +10,18 @@ import (
// constants so we don't end up with both "linux" and "Linux" rows. // constants so we don't end up with both "linux" and "Linux" rows.
type HostOS string type HostOS string
// Allowed values for HostOS. Lowercased on the wire so the server
// can use a single CHECK constraint.
const ( const (
OSLinux HostOS = "linux" OSLinux HostOS = "linux"
OSWindows HostOS = "windows" OSWindows HostOS = "windows"
) )
// HostArch is the agent's CPU architecture; same lowercase-on-wire
// rule as HostOS.
type HostArch string type HostArch string
// Allowed values for HostArch.
const ( const (
ArchAmd64 HostArch = "amd64" ArchAmd64 HostArch = "amd64"
ArchArm64 HostArch = "arm64" ArchArm64 HostArch = "arm64"
@@ -45,6 +50,9 @@ type HeartbeatPayload struct {
// JobKind is the operation an agent is being asked to run, or just ran. // JobKind is the operation an agent is being asked to run, or just ran.
type JobKind string 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 ( const (
JobBackup JobKind = "backup" JobBackup JobKind = "backup"
JobInit JobKind = "init" JobInit JobKind = "init"
@@ -57,12 +65,16 @@ const (
// JobStatus is the lifecycle state of a job. // JobStatus is the lifecycle state of a job.
type JobStatus string 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 ( const (
JobQueued JobStatus = "queued" JobQueued JobStatus = "queued"
JobRunning JobStatus = "running" JobRunning JobStatus = "running"
JobSucceeded JobStatus = "succeeded" JobSucceeded JobStatus = "succeeded"
JobFailed JobStatus = "failed" JobFailed JobStatus = "failed"
JobCancelled JobStatus = "cancelled" JobCancelled JobStatus = "cancelled" //nolint:misspell // wire format
) )
// CommandRunPayload is the server → agent dispatch for a run-now job. // 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. // LogStream identifies which channel a log line came from.
type LogStream string type LogStream string
// Allowed LogStream values. stdout/stderr are passed through verbatim;
// event is the parsed restic --json envelope (summary, error, etc).
const ( const (
LogStdout LogStream = "stdout" LogStdout LogStream = "stdout"
LogStderr LogStream = "stderr" LogStderr LogStream = "stderr"
@@ -175,12 +189,12 @@ type Snapshot struct {
// RepoStatsPayload — agent reports periodic repo health facts derived // RepoStatsPayload — agent reports periodic repo health facts derived
// from `restic stats` and lock-file inspection. // from `restic stats` and lock-file inspection.
type RepoStatsPayload struct { type RepoStatsPayload struct {
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
SnapshotCount int `json:"snapshot_count"` SnapshotCount int `json:"snapshot_count"`
DedupRatio float64 `json:"dedup_ratio"` DedupRatio float64 `json:"dedup_ratio"`
LastCheckAt time.Time `json:"last_check_at,omitempty"` LastCheckAt time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"` LastCheckStatus string `json:"last_check_status,omitempty"`
LockState string `json:"lock_state"` // locked|unlocked LockState string `json:"lock_state"` // locked|unlocked
} }
// Schedule is the agent-facing view of a slim Schedule row plus its // 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. // ScheduleAckPayload — agent confirms it has applied a given version.
type ScheduleAckPayload struct { type ScheduleAckPayload struct {
Version int64 `json:"version"` Version int64 `json:"version"`
AppliedAt time.Time `json:"applied_at"` AppliedAt time.Time `json:"applied_at"`
} }
// ScheduleFirePayload — agent reports a local cron entry just fired. // 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"; // repo connection details). Empty fields mean "leave existing alone";
// to clear something, send an explicit zero value. // to clear something, send an explicit zero value.
type ConfigUpdatePayload struct { type ConfigUpdatePayload struct {
RepoURL string `json:"repo_url,omitempty"` RepoURL string `json:"repo_url,omitempty"`
RepoPassword string `json:"repo_password,omitempty"` // sensitive RepoPassword string `json:"repo_password,omitempty"` // sensitive
RepoUsername string `json:"repo_username,omitempty"` RepoUsername string `json:"repo_username,omitempty"`
RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth) RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth)
HookShell string `json:"hook_shell,omitempty"` HookShell string `json:"hook_shell,omitempty"`
} }
// AgentUpdateAvailablePayload — informational only; the agent does // AgentUpdateAvailablePayload — informational only; the agent does
+22 -20
View File
@@ -12,35 +12,35 @@ type MessageType string
// Agent → server message types. // Agent → server message types.
const ( const (
MsgHello MessageType = "hello" MsgHello MessageType = "hello"
MsgHeartbeat MessageType = "heartbeat" MsgHeartbeat MessageType = "heartbeat"
MsgJobStarted MessageType = "job.started" MsgJobStarted MessageType = "job.started"
MsgJobProgress MessageType = "job.progress" MsgJobProgress MessageType = "job.progress"
MsgJobFinished MessageType = "job.finished" MsgJobFinished MessageType = "job.finished"
MsgSnapshotsRpt MessageType = "snapshots.report" MsgSnapshotsRpt MessageType = "snapshots.report"
MsgRepoStats MessageType = "repo.stats" MsgRepoStats MessageType = "repo.stats"
MsgLogStream MessageType = "log.stream" MsgLogStream MessageType = "log.stream"
MsgScheduleAck MessageType = "schedule.ack" MsgScheduleAck MessageType = "schedule.ack"
MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job
MsgCommandResult MessageType = "command.result" // ack for command.run MsgCommandResult MessageType = "command.result" // ack for command.run
MsgError MessageType = "error" MsgError MessageType = "error"
) )
// Server → agent message types. // Server → agent message types.
const ( const (
MsgCommandRun MessageType = "command.run" MsgCommandRun MessageType = "command.run"
MsgCommandCancel MessageType = "command.cancel" MsgCommandCancel MessageType = "command.cancel"
MsgScheduleSet MessageType = "schedule.set" MsgScheduleSet MessageType = "schedule.set"
MsgConfigUpdate MessageType = "config.update" MsgConfigUpdate MessageType = "config.update"
MsgAgentUpdateAvail MessageType = "agent.update.available" MsgAgentUpdateAvail MessageType = "agent.update.available"
) )
// Envelope is the framing for every WS message in either direction. // Envelope is the framing for every WS message in either direction.
// Payload is parsed into the concrete struct chosen by Type. // Payload is parsed into the concrete struct chosen by Type.
// //
// ID is set on RPC-style messages (command.run / command.result) so // ID is set on RPC-style messages (command.run / command.result) so
// responses can be correlated. For one-shot pushes (heartbeat, // responses can be correlated. For one-shot pushes (heartbeat,
// job.progress) it is empty. // job.progress) it is empty.
type Envelope struct { type Envelope struct {
Type MessageType `json:"type"` Type MessageType `json:"type"`
ID string `json:"id,omitempty"` 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. // These are stable identifiers; client code may switch on them.
type ErrorCode string type ErrorCode string
// Stable ErrorCode values surfaced over the wire. Clients switch on
// these; renaming requires a wire-version bump.
const ( const (
ErrProtocolTooOld ErrorCode = "protocol_too_old" ErrProtocolTooOld ErrorCode = "protocol_too_old"
ErrProtocolTooNew ErrorCode = "protocol_too_new" ErrProtocolTooNew ErrorCode = "protocol_too_new"
+5 -2
View File
@@ -16,6 +16,7 @@ import (
// argon2id parameters following RFC 9106 §4 "second // argon2id parameters following RFC 9106 §4 "second
// recommended option" (memory-constrained): // recommended option" (memory-constrained):
// - 64 MiB memory, 3 iterations, 4 lanes, 32-byte tag. // - 64 MiB memory, 3 iterations, 4 lanes, 32-byte tag.
//
// These are tunable per-deployment if a beefy controller wants to // These are tunable per-deployment if a beefy controller wants to
// crank them; we ship a defensible default. // crank them; we ship a defensible default.
const ( const (
@@ -27,7 +28,9 @@ const (
) )
// HashPassword returns an argon2id-encoded string of the form // HashPassword returns an argon2id-encoded string of the form
// $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash> //
// $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>
//
// safe to store in a TEXT column. The salt is freshly random per call. // safe to store in a TEXT column. The salt is freshly random per call.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
salt := make([]byte, defaultSaltLen) salt := make([]byte, defaultSaltLen)
@@ -53,7 +56,7 @@ func VerifyPassword(encoded, password string) error {
parts := strings.Split(encoded, "$") parts := strings.Split(encoded, "$")
// "$argon2id$v=...$m=...,t=...,p=...$<salt>$<hash>" → 6 parts (leading empty) // "$argon2id$v=...$m=...,t=...,p=...$<salt>$<hash>" → 6 parts (leading empty)
if len(parts) != 6 || parts[1] != "argon2id" { if len(parts) != 6 || parts[1] != "argon2id" {
return errors.New("auth: unrecognised hash format") return errors.New("auth: unrecognized hash format")
} }
var version int var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
+1 -1
View File
@@ -41,7 +41,7 @@ func TestVerifyRejectsMalformed(t *testing.T) {
"", "",
"not-a-hash", "not-a-hash",
"$argon2i$v=19$m=64,t=3,p=4$AAAA$BBBB", // wrong variant "$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 "$argon2id$v=99$m=64,t=3,p=4$AAAA$BBBB", // bad version
} }
for _, c := range cases { for _, c := range cases {
+1 -1
View File
@@ -65,7 +65,7 @@ func GenerateKeyFile(path string) error {
if err != nil { if err != nil {
return fmt.Errorf("create key file %q: %w", path, err) return fmt.Errorf("create key file %q: %w", path, err)
} }
defer f.Close() defer func() { _ = f.Close() }()
key := make([]byte, KeyLen) key := make([]byte, KeyLen)
if _, err := io.ReadFull(rand.Reader, key); err != nil { if _, err := io.ReadFull(rand.Reader, key); err != nil {
return fmt.Errorf("read random: %w", err) return fmt.Errorf("read random: %w", err)
+14 -12
View File
@@ -15,7 +15,7 @@ import (
"time" "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. // override if provided, else fall back to PATH.
func Locate(override string) (string, error) { func Locate(override string) (string, error) {
if override != "" { 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 // never assign it back to Env, never pass it to slog. If anything
// in this package ever needs to *log* a URL, use RedactURL. // in this package ever needs to *log* a URL, use RedactURL.
type Env struct { type Env struct {
Bin string // path to restic binary Bin string // path to restic binary
RepoURL string // RESTIC_REPOSITORY (no embedded creds) RepoURL string // RESTIC_REPOSITORY (no embedded creds)
RepoUsername string // optional HTTP basic-auth user for rest: URLs RepoUsername string // optional HTTP basic-auth user for rest: URLs
RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password
ExtraEnv map[string]string // any other RESTIC_* / passthrough ExtraEnv map[string]string // any other RESTIC_* / passthrough
WorkDir string // CWD; default = current WorkDir string // CWD; default = current
} }
// EventKind enumerates what we care about in restic's --json output // EventKind enumerates what we care about in restic's --json output
@@ -54,10 +54,12 @@ type Env struct {
// switch on message_type. // switch on message_type.
type EventKind string type EventKind string
// Known message_type values restic --json emits during a backup.
// Kept as constants so callers can switch without typo risk.
const ( const (
EventStatus EventKind = "status" // periodic progress EventStatus EventKind = "status" // periodic progress
EventVerbose EventKind = "verbose_status" 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" EventErrorEvent EventKind = "error"
) )
@@ -90,7 +92,7 @@ type BackupSummary struct {
} }
// LineHandler receives every stdout/stderr line. event is non-nil // 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`). // original text (so we can also tee to job_logs as `stdout`).
type LineHandler func(stream string, raw string, event any) 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 // 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 // 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 // not a failure. Wraps the caller's handle so the line still
// gets streamed verbatim to the operator-facing log. // gets streamed verbatim to the operator-facing log.
alreadyInited := false alreadyInited := false
@@ -280,7 +282,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error {
if werr := cmd.Wait(); werr != nil { if werr := cmd.Wait(); werr != nil {
if alreadyInited { if alreadyInited {
if handle != nil { if handle != nil {
handle("event", "repo already initialised — treating as success", nil) handle("event", "repo already initialized — treating as success", nil)
} }
return nil return nil
} }
+4 -2
View File
@@ -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 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 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/", "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"}, {"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"}, {"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/"}, {"https URL kept intact", "rest:https://h/p/", "u", "p", "rest:https://u:p@h/p/"},
+3 -3
View File
@@ -34,9 +34,9 @@ type Config struct {
} }
// Load resolves config in this order: // Load resolves config in this order:
// 1. defaults // 1. defaults
// 2. YAML at the given path (if non-empty and exists) // 2. YAML at the given path (if non-empty and exists)
// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …) // 3. environment variables (RM_LISTEN, RM_DATA_DIR, …)
// //
// The result is validated; a zero-error return means the server is // The result is validated; a zero-error return means the server is
// safe to start. // safe to start.
+1 -1
View File
@@ -57,7 +57,7 @@ func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request)
} }
func (s *Server) handleInstallAsset(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/<file>". // chi's TrimPrefix-like behavior: r.URL.Path is "/install/<file>".
rel := strings.TrimPrefix(r.URL.Path, "/install/") rel := strings.TrimPrefix(r.URL.Path, "/install/")
// Reject any path traversal — must be a flat filename. // Reject any path traversal — must be a flat filename.
if rel == "" || strings.ContainsAny(rel, "/\\") { if rel == "" || strings.ContainsAny(rel, "/\\") {
+1 -1
View File
@@ -137,7 +137,7 @@ func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) {
return return
} }
if n > 0 { if n > 0 {
writeJSONError(w, stdhttp.StatusConflict, "already_initialised", writeJSONError(w, stdhttp.StatusConflict, "already_initialized",
"a user already exists; bootstrap is disabled") "a user already exists; bootstrap is disabled")
return return
} }
+4 -2
View File
@@ -36,7 +36,7 @@ func newTestServer(t *testing.T, withBootstrapToken bool) (*Server, string) {
aead, _ := crypto.NewAEAD(key) aead, _ := crypto.NewAEAD(key)
deps := Deps{ deps := Deps{
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
Store: st, Store: st,
AEAD: aead, AEAD: aead,
} }
@@ -125,7 +125,9 @@ func TestLoginAndLogout(t *testing.T) {
bs, _ := json.Marshal(bootstrapRequest{ bs, _ := json.Marshal(bootstrapRequest{
Token: "test-token", Username: "alice", Password: "averylongpassword", 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. // Login.
body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"}) body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"})
+6 -6
View File
@@ -3,6 +3,7 @@ package http
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
stdhttp "net/http" 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 // Seed the host's "default" source group with whatever paths the
// operator typed into Add-host (empty allowed; group is editable // 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 // repo-maintenance row with default cadences so forget/prune/check
// start ticking on their own. Auto-init dispatch lands in Phase 6 // start ticking on their own. Auto-init dispatch lands in Phase 6
// of the redesign. // of the redesign.
@@ -222,12 +223,11 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt
return return
} }
token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths) token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths)
switch err { switch {
case nil: case err == nil:
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt}) writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt})
case errMissingRepoCreds: case errors.Is(err, errMissingRepoCreds):
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "repo_url and repo_password are required so the agent can run backups on first connect")
"repo_url and repo_password are required so the agent can run backups on first connect")
default: default:
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
} }
+62
View File
@@ -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))
}
+1 -1
View File
@@ -162,7 +162,7 @@ func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.R
w.WriteHeader(stdhttp.StatusNoContent) 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 // 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 // (no-op if not connected — caller is expected to check first when it
// matters). // matters).
+17 -17
View File
@@ -10,23 +10,23 @@ import (
// store row, but with explicit time-strings so wire format is stable // store row, but with explicit time-strings so wire format is stable
// across DB driver changes. // across DB driver changes.
type hostView struct { type hostView struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OS string `json:"os"` OS string `json:"os"`
Arch string `json:"arch"` Arch string `json:"arch"`
AgentVersion string `json:"agent_version,omitempty"` AgentVersion string `json:"agent_version,omitempty"`
ResticVersion string `json:"restic_version,omitempty"` ResticVersion string `json:"restic_version,omitempty"`
ProtocolVersion int `json:"protocol_version"` ProtocolVersion int `json:"protocol_version"`
EnrolledAt string `json:"enrolled_at"` EnrolledAt string `json:"enrolled_at"`
LastSeenAt *string `json:"last_seen_at,omitempty"` LastSeenAt *string `json:"last_seen_at,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
CurrentJobID *string `json:"current_job_id,omitempty"` CurrentJobID *string `json:"current_job_id,omitempty"`
LastBackupAt *string `json:"last_backup_at,omitempty"` LastBackupAt *string `json:"last_backup_at,omitempty"`
LastBackupStatus *string `json:"last_backup_status,omitempty"` LastBackupStatus *string `json:"last_backup_status,omitempty"`
RepoSizeBytes int64 `json:"repo_size_bytes"` RepoSizeBytes int64 `json:"repo_size_bytes"`
SnapshotCount int `json:"snapshot_count"` SnapshotCount int `json:"snapshot_count"`
OpenAlertCount int `json:"open_alert_count"` OpenAlertCount int `json:"open_alert_count"`
} }
// handleListHosts returns the full fleet as JSON. Authenticated; the // handleListHosts returns the full fleet as JSON. Authenticated; the
+2 -2
View File
@@ -16,8 +16,8 @@ import (
// runNowRequest is the body of POST /api/hosts/:id/jobs. // runNowRequest is the body of POST /api/hosts/:id/jobs.
type runNowRequest struct { type runNowRequest struct {
Kind api.JobKind `json:"kind"` Kind api.JobKind `json:"kind"`
Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.) Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.)
} }
type runNowResponse struct { type runNowResponse struct {
+32 -14
View File
@@ -215,24 +215,30 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Bad cron → 400. // Bad cron → 400.
status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "not-a-cron", "enabled": true, map[string]any{
"source_group_ids": []string{"x"}}, cookie) "cron": "not-a-cron", "enabled": true,
"source_group_ids": []string{"x"},
}, cookie)
if status != 400 { if status != 400 {
t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body) t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
} }
// Missing groups → 400. // Missing groups → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true, map[string]any{
"source_group_ids": []string{}}, cookie) "cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{},
}, cookie)
if status != 400 { if status != 400 {
t.Errorf("missing groups: want 400, got %d", status) t.Errorf("missing groups: want 400, got %d", status)
} }
// Group not on host → 400. // Group not on host → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true, map[string]any{
"source_group_ids": []string{"non-existent"}}, cookie) "cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{"non-existent"},
}, cookie)
if status != 400 { if status != 400 {
t.Errorf("bogus group: want 400, got %d", status) t.Errorf("bogus group: want 400, got %d", status)
} }
@@ -247,8 +253,10 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Happy create. // Happy create.
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true, map[string]any{
"source_group_ids": []string{gid}}, cookie) "cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{gid},
}, cookie)
if status != 201 { if status != 201 {
t.Fatalf("create: %d body=%+v", status, body) t.Fatalf("create: %d body=%+v", status, body)
} }
@@ -269,8 +277,10 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Update — change cron, keep group. // Update — change cron, keep group.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid, status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
map[string]any{"cron": "@hourly", "enabled": false, map[string]any{
"source_group_ids": []string{gid}}, cookie) "cron": "@hourly", "enabled": false,
"source_group_ids": []string{gid},
}, cookie)
if status != 200 { if status != 200 {
t.Fatalf("update: %d body=%+v", status, body) 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) url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil)
req.AddCookie(cookie) req.AddCookie(cookie)
req.Header.Set("Accept", "application/json") 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() defer res.Body.Close()
if res.StatusCode != stdhttp.StatusServiceUnavailable { if res.StatusCode != stdhttp.StatusServiceUnavailable {
t.Errorf("offline: want 503, got %d", res.StatusCode) 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) url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil)
req.AddCookie(cookie) req.AddCookie(cookie)
req.Header.Set("Accept", "application/json") 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() defer res.Body.Close()
if res.StatusCode != stdhttp.StatusNotFound { if res.StatusCode != stdhttp.StatusNotFound {
t.Errorf("unknown group: want 404, got %d", res.StatusCode) 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. // keep fmt import live — used for occasional debug.
var _ = fmt.Sprintf var (
var _ = strings.HasPrefix _ = fmt.Sprintf
_ = strings.HasPrefix
)
+10 -5
View File
@@ -6,8 +6,8 @@ package http
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/http/httptest"
stdhttp "net/http" stdhttp "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "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" url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
}) })
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) 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 return c
} }
@@ -76,7 +81,7 @@ func drainUntil(t *testing.T, c *websocket.Conn, wantType api.MessageType) api.E
return api.Envelope{} 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. // will treat it as ready to receive command.run.
func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) { func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) {
t.Helper() 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 { if err := st.SetHostCredentials(context.Background(), hostID, enc); err != nil {
t.Fatalf("set creds: %v", err) t.Fatalf("set creds: %v", err)
} }
return return hostID, token
} }
func sendHello(t *testing.T, c *websocket.Conn, hostname string) { func sendHello(t *testing.T, c *websocket.Conn, hostname string) {
-1
View File
@@ -142,4 +142,3 @@ func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhtt
} }
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m)) writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m))
} }
+11 -5
View File
@@ -4,7 +4,7 @@
// The slim-schedule wire shape is built here from the (Schedule, // The slim-schedule wire shape is built here from the (Schedule,
// SourceGroup) pair. Each schedule is sent with its resolved source // SourceGroup) pair. Each schedule is sent with its resolved source
// groups inlined so the agent doesn't have to keep its own copy of // 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 // when an entry fires the agent ships back a schedule.fire and
// dispatchScheduledJob below resolves the schedule's groups and // dispatchScheduledJob below resolves the schedule's groups and
// dispatches one backup command.run per group. // 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 // dispatchBackupForGroup builds and sends a single backup command.run
// envelope on conn for the given group. Persists the job row first so // envelope on conn for the given group. Persists the job row first so
// the live log viewer can subscribe to it. // 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() jobID := ulid.Make().String()
now := time.Now().UTC() now := time.Now().UTC()
scheduleRef := scheduleID scheduleRef := scheduleID
@@ -181,7 +186,7 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host
}); err != nil { }); err != nil {
slog.Warn("schedule.fire: persist job", "host_id", hostID, slog.Warn("schedule.fire: persist job", "host_id", hostID,
"schedule_id", scheduleID, "group", g.Name, "err", err) "schedule_id", scheduleID, "group", g.Name, "err", err)
return return ""
} }
// Backup ignores RetentionPolicy — the forget cadence lives on // Backup ignores RetentionPolicy — the forget cadence lives on
// host_repo_maintenance and is driven by the server-side ticker // 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 { if err != nil {
slog.Warn("schedule.fire: marshal command.run", slog.Warn("schedule.fire: marshal command.run",
"host_id", hostID, "schedule_id", scheduleID, "err", err) "host_id", hostID, "schedule_id", scheduleID, "err", err)
return return ""
} }
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
if err := conn.Send(sendCtx, env); err != nil { if err := conn.Send(sendCtx, env); err != nil {
slog.Warn("schedule.fire: send command.run", slog.Warn("schedule.fire: send command.run",
"host_id", hostID, "schedule_id", scheduleID, "err", err) "host_id", hostID, "schedule_id", scheduleID, "err", err)
return return ""
} }
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
ID: ulid.Make().String(), 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", slog.Info("schedule.fire: dispatched backup",
"host_id", hostID, "schedule_id", scheduleID, "host_id", hostID, "schedule_id", scheduleID,
"group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt) "group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt)
return jobID
} }
+1 -1
View File
@@ -212,7 +212,7 @@ func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req
for _, gid := range req.SourceGroupIDs { for _, gid := range req.SourceGroupIDs {
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid) g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid)
if err != nil || g == nil { 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 return "", "", true
+18 -1
View File
@@ -126,6 +126,10 @@ func (s *Server) routes(r chi.Router) {
r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance) 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 // Per-source-group Run-now (JSON variant). HTMX action is
// mounted at the equivalent path outside /api below — both // mounted at the equivalent path outside /api below — both
// resolve to the same handler, which sniffs HX-Request. // 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 // Durable post-Add-host page (operator can refresh / come
// back; password decrypted from the token row each render). // back; password decrypted from the token row each render).
// Polled fragment under /awaiting flips to "connected" once // 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}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default). // Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail) 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. // Schedules tab + create/edit/delete forms.
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
+138 -41
View File
@@ -44,7 +44,11 @@ func staticHandler() stdhttp.Handler {
func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
c, err := r.Cookie(sessionCookieName) c, err := r.Cookie(sessionCookieName)
if err != nil { 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)) sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
if err != nil { 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 // baseView populates the fields the nav partial needs on every
// authenticated page. // authenticated page. Every UI page sits under the dashboard primary
func (s *Server) baseView(u *ui.User, active string) ui.ViewData { // 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{ return ui.ViewData{
User: u, User: u,
Active: active, Active: "dashboard",
Version: s.version(), Version: s.version(),
} }
} }
@@ -103,11 +109,64 @@ func (s *Server) version() string {
// dashboardPage is the data the dashboard template renders against. // dashboardPage is the data the dashboard template renders against.
type dashboardPage struct { type dashboardPage struct {
Hosts []store.Host Hosts []dashboardHostRow
HostCount int HostCount int
Summary store.FleetSummary 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 // handleUIDashboard is the root page. Auth-gated; falls through to
// /login if there is no session. // /login if there is no session.
func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) { 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 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.OpenAlerts = summary.OpenAlerts
view.Page = dashboardPage{ view.Page = dashboardPage{
Hosts: hosts, Hosts: rows,
HostCount: len(hosts), HostCount: len(hosts),
Summary: summary, Summary: summary,
} }
@@ -178,16 +255,16 @@ type addHostPage struct {
} }
// pendingHostPage is the GET /hosts/pending/{token} view. Lives // 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. // the handler redirects to /hosts/{host_id} and this page is gone.
type pendingHostPage struct { type pendingHostPage struct {
Token string Token string
ServerURL string ServerURL string
ExpiresAt time.Time ExpiresAt time.Time
RepoURL string RepoURL string
RepoUsername string RepoUsername string
RepoPassword string RepoPassword string
InitialPaths []string InitialPaths []string
} }
// handleUIAddHostGet renders the empty Add host form. // handleUIAddHostGet renders the empty Add host form.
@@ -196,7 +273,7 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request
if u == nil { if u == nil {
return return
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "Add host · restic-manager" view.Title = "Add host · restic-manager"
view.Page = addHostPage{ServerURL: s.publicURL(r)} view.Page = addHostPage{ServerURL: s.publicURL(r)}
if err := s.deps.UI.Render(w, "add_host", view); err != nil { 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 == "" { if page.Error == "" {
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths)) token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths))
switch err { switch {
case nil: case err == nil:
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther) stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
return return
case errMissingRepoCreds: case errors.Is(err, errMissingRepoCreds):
page.Error = "Repo URL and password are both required." page.Error = "Repo URL and password are both required."
default: default:
slog.Error("ui add_host: mint token", "err", err) 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.Title = "Add host · restic-manager"
view.Page = page view.Page = page
w.WriteHeader(stdhttp.StatusUnprocessableEntity) 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 — // handleUIPendingHost serves the durable Add-host result page —
// shown after a successful POST /hosts/new and reachable until the // 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 // happens) or the token expires (1h ttl). The password is
// re-decrypted from the encrypted token row on every render so // re-decrypted from the encrypted token row on every render so
// the operator can refresh, bookmark, navigate away and come back. // 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.Title = "Pending host · restic-manager"
view.Page = page view.Page = page
if err := s.deps.UI.Render(w, "pending_host", view); err != nil { if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
@@ -397,9 +474,44 @@ type awaitingFragment struct {
LastSeenAt *time.Time 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. // hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct { type hostDetailPage struct {
Host store.Host hostChromeData
Snapshots []store.Snapshot Snapshots []store.Snapshot
// SnapshotsShown is the number rendered (we cap at ~50 for the // SnapshotsShown is the number rendered (we cap at ~50 for the
// first slice; pagination lands when it matters). // first slice; pagination lands when it matters).
@@ -440,10 +552,10 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request
shown = shown[:cap] shown = shown[:cap]
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = host.Name + " · restic-manager" view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{ view.Page = hostDetailPage{
Host: *host, hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
Snapshots: shown, Snapshots: shown,
SnapshotsShown: len(shown), SnapshotsShown: len(shown),
} }
@@ -539,7 +651,7 @@ func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request)
nextSeq = logs[n-1].Seq nextSeq = logs[n-1].Seq
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = job.Kind + " · " + host.Name + " · restic-manager" view.Title = job.Kind + " · " + host.Name + " · restic-manager"
view.Page = jobDetailPage{ view.Page = jobDetailPage{
Job: *job, 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 // handleUILoginGet renders the login form. If the user is already
// signed in we redirect them home — login is for the unauthenticated. // signed in we redirect them home — login is for the unauthenticated.
func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+350
View File
@@ -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=<section>` 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
}
+398 -14
View File
@@ -1,38 +1,422 @@
package http package http
import ( import (
"context"
"encoding/json"
"errors"
"log/slog"
stdhttp "net/http" 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. // ui_schedules.go — HTML form-driven schedule CRUD against the slim
// // shape (cron + source-group multi-select + enabled).
// Stubbed during the P2 redesign template rewrite. Phase 4 of the
// redesign rebuilds the schedule editor against the new slim shape // hostSchedulesPage backs the list view. GroupNames maps source-group
// (cron + source-group multi-select + enabled), the source-group // ID → name for the per-row tag rendering, populated once on load so
// list/edit pages, and the repo-maintenance tab. Until then these // the template doesn't need to do per-row store lookups.
// routes return 501; the dashboard's host-row "View →" link is the type hostSchedulesPage struct {
// only operator entry point that still works. 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) { 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) { 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) { 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) { 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) { 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) { 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
} }
+439
View File
@@ -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,
}
}
+4 -4
View File
@@ -13,10 +13,10 @@ import (
// which can pre-compute and pass primitives into the view. // which can pre-compute and pass primitives into the view.
func funcMap() template.FuncMap { func funcMap() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"bytes": formatBytes, "bytes": formatBytes,
"relTime": formatRelTime, "relTime": formatRelTime,
"comma": formatComma, "comma": formatComma,
"deref": derefStr, "deref": derefStr,
"timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() }, "timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() },
"joinDot": func(parts []string) string { return strings.Join(parts, " · ") }, "joinDot": func(parts []string) string { return strings.Join(parts, " · ") },
"absTime": func(t time.Time) string { "absTime": func(t time.Time) string {
+1
View File
@@ -91,6 +91,7 @@ func New() (*Renderer, error) {
"templates/partials/host_row.html", "templates/partials/host_row.html",
"templates/partials/toast.html", "templates/partials/toast.html",
"templates/partials/awaiting_agent.html", "templates/partials/awaiting_agent.html",
"templates/partials/host_chrome.html",
} }
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
+9 -9
View File
@@ -42,14 +42,14 @@ type HandlerDeps struct {
// enrollment) before the WS upgrade. // enrollment) before the WS upgrade.
// //
// Lifecycle: // Lifecycle:
// 1. Bearer token resolves to a Host row. // 1. Bearer token resolves to a Host row.
// 2. Upgrade. // 2. Upgrade.
// 3. First message must be `hello`; protocol_version checked here. // 3. First message must be `hello`; protocol_version checked here.
// 4. Loop: read messages, dispatch by type. Heartbeats touch the // 4. Loop: read messages, dispatch by type. Heartbeats touch the
// host row; job/log/repo messages forward to the relevant // host row; job/log/repo messages forward to the relevant
// handlers (TODO: lands with P1-18 onward). // handlers (TODO: lands with P1-18 onward).
// 5. On Read error or context cancel, mark host offline, unregister // 5. On Read error or context cancel, mark host offline, unregister
// from the hub. // from the hub.
func AgentHandler(deps HandlerDeps) stdhttp.Handler { func AgentHandler(deps HandlerDeps) stdhttp.Handler {
return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
host, ok := authenticateAgent(r, deps.Store) 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 { string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil {
slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err) 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 // at host enrolment makes "is the repo init'd" derivable from
// the latest init job's status, no separate column needed. // the latest init job's status, no separate column needed.
if deps.JobHub != nil { if deps.JobHub != nil {
+1 -1
View File
@@ -100,7 +100,7 @@ func NewConn(hostID string, c *websocket.Conn) *Conn {
} }
// Send writes an envelope as a JSON text message. Concurrent calls // 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. // writers.
func (c *Conn) Send(ctx context.Context, env api.Envelope) error { func (c *Conn) Send(ctx context.Context, env api.Envelope) error {
c.writeMu.Lock() c.writeMu.Lock()
+20 -3
View File
@@ -47,7 +47,7 @@ func setupTestHub(t *testing.T) (url string, token string, hostID string, st *st
t.Fatalf("enroll: %v", err) t.Fatalf("enroll: %v", err)
} }
url = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent" url = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
return return url, token, hostID, st, hub
} }
func TestWSHelloAndHeartbeat(t *testing.T) { func TestWSHelloAndHeartbeat(t *testing.T) {
@@ -57,13 +57,18 @@ func TestWSHelloAndHeartbeat(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
}) })
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
defer c.CloseNow() defer c.CloseNow()
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
// Send hello. // Send hello.
hello := api.HelloPayload{ hello := api.HelloPayload{
@@ -125,13 +130,18 @@ func TestWSRejectsOldProtocol(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
}) })
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
defer c.CloseNow() defer c.CloseNow()
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
hello := api.HelloPayload{ProtocolVersion: 0} // below minimum hello := api.HelloPayload{ProtocolVersion: 0} // below minimum
env, _ := api.Marshal(api.MsgHello, "", hello) env, _ := api.Marshal(api.MsgHello, "", hello)
@@ -170,6 +180,13 @@ func TestWSRejectsBadToken(t *testing.T) {
_, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ _, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer wrong"}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer wrong"}},
}) })
if res != nil {
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
}
if err == nil { if err == nil {
t.Fatal("dial should fail") t.Fatal("dial should fail")
} }
+2 -2
View File
@@ -33,7 +33,7 @@ func NewJobHub() *JobHub {
// the hub's set (so concurrent Broadcasts will reach it), but no // the hub's set (so concurrent Broadcasts will reach it), but no
// pump goroutine runs yet. The caller can prime the channel via Send // pump goroutine runs yet. The caller can prime the channel via Send
// — useful for late-subscriber catch-up — and then call Run to start // — 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. // unregisters on return.
type Subscriber struct { type Subscriber struct {
hub *JobHub hub *JobHub
@@ -73,7 +73,7 @@ func (s *Subscriber) Send(env api.Envelope) {
} }
// Run pumps messages from the subscriber's channel onto conn until // 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. // expected to invoke this from the goroutine that owns conn.
func (s *Subscriber) Run(ctx context.Context, conn *Conn) { func (s *Subscriber) Run(ctx context.Context, conn *Conn) {
defer s.unregister() defer s.unregister()
+1 -1
View File
@@ -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. // 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." // We want NULL semantics for "absent."
func nullable(p *string) any { func nullable(p *string) any {
if p == nil || *p == "" { if p == nil || *p == "" {
-1
View File
@@ -172,4 +172,3 @@ func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error)
n, _ := res.RowsAffected() n, _ := res.RowsAffected()
return n, nil return n, nil
} }
+2 -2
View File
@@ -57,7 +57,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) {
if err != nil { if err != nil {
return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err) return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
for rows.Next() { for rows.Next() {
var status string var status string
var n int var n int
@@ -70,7 +70,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) {
fs.JobsLast24hSucceeded = n fs.JobsLast24hSucceeded = n
case "failed": case "failed":
fs.JobsLast24hFailed = n fs.JobsLast24hFailed = n
case "cancelled": case "cancelled": //nolint:misspell // matches the DB CHECK constraint and api.JobCancelled wire value
fs.JobsLast24hCancelled = n fs.JobsLast24hCancelled = n
} }
} }
+6 -6
View File
@@ -121,7 +121,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list hosts: %w", err) return nil, fmt.Errorf("store: list hosts: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
var out []Host var out []Host
for rows.Next() { for rows.Next() {
h, err := scanHostRow(rows) h, err := scanHostRow(rows)
@@ -150,11 +150,11 @@ func scanHost(row *sql.Row) (*Host, error) {
func scanHostRow(s hostScanner) (*Host, error) { func scanHostRow(s hostScanner) (*Host, error) {
var h Host var h Host
var ( var (
lastSeen, lastBackupAt sql.NullString lastSeen, lastBackupAt sql.NullString
repoID, currentJob, lastBkSt sql.NullString repoID, currentJob, lastBkSt sql.NullString
enrolled string enrolled string
tags string tags string
bwUp, bwDown sql.NullInt64 bwUp, bwDown sql.NullInt64
) )
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
+10 -10
View File
@@ -118,7 +118,7 @@ func (s *Store) ListJobLogs(ctx context.Context, jobID string, afterSeq int64, l
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list job logs: %w", err) return nil, fmt.Errorf("store: list job logs: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
var out []JobLogLine var out []JobLogLine
for rows.Next() { for rows.Next() {
var l JobLogLine 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 started_at, finished_at, exit_code, stats, error, created_at
FROM jobs WHERE id = ?`, id) FROM jobs WHERE id = ?`, id)
var ( var (
j Job j Job
schedID sql.NullString schedID sql.NullString
actorID sql.NullString actorID sql.NullString
startedAt sql.NullString startedAt sql.NullString
finishedAt sql.NullString finishedAt sql.NullString
exitCode sql.NullInt64 exitCode sql.NullInt64
stats sql.NullString stats sql.NullString
errMsg sql.NullString errMsg sql.NullString
createdAt string createdAt string
) )
if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID, if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID,
&j.ActorKind, &actorID, &startedAt, &finishedAt, &j.ActorKind, &actorID, &startedAt, &finishedAt,
+1 -1
View File
@@ -31,7 +31,7 @@ func (st *Store) GetRepoMaintenance(ctx context.Context, hostID string) (*HostRe
check_cron, check_enabled, check_subset_pct check_cron, check_enabled, check_subset_pct
FROM host_repo_maintenance WHERE host_id = ?`, hostID) FROM host_repo_maintenance WHERE host_id = ?`, hostID)
var ( var (
m HostRepoMaintenance m HostRepoMaintenance
forgetEnabled, pruneEnabled, checkEnabled int forgetEnabled, pruneEnabled, checkEnabled int
) )
err := row.Scan(&m.HostID, err := row.Scan(&m.HostID,
+3 -3
View File
@@ -15,9 +15,9 @@ var migrationsFS embed.FS
// migration is one ordered SQL file from migrations/. // migration is one ordered SQL file from migrations/.
type migration struct { type migration struct {
version int // parsed from filename prefix (0001, 0002, …) version int // parsed from filename prefix (0001, 0002, …)
name string // full filename, for error messages name string // full filename, for error messages
sql string sql string
} }
// loadMigrations reads every migrations/*.sql file in lexical order // loadMigrations reads every migrations/*.sql file in lexical order
+1 -1
View File
@@ -52,7 +52,7 @@ func (st *Store) DuePendingRuns(ctx context.Context, now time.Time, limit int) (
if err != nil { if err != nil {
return nil, fmt.Errorf("store: due pending runs: %w", err) return nil, fmt.Errorf("store: due pending runs: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []PendingRun{} out := []PendingRun{}
for rows.Next() { for rows.Next() {
var p PendingRun var p PendingRun
+3 -3
View File
@@ -144,7 +144,7 @@ func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Sche
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list schedules: %w", err) return nil, fmt.Errorf("store: list schedules: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []Schedule{} out := []Schedule{}
for rows.Next() { for rows.Next() {
s, err := scanScheduleRow(rows) s, err := scanScheduleRow(rows)
@@ -247,7 +247,7 @@ func (st *Store) scheduleGroupIDs(ctx context.Context, scheduleID string) ([]str
if err != nil { if err != nil {
return nil, fmt.Errorf("store: read schedule junction: %w", err) return nil, fmt.Errorf("store: read schedule junction: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []string{} out := []string{}
for rows.Next() { for rows.Next() {
var id string var id string
@@ -269,7 +269,7 @@ func (st *Store) SchedulesUsingGroup(ctx context.Context, groupID string) ([]str
if err != nil { if err != nil {
return nil, fmt.Errorf("store: schedules using group: %w", err) return nil, fmt.Errorf("store: schedules using group: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []string{} out := []string{}
for rows.Next() { for rows.Next() {
var id string var id string
+2 -2
View File
@@ -51,7 +51,7 @@ func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps [
if err != nil { if err != nil {
return fmt.Errorf("store: prepare snapshot insert: %w", err) return fmt.Errorf("store: prepare snapshot insert: %w", err)
} }
defer stmt.Close() defer func() { _ = stmt.Close() }()
refreshed := when.UTC().Format(time.RFC3339Nano) refreshed := when.UTC().Format(time.RFC3339Nano)
for _, snap := range snaps { for _, snap := range snaps {
@@ -92,7 +92,7 @@ func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snaps
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list snapshots: %w", err) return nil, fmt.Errorf("store: list snapshots: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
var out []Snapshot var out []Snapshot
for rows.Next() { for rows.Next() {
+15 -13
View File
@@ -30,20 +30,20 @@ func TestReplaceHostSnapshotsRoundTrip(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
in := []Snapshot{ in := []Snapshot{
{ {
ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000", ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "deadbeef", ShortID: "deadbeef",
Time: now.Add(-2 * time.Hour), Time: now.Add(-2 * time.Hour),
Hostname: "snap-host", Hostname: "snap-host",
Paths: []string{"/etc", "/home"}, Paths: []string{"/etc", "/home"},
Tags: []string{"daily"}, Tags: []string{"daily"},
SizeBytes: 4096, FileCount: 12, SizeBytes: 4096, FileCount: 12,
}, },
{ {
ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000", ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "cafef00d", ShortID: "cafef00d",
Time: now.Add(-1 * time.Hour), Time: now.Add(-1 * time.Hour),
Hostname: "snap-host", Hostname: "snap-host",
Paths: []string{"/etc"}, Paths: []string{"/etc"},
SizeBytes: 8192, FileCount: 24, SizeBytes: 8192, FileCount: 24,
}, },
} }
@@ -129,9 +129,11 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) {
// First a non-empty replace. // First a non-empty replace.
if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{ if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{
{ID: "1111111111111111111111111111111111111111111111111111111111111111", {
ID: "1111111111111111111111111111111111111111111111111111111111111111",
ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host", ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host",
Paths: []string{"/x"}}, Paths: []string{"/x"},
},
}, time.Now().UTC()); err != nil { }, time.Now().UTC()); err != nil {
t.Fatalf("replace 1: %v", err) t.Fatalf("replace 1: %v", err)
} }
+5 -5
View File
@@ -183,7 +183,7 @@ func (st *Store) ListSourceGroupsByHost(ctx context.Context, hostID string) ([]S
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list source groups: %w", err) return nil, fmt.Errorf("store: list source groups: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []SourceGroup{} out := []SourceGroup{}
for rows.Next() { for rows.Next() {
g, err := scanSourceGroupRow(rows) g, err := scanSourceGroupRow(rows)
@@ -220,10 +220,10 @@ type sourceGroupScanner interface {
func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) { func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
var ( var (
out SourceGroup out SourceGroup
includes, excludes, retention string includes, excludes, retention string
conflict sql.NullString conflict sql.NullString
createdAt, updatedAt string createdAt, updatedAt string
) )
err := s.Scan(&out.ID, &out.HostID, &out.Name, err := s.Scan(&out.ID, &out.HostID, &out.Name,
&includes, &excludes, &retention, &includes, &excludes, &retention,
+1 -1
View File
@@ -177,7 +177,7 @@ func TestPendingRunQueue(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
if err := s.EnqueuePendingRun(ctx, &PendingRun{ if err := s.EnqueuePendingRun(ctx, &PendingRun{
ID: "01HPEND00000000000000001", ID: "01HPEND00000000000000001",
ScheduleID: schedID, SourceGroupID: gid, HostID: hostID, ScheduleID: schedID, SourceGroupID: gid, HostID: hostID,
NextAttemptAt: now.Add(-time.Second), // already due NextAttemptAt: now.Add(-time.Second), // already due
ScheduledAt: now.Add(-time.Minute), ScheduledAt: now.Add(-time.Minute),
+4 -2
View File
@@ -34,10 +34,12 @@ func TestOpenAppliesMigrations(t *testing.T) {
} }
// Spot-check a few tables exist with expected columns. // 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", "credentials", "schedules", "jobs", "job_logs",
"snapshots", "alerts", "audit_log", "snapshots", "alerts", "audit_log",
"enrollment_tokens", "host_schedule_version"} "enrollment_tokens", "host_schedule_version",
}
for _, tbl := range tables { for _, tbl := range tables {
row := s.DB().QueryRow( row := s.DB().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl) `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl)
+15 -14
View File
@@ -20,6 +20,7 @@ type User struct {
// Role enumerates the access tiers from spec.md §7.2. // Role enumerates the access tiers from spec.md §7.2.
type Role string type Role string
// Defined Role values, in descending order of privilege.
const ( const (
RoleAdmin Role = "admin" RoleAdmin Role = "admin"
RoleOperator Role = "operator" RoleOperator Role = "operator"
@@ -73,12 +74,12 @@ type Host struct {
// only. forget/prune/check are repo-level cadences on // only. forget/prune/check are repo-level cadences on
// HostRepoMaintenance, not schedule kinds. // HostRepoMaintenance, not schedule kinds.
type Schedule struct { type Schedule struct {
ID string ID string
HostID string HostID string
CronExpr string CronExpr string
Enabled bool Enabled bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
// SourceGroupIDs is populated by ListSchedulesByHost (joins // SourceGroupIDs is populated by ListSchedulesByHost (joins
// schedule_source_groups) and accepted on Create / Update so the // schedule_source_groups) and accepted on Create / Update so the
// caller passes the desired junction state in one shape. // 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 // PendingRun queues a missed cron tick (agent was offline) for the
// server-side retry ticker to dispatch later. // server-side retry ticker to dispatch later.
type PendingRun struct { type PendingRun struct {
ID string ID string
ScheduleID string ScheduleID string
SourceGroupID string SourceGroupID string
HostID string HostID string
Attempt int Attempt int
NextAttemptAt time.Time NextAttemptAt time.Time
ScheduledAt time.Time // original cron tick — forensic / audit ScheduledAt time.Time // original cron tick — forensic / audit
LastError string LastError string
} }
// EnrollmentToken is the issuer's view of a one-time token. // EnrollmentToken is the issuer's view of a one-time token.
+21 -9
View File
@@ -142,16 +142,28 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 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. - **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. - **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: > **Row-design rule (binding for every list-row template in this app, current and future):**
- `/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. > Whole-row click navigates to the row's primary detail/edit page —
- `/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). > mirror `.host-row.clickable` on the dashboard
- `/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). > (`partials/host_row.html`): an absolute-positioned `.row-link`
- `/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. > overlay with `text-indent: -9999px` covers the row, action buttons
- **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. > live in `.row-action` cells that sit above via z-index. **Do not
- **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). > add an explicit "Edit" button** when the row is clickable — it
- Header "version N · agent in sync / agent at vM" indicator preserved (still backed by `host_schedule_version` + `applied_schedule_version`). > 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 `<a>` 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. - 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 ### P2 redesign — Phase 5 (server-side maintenance ticker) — TODO
+4
View File
@@ -7,5 +7,9 @@ package web
import "embed" 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/* //go:embed templates/* static/*
var FS embed.FS var FS embed.FS
File diff suppressed because one or more lines are too long
+106
View File
@@ -186,6 +186,112 @@
.host-row.clickable > .row-link { pointer-events: auto; } .host-row.clickable > .row-link { pointer-events: auto; }
.host-row.clickable > .row-action { 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 viewer ---------- */
.log { .log {
background: var(--bg); border: 1px solid var(--line-soft); background: var(--bg); border: 1px solid var(--line-soft);
+8 -91
View File
@@ -1,93 +1,13 @@
{{define "title"}}{{.Title}}{{end}} {{define "title"}}{{.Title}}{{end}}
{{define "content"}} {{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}} {{$page := .Page}}
{{$host := $page.Host}} {{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pt-7"> <div class="max-w-[1280px] mx-auto px-8 pb-14">
<div class="crumbs"><a href="/">Dashboard</a><span class="sep">/</span><span class="text-ink-mid">{{$host.Name}}</span></div>
{{/* ---------- header ---------- */}}
<div class="flex items-start justify-between mt-3.5">
<div>
<div class="flex items-center gap-3">
{{if eq $host.Status "online"}}
<span class="dot dot-online{{if $host.CurrentJobID}} pulse{{end}}"></span>
{{else if eq $host.Status "degraded"}}
<span class="dot dot-degraded"></span>
{{else if eq $host.Status "offline"}}
<span class="dot dot-offline"></span>
{{else}}
<span class="dot dot-failed"></span>
{{end}}
<h1 class="mono text-[26px] font-medium tracking-[0.005em] text-ink">{{$host.Name}}</h1>
<div class="flex gap-1.5">{{range $host.Tags}}<span class="tag">{{.}}</span>{{end}}</div>
</div>
<div class="flex items-center gap-3 mt-3 text-[13px] text-ink-mute">
<span class="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span>
<span class="text-ink-fade">·</span>
<span>agent <span class="mono text-ink-mid">{{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}}</span></span>
<span class="text-ink-fade">·</span>
<span>restic <span class="mono text-ink-mid">{{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}}</span></span>
<span class="text-ink-fade">·</span>
{{if eq $host.Status "offline"}}
<span>last seen <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
{{else}}
<span>online · last heartbeat <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
{{end}}
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn" disabled title="per-source-group Run-now lands in P2 Phase 4">Run&nbsp;backup&nbsp;now</button>
<button class="btn">Edit credentials</button>
<button class="btn btn-ghost text-base px-2.5"></button>
</div>
</div>
{{/* ---------- vitals strip ---------- */}}
<div class="grid grid-cols-12 gap-6 mt-6 py-5 border-y border-line-soft">
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Last backup</div>
<div class="mono text-[18px] text-ink mt-1">
{{if eq (deref $host.LastBackupStatus) "succeeded"}}
<span class="text-ok">succeeded</span>
{{else if eq (deref $host.LastBackupStatus) "failed"}}
<span class="text-bad">failed</span>
{{else if eq (deref $host.LastBackupStatus) "cancelled"}}
<span class="text-warn">cancelled</span>
{{else}}
<span class="text-ink-fade italic">never run</span>
{{end}}
{{if $host.LastBackupAt}} · {{relTime $host.LastBackupAt}}{{end}}
</div>
</div>
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Repo size</div>
<div class="mono text-[18px] text-ink mt-1">{{bytes $host.RepoSizeBytes}}</div>
</div>
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Snapshots</div>
<div class="mono text-[18px] text-ink mt-1">{{comma $host.SnapshotCount}}</div>
</div>
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Open alerts</div>
<div class="mono text-[18px] mt-1 {{if gt $host.OpenAlertCount 0}}text-bad{{else}}text-ok{{end}}">
{{if eq $host.OpenAlertCount 0}}0 · all clear{{else}}{{$host.OpenAlertCount}} · review →{{end}}
</div>
</div>
</div>
{{/* ---------- secondary tabs ---------- */}}
<div class="flex items-end mt-1.5">
<a class="sub-tab active" href="/hosts/{{$host.ID}}">Snapshots <span class="mono text-ink-fade text-[11px] ml-1">{{comma $host.SnapshotCount}}</span></a>
<div class="sub-tab" title="schedules UI lands in P2 Phase 4">Schedules</div>
<div class="sub-tab">Jobs</div>
<div class="sub-tab">Repo</div>
<div class="sub-tab">Settings</div>
</div>
{{/* ---------- snapshots tab ---------- */}} {{/* ---------- snapshots tab ---------- */}}
<div class="grid grid-cols-12 gap-6 pt-6 pb-14 items-start"> <div class="grid grid-cols-12 gap-6 pt-6 items-start">
<div class="col-span-9"> <div class="col-span-9">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
@@ -106,7 +26,7 @@
Once a backup completes, the agent will refresh this list automatically. Once a backup completes, the agent will refresh this list automatically.
</p> </p>
<div class="mt-5"> <div class="mt-5">
<button class="btn" disabled title="per-source-group Run-now lands in P2 Phase 4">Run now</button> <a href="/hosts/{{$host.ID}}/sources" class="btn">Open Sources →</a>
</div> </div>
</div> </div>
{{else}} {{else}}
@@ -150,13 +70,10 @@
<div class="panel rounded-[7px] px-4 py-3.5"> <div class="panel rounded-[7px] px-4 py-3.5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Run-now</div> <div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Run-now</div>
<div class="flex flex-col gap-1.5"> <p class="text-[12px] text-ink-mute leading-[1.55] mb-2">
<button class="btn justify-start w-full" disabled title="per-source-group Run-now lands in P2 Phase 4">backup <span class="text-[10px] text-ink-fade ml-1.5">P2</span></button> Run-now lives on individual source groups now —
<button class="btn justify-start w-full" disabled title="lands with P2-05">forget <span class="text-[10px] text-ink-fade ml-1.5">P2</span></button> <a href="/hosts/{{$host.ID}}/sources" class="underline">open Sources →</a>
<button class="btn justify-start w-full" disabled title="lands with P2-06">prune <span class="text-[10px] text-ink-fade ml-1.5">admin</span></button> </p>
<button class="btn justify-start w-full" disabled title="lands with P2-07">check <span class="text-[10px] text-ink-fade ml-1.5">P2</span></button>
<button class="btn justify-start w-full" disabled title="lands with P2-08">unlock <span class="text-[10px] text-ink-fade ml-1.5">P2</span></button>
</div>
</div> </div>
<div class="panel rounded-[7px] px-4 py-3.5"> <div class="panel rounded-[7px] px-4 py-3.5">
+210
View File
@@ -0,0 +1,210 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6 grid grid-cols-12 gap-6 items-start">
<div class="col-span-8">
{{/* ---------- Connection ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Connection</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/credentials" class="panel rounded-[7px] p-5">
{{if $page.CredentialsError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{$page.CredentialsError}}
</div>
{{end}}
{{if eq $page.SavedSection "credentials"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="field-label" for="repo_url">Repo URL</label>
<input id="repo_url" name="repo_url" type="text" class="field mono" value="{{$page.RepoURL}}" required />
<div class="field-help">e.g. <span class="mono text-ink-mid">rest:http://192.168.0.99:8000/{{$host.Name}}/</span></div>
</div>
<div>
<label class="field-label" for="repo_username">Username</label>
<input id="repo_username" name="repo_username" type="text" class="field mono" value="{{$page.RepoUsername}}" />
<div class="field-help">Sent as the rest-server <span class="mono text-ink-mid">--htpasswd</span> user.</div>
</div>
<div class="col-span-2">
<label class="field-label" for="repo_password">Password</label>
<input id="repo_password" name="repo_password" type="password" class="field mono" placeholder="{{if $page.HasPassword}}•••••••••••••••• · stored, leave blank to keep{{else}}— not yet set —{{end}}" autocomplete="new-password" />
<div class="field-help">Stored AEAD-encrypted; pushed to the agent over WS. Leave blank to keep the existing password.</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2">
<button type="submit" class="btn btn-primary">Save credentials</button>
</div>
</form>
{{/* ---------- Bandwidth ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Bandwidth · host-wide</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/bandwidth" class="panel rounded-[7px] p-5">
{{if $page.BandwidthError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{$page.BandwidthError}}
</div>
{{end}}
{{if eq $page.SavedSection "bandwidth"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="field-label" for="bandwidth_up">Upload limit <span class="text-ink-fade">· KB/s · blank = no cap</span></label>
<input id="bandwidth_up" name="bandwidth_up" type="number" min="0" class="field mono" value="{{$page.BandwidthUp}}" placeholder="—" />
</div>
<div>
<label class="field-label" for="bandwidth_down">Download limit <span class="text-ink-fade">· KB/s · blank = no cap</span></label>
<input id="bandwidth_down" name="bandwidth_down" type="number" min="0" class="field mono" value="{{$page.BandwidthDown}}" placeholder="—" />
</div>
</div>
<div class="field-help mt-3">
Applies to every backup, restore, and prune job for this host. Maps to <span class="mono text-ink-mid">restic --limit-upload</span> / <span class="mono text-ink-mid">--limit-download</span>.
</div>
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2">
<button type="submit" class="btn btn-primary">Save bandwidth caps</button>
</div>
</form>
{{/* ---------- Maintenance ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Maintenance · server-side cadences</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/maintenance" class="panel rounded-[7px] p-5">
{{if $page.MaintenanceError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{$page.MaintenanceError}}
</div>
{{end}}
{{if eq $page.SavedSection "maintenance"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
{{$m := $page.Maintenance}}
<div class="grid grid-cols-12 gap-3 items-center text-[13px] mb-3 text-[11px] uppercase tracking-[0.08em] text-ink-fade">
<div class="col-span-2">Verb</div>
<div class="col-span-5">Cron cadence</div>
<div class="col-span-3">Notes</div>
<div class="col-span-2 text-right">Enabled</div>
</div>
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
<div class="col-span-2 mono text-ink font-medium">forget</div>
<div class="col-span-5"><input type="text" name="forget_cron" class="field mono" value="{{$m.ForgetCron}}" required /></div>
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">Per source group, using each group's retention policy.</div>
<div class="col-span-2 text-right">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="forget_enabled" value="1" {{if $m.ForgetEnabled}}checked{{end}} class="w-3.5 h-3.5" />
<span class="mono text-[11px]">on</span>
</label>
</div>
</div>
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
<div class="col-span-2 mono text-ink font-medium">prune</div>
<div class="col-span-5"><input type="text" name="prune_cron" class="field mono" value="{{$m.PruneCron}}" required /></div>
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">Reclaims storage made dead by forget. Heavy — weekly only.</div>
<div class="col-span-2 text-right">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="prune_enabled" value="1" {{if $m.PruneEnabled}}checked{{end}} class="w-3.5 h-3.5" />
<span class="mono text-[11px]">on</span>
</label>
</div>
</div>
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
<div class="col-span-2 mono text-ink font-medium">check</div>
<div class="col-span-5"><input type="text" name="check_cron" class="field mono" value="{{$m.CheckCron}}" required /></div>
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">
<span class="mono text-ink-mid">--read-data-subset</span>
<input type="number" name="check_subset_pct" min="0" max="100" value="{{$m.CheckSubsetPct}}" class="field mono inline-block w-16 px-2 py-1" style="font-size: 11px;" />%
</div>
<div class="col-span-2 text-right">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="check_enabled" value="1" {{if $m.CheckEnabled}}checked{{end}} class="w-3.5 h-3.5" />
<span class="mono text-[11px]">on</span>
</label>
</div>
</div>
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2 items-center">
<button type="submit" class="btn btn-primary">Save cadences</button>
<span class="text-[12px] text-ink-fade ml-2">Server-side ticker drives execution — independent of the agent's cron.</span>
</div>
</form>
{{/* ---------- Danger zone ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-bad mt-9 mb-3.5">Danger zone</h2>
<div class="panel rounded-[7px] p-5"
style="border-color: color-mix(in oklch, var(--bad), transparent 70%);">
<div class="flex items-start justify-between gap-6">
<div class="flex-1">
<div class="text-[14px] font-semibold text-ink">Re-initialise repo</div>
<p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6] mt-2 max-w-[580px]">
Tries to <span class="mono text-ink-mid">DELETE</span> the rest-server's copy of this repo, then runs
<span class="mono text-ink-mid">restic init</span> against the empty path. Most rest-server setups run with
<span class="mono text-ink-mid">--append-only</span> and refuse the DELETE — the future P2R-09 flow surfaces
guided cleanup steps in that case.
</p>
<p class="text-[12px] text-ink-fade leading-[1.55] mt-2">
All snapshots are lost; this host's schedule version stays the same and the agent's
<span class="mono text-ink-mid">secrets.enc</span> is reused.
</p>
</div>
<button class="btn btn-danger btn-lg flex-none" disabled
title="re-init flow lands in P2R-09">Re-init repo…</button>
</div>
</div>
</div>
{{/* ---------- right rail ---------- */}}
<aside class="col-span-4">
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Storage</h2>
<div class="panel rounded-[7px] p-5">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Repo size</div>
<div class="mono text-[20px] text-ink mt-1">{{bytes $host.RepoSizeBytes}}</div>
</div>
<div>
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Snapshots</div>
<div class="mono text-[20px] text-ink mt-1">{{comma $host.SnapshotCount}}</div>
<div class="text-[11.5px] text-ink-mute mt-0.5">across {{len $page.GroupNames}} source group{{if ne (len $page.GroupNames) 1}}s{{end}}</div>
</div>
</div>
</div>
{{if gt (len $page.GroupNames) 0}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Snapshots by source</h2>
<div class="panel rounded-[7px] p-4">
<div class="grid items-baseline text-[13px]" style="grid-template-columns: 1fr auto auto; gap: 8px 14px;">
{{range $page.GroupNames}}
<span class="mono text-ink">{{.}}</span>
<span class="mono text-ink-mute text-right">{{index $page.SnapshotsByTag .}}</span>
<span class="mono text-ink-fade text-[11px]">snapshots</span>
{{end}}
{{if gt $page.UntaggedSnapshots 0}}
<span class="mono text-ink-fade italic">untagged</span>
<span class="mono text-ink-mute text-right">{{$page.UntaggedSnapshots}}</span>
<span class="mono text-ink-fade text-[11px]">snapshots</span>
{{end}}
</div>
</div>
{{end}}
<div class="panel rounded-[6px] px-4 py-3.5 mt-5" style="background: var(--bg);">
<div class="text-[11px] uppercase tracking-[0.08em] text-ink-fade">Untagged snapshots</div>
<p class="text-[12px] text-ink-mid mt-1.5 leading-[1.55]">
Any snapshot not tagged with one of this host's source groups is left alone — forget never touches it. Useful if someone runs
<span class="mono text-ink-mid">restic backup</span> outside restic-manager; nothing here will silently delete those.
</p>
</div>
</aside>
</div>
{{end}}
+84
View File
@@ -0,0 +1,84 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
{{$groupNames := $page.GroupNames}}
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
<div class="flex items-center justify-between mb-4">
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] max-w-[760px]">
A schedule is a cron expression pointing at one or more source groups. When it fires, the agent runs a separate
<span class="mono text-ink-mid">restic backup</span> per chosen group — independent jobs, independent snapshots,
independent retention. Failure of one group doesn't fail the others.
</p>
<a href="/hosts/{{$host.ID}}/schedules/new" class="btn btn-primary whitespace-nowrap">+ New schedule</a>
</div>
{{if eq (len $page.Schedules) 0}}
<div class="panel rounded-[7px] empty-state" style="border-radius: 7px;">
<h3 class="text-base font-medium tracking-[-0.005em]">No schedules yet.</h3>
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
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.
</p>
<div class="mt-5">
<a href="/hosts/{{$host.ID}}/schedules/new" class="btn btn-primary">+ New schedule</a>
</div>
</div>
{{else}}
<div class="panel rounded-[7px] overflow-hidden">
<div class="schd-row head hairline">
<div>Status</div>
<div>Cron</div>
<div>Sources</div>
<div></div>
</div>
{{range $i, $sc := $page.Schedules}}
<div class="schd-row clickable {{if not (eq $i 0)}}hairline{{end}}">
<a href="/hosts/{{$host.ID}}/schedules/{{$sc.ID}}/edit" class="row-link" aria-label="Edit schedule">edit</a>
<div>
{{if $sc.Enabled}}
<span class="mono text-[11px] text-ok">enabled</span>
{{else}}
<span class="mono text-[11px] text-ink-fade">paused</span>
{{end}}
</div>
<div class="mono {{if $sc.Enabled}}text-ink{{else}}text-ink-mute{{end}}">{{$sc.CronExpr}}</div>
<div class="flex gap-1.5 flex-wrap">
{{range $sc.SourceGroupIDs}}
{{$name := index $groupNames .}}
<span class="tag" style="border-color: color-mix(in oklch, var(--accent), transparent 60%); color: var(--accent); {{if not $sc.Enabled}}opacity: 0.6;{{end}}">{{if $name}}{{$name}}{{else}}<span class="text-ink-fade">unknown</span>{{end}}</span>
{{end}}
</div>
<div class="flex gap-1.5 justify-end row-action">
{{if eq $host.Status "online"}}
{{if $sc.Enabled}}
<button class="btn btn-primary"
hx-post="/hosts/{{$host.ID}}/schedules/{{$sc.ID}}/run"
hx-swap="none"
hx-disabled-elt="this">Run now</button>
{{else}}
<button class="btn"
hx-post="/hosts/{{$host.ID}}/schedules/{{$sc.ID}}/run"
hx-swap="none"
hx-disabled-elt="this"
hx-confirm="This schedule is paused — running it now won't change that. Fire it once anyway?"
title="schedule is paused; click to fire one ad-hoc run anyway">Run now</button>
{{end}}
{{else}}
<button class="btn" disabled title="host is offline">Run now</button>
{{end}}
<form method="post" action="/hosts/{{$host.ID}}/schedules/{{$sc.ID}}/delete" style="display: inline;"
onsubmit="return confirm('Delete this schedule? Existing snapshots are not affected.');">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
+91
View File
@@ -0,0 +1,91 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
<div class="flex items-center justify-between mb-4">
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] max-w-[720px]">
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 <span class="mono text-ink-mid">restic backup</span> runs per group,
tagged by name so <span class="mono text-ink-mid">forget</span> can apply retention cleanly.
</p>
<a href="/hosts/{{$host.ID}}/sources/new" class="btn btn-primary whitespace-nowrap">+ New source group</a>
</div>
{{if eq (len $page.Groups) 0}}
<div class="panel rounded-[7px] empty-state" style="border-radius: 7px;">
<h3 class="text-base font-medium tracking-[-0.005em]">No source groups yet.</h3>
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
Create one to tell the agent what to back up. The group's name doubles as the snapshot tag.
</p>
<div class="mt-5">
<a href="/hosts/{{$host.ID}}/sources/new" class="btn btn-primary">+ New source group</a>
</div>
</div>
{{else}}
<div class="panel rounded-[7px] overflow-hidden">
{{range $i, $row := $page.Groups}}
{{$g := $row.Group}}
<div class="src-row clickable {{if not (eq $i 0)}}hairline{{end}}">
<a href="/hosts/{{$host.ID}}/sources/{{$g.ID}}/edit" class="row-link" aria-label="Edit {{$g.Name}}">{{$g.Name}}</a>
<div>
<div class="flex items-center" style="gap: 10px;">
<span class="tag mono" style="border-color: color-mix(in oklch, var(--accent), transparent 60%); color: var(--accent);">{{$g.Name}}</span>
{{if $g.ConflictDimension}}
<span class="tag" title="keep-{{$g.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket. Either drop the keep-{{$g.ConflictDimension}} value or add a finer-grained schedule."
style="border-color: color-mix(in oklch, var(--warn), transparent 60%); color: var(--warn); cursor: help;">keep-{{$g.ConflictDimension}} · cadence mismatch</span>
{{end}}
</div>
<div class="mono text-[12px] text-ink-mid mt-2">
{{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}}
</div>
<div class="text-[11.5px] text-ink-fade mt-1">
{{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}} · <span class="mono">{{$row.SnapshotCount}}</span> snapshot{{if ne $row.SnapshotCount 1}}s{{end}}{{end}}
</div>
</div>
<div class="flex justify-end row-action" style="gap: 6px;">
{{if and (gt (len $g.Includes) 0) (eq $host.Status "online")}}
<button class="btn btn-primary"
hx-post="/hosts/{{$host.ID}}/source-groups/{{$g.ID}}/run"
hx-swap="none"
hx-disabled-elt="this">Run now</button>
{{else}}
<button class="btn" disabled
title="{{if eq (len $g.Includes) 0}}add at least one include path before running{{else}}host is offline{{end}}">Run now</button>
{{end}}
{{if gt $row.UsedBy 0}}
<button class="btn btn-danger" disabled
title="remove this group from {{$row.UsedBy}} schedule{{if ne $row.UsedBy 1}}s{{end}} first">Delete</button>
{{else if eq (len $page.Groups) 1}}
<button class="btn btn-danger" disabled
title="this is the host's only source group — create another one first">Delete</button>
{{else}}
<form method="post" action="/hosts/{{$host.ID}}/sources/{{$g.ID}}/delete" style="display: inline;"
onsubmit="return confirm('Delete source group &quot;{{$g.Name}}&quot;? Existing snapshots are not affected.');">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="text-[11.5px] text-ink-fade mt-4 leading-[1.65]">
Run-now on a row dispatches one immediate backup using that group's paths and tag.
Group <span class="mono text-ink-mid">name</span> is used as the snapshot tag — renaming a group
doesn't retag existing snapshots.
</div>
{{end}}
</div>
{{end}}
+94 -169
View File
@@ -1,198 +1,123 @@
{{define "title"}}{{.Title}}{{end}} {{define "title"}}{{.Title}}{{end}}
{{define "content"}} {{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}} {{$page := .Page}}
{{$host := $page.Host}} {{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pt-9 pb-24"> {{$f := $page.Form}}
<div class="max-w-[1280px] mx-auto px-8 pb-24 pt-6">
<div class="crumbs"> <h1 class="text-[22px] font-medium tracking-[-0.005em] mt-1">
<a href="/">Dashboard</a><span class="sep">/</span>
<a href="/hosts/{{$host.ID}}">{{$host.Name}}</a><span class="sep">/</span>
<a href="/hosts/{{$host.ID}}/schedules">schedules</a><span class="sep">/</span>
<span class="text-ink-mid">{{if $page.IsNew}}new{{else}}edit{{end}}</span>
</div>
<h1 class="text-2xl font-medium tracking-[-0.012em] mt-2.5">
{{if $page.IsNew}}New schedule{{else}}Edit schedule{{end}} {{if $page.IsNew}}New schedule{{else}}Edit schedule{{end}}
<span class="text-ink-fade">·</span>
<span class="mono text-ink">{{$host.Name}}</span>
</h1> </h1>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[640px]">
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.
</p>
{{if $page.Error}} {{if $page.Error}}
<div class="mt-6 px-4 py-3 rounded-[5px] text-[13px]" <div class="mt-5 panel rounded-[6px] px-4 py-3 text-[13px]"
style="background: color-mix(in oklch, var(--bad), transparent 88%); style="border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); color: var(--ink);">
border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);
color: oklch(0.85 0.10 25);">
{{$page.Error}} {{$page.Error}}
</div> </div>
{{end}} {{end}}
<form method="post" <form method="post" action="{{$page.SaveAction}}" class="panel rounded-[7px] p-7 mt-6">
action="{{if $page.IsNew}}/hosts/{{$host.ID}}/schedules/new{{else}}/hosts/{{$host.ID}}/schedules/{{$page.ScheduleID}}/edit{{end}}" <div class="grid grid-cols-12 gap-6">
class="grid grid-cols-12 gap-8 mt-7">
<div class="col-span-7 panel rounded-[7px] px-8 py-7"> <div class="col-span-7">
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">When</h3>
<label class="field-label" for="cron">Cron expression</label>
<input type="text" id="cron" name="cron" class="field mono" value="{{$f.CronExpr}}" required autofocus />
<div class="flex flex-wrap gap-1.5 mt-2.5" id="cron-presets">
<span class="preset-chip" data-cron="0 3 * * *"
title="Every day at 03:00">0 3 * * *</span>
<span class="preset-chip" data-cron="@hourly"
title="Every hour, on the hour (00 minutes)">@hourly</span>
<span class="preset-chip" data-cron="0 */6 * * *"
title="Every 6 hours, on the hour (00:00, 06:00, 12:00, 18:00)">0 */6 * * *</span>
<span class="preset-chip" data-cron="0 3 * * 0"
title="Every Sunday at 03:00">0 3 * * 0</span>
<span class="preset-chip" data-cron="0 3 1 * *"
title="The 1st of every month at 03:00">0 3 1 * *</span>
</div>
<div class="field-help mt-2.5">
Standard 5-field cron with descriptors. Server validates with the same parser the agent uses to fire — what saves here is what runs.
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4">Kind</h3> <h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-6 pt-4 border-t border-line-soft">
<div class="mb-7"> What — pick one or more source groups
{{if $page.IsNew}} </h3>
<label class="field-label" for="se-kind">What does this schedule do?</label> {{if eq (len $page.AvailableGroups) 0}}
<select id="se-kind" name="kind" class="field mono" <div class="text-[12.5px] text-ink-mute leading-[1.6]">
onchange="document.querySelectorAll('[data-kind]').forEach(el => { el.style.display = el.dataset.kind === this.value ? '' : 'none'; });"> This host has no source groups yet — <a href="/hosts/{{$host.ID}}/sources/new" class="text-accent underline">create one first</a>
<option value="backup" {{if eq $page.Kind "backup"}}selected{{end}}>backup — snapshot the configured paths</option> so this schedule has something to back up.
<option value="forget" {{if eq $page.Kind "forget"}}selected{{end}}>forget — apply retention policy (rewrite the snapshot index)</option>
</select>
<div class="field-help">
<span class="mono text-ink-mid">backup</span> reads files and writes a snapshot.
<span class="mono text-ink-mid">forget</span> trims the index by your <strong>Keep-*</strong> rules without deleting data —
an admin-only <span class="mono text-ink-mid">prune</span> job (P2-06) reclaims the disk space later.
Other kinds (<span class="mono text-ink-mid">prune</span>, <span class="mono text-ink-mid">check</span>, <span class="mono text-ink-mid">unlock</span>) land in P2-06..08.
</div> </div>
{{else}} {{else}}
<input type="hidden" name="kind" value="{{$page.Kind}}"> <div class="grid grid-cols-1 gap-1.5" id="group-pickers">
<div class="text-[13px] text-ink-mid"> {{range $page.AvailableGroups}}
Kind: <span class="mono text-ink">{{$page.Kind}}</span> {{$checked := index $page.SelectedGroupIDs .ID}}
<span class="text-ink-fade">— immutable on edit; delete and recreate to switch kind.</span> <label class="picker {{if $checked}}checked{{end}}">
<input type="checkbox" name="source_group_ids" value="{{.ID}}" {{if $checked}}checked{{end}} />
<span class="check"></span>
<span class="mono text-ink flex-1">{{.Name}}</span>
<span class="text-[11.5px] text-ink-fade">
{{len .Includes}} include{{if ne (len .Includes) 1}}s{{end}} ·
{{.RetentionPolicy.Summary}}
</span>
</label>
{{end}}
</div>
<div class="field-help mt-2.5">
Each picked group runs as a separate <span class="mono text-ink-mid">restic backup</span> with its own tag — its own snapshot, its own retention. Pick multiple to fire them all on the same cron tick.
</div> </div>
{{end}} {{end}}
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">When</h3> <h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-6 pt-4 border-t border-line-soft">Status</h3>
<label class="flex items-center gap-2.5 text-[13px] cursor-pointer">
<div class="mb-5"> <input type="checkbox" name="enabled" value="1" {{if $f.Enabled}}checked{{end}} class="w-3.5 h-3.5" />
<label class="flex items-center gap-2.5 cursor-pointer text-[13px]">
<input id="se-manual" type="checkbox" name="manual" {{if $page.Manual}}checked{{end}}
onchange="document.getElementById('se-cron-block').style.display=this.checked?'none':'';">
<span>Manual schedule <span class="text-ink-fade font-normal">— no cron, only fires when you click Run-now</span></span>
</label>
</div>
<div id="se-cron-block" class="mb-5" {{if $page.Manual}}style="display: none;"{{end}}>
<label class="field-label" for="se-cron">Cron expression</label>
<input id="se-cron" name="cron_expr" type="text" class="field mono" value="{{$page.CronExpr}}">
<div class="field-help">
Standard 5-field cron with descriptors. Examples:
<span class="mono text-ink-mid">0 3 * * *</span> (daily 03:00),
<span class="mono text-ink-mid">@hourly</span>,
<span class="mono text-ink-mid">*/30 * * * *</span> (every 30 min).
Server validates with the same parser the agent uses to fire.
</div>
<div class="flex flex-wrap gap-1.5 mt-2.5">
{{range $cron := list "0 3 * * *" "0 */6 * * *" "@hourly" "0 3 * * 0" "0 3 1 * *"}}
<button type="button" class="btn btn-ghost mono text-[11px]"
onclick="document.getElementById('se-cron').value='{{$cron}}'">{{$cron}}</button>
{{end}}
</div>
</div>
<div data-kind="backup" {{if ne $page.Kind "backup"}}style="display: none;"{{end}}>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Paths</h3>
<div class="mb-5">
<label class="field-label" for="se-paths">Backup paths <span class="text-ink-fade font-normal">· one per line</span></label>
<textarea id="se-paths" name="paths" rows="4" class="field mono"
style="resize: vertical;"
placeholder="/etc&#10;/home&#10;/var/lib/postgresql">{{$page.PathsRaw}}</textarea>
<div class="field-help">What <span class="mono text-ink-mid">restic backup</span> walks. The agent runs as root with <span class="mono text-ink-mid">CAP_DAC_READ_SEARCH</span>, so any readable path is fair game.</div>
</div>
<div class="mb-7">
<label class="field-label" for="se-excludes">Excludes <span class="text-ink-fade font-normal">· optional, one per line</span></label>
<textarea id="se-excludes" name="excludes" rows="3" class="field mono"
style="resize: vertical;"
placeholder="*.tmp&#10;node_modules&#10;.cache">{{$page.ExcludesRaw}}</textarea>
<div class="field-help">Passed straight through as <span class="mono text-ink-mid">--exclude</span> args.</div>
</div>
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Tags <span class="text-ink-fade font-normal">· optional</span></h3>
<div class="mb-7">
<label class="field-label" for="se-tags">Tags <span class="text-ink-fade font-normal">· comma-separated</span></label>
<input id="se-tags" name="tags" type="text" class="field mono" placeholder="nightly, prod" value="{{$page.TagsRaw}}">
<div class="field-help">Attached to every snapshot this schedule produces. Useful for retention rules (P2-05).</div>
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Retention <span class="text-ink-fade font-normal">· optional, all blank = keep everything</span></h3>
<div class="grid grid-cols-3 gap-4 mb-7">
<div>
<label class="field-label" for="se-keep-last">Keep last</label>
<input id="se-keep-last" name="keep_last" type="number" min="0" class="field mono" value="{{$page.KeepLast}}">
</div>
<div>
<label class="field-label" for="se-keep-hourly">Keep hourly</label>
<input id="se-keep-hourly" name="keep_hourly" type="number" min="0" class="field mono" value="{{$page.KeepHourly}}">
</div>
<div>
<label class="field-label" for="se-keep-daily">Keep daily</label>
<input id="se-keep-daily" name="keep_daily" type="number" min="0" class="field mono" value="{{$page.KeepDaily}}">
</div>
<div>
<label class="field-label" for="se-keep-weekly">Keep weekly</label>
<input id="se-keep-weekly" name="keep_weekly" type="number" min="0" class="field mono" value="{{$page.KeepWeekly}}">
</div>
<div>
<label class="field-label" for="se-keep-monthly">Keep monthly</label>
<input id="se-keep-monthly" name="keep_monthly" type="number" min="0" class="field mono" value="{{$page.KeepMonthly}}">
</div>
<div>
<label class="field-label" for="se-keep-yearly">Keep yearly</label>
<input id="se-keep-yearly" name="keep_yearly" type="number" min="0" class="field mono" value="{{$page.KeepYearly}}">
</div>
</div>
<div class="text-[12px] text-ink-mute leading-[1.55] mb-7">
Applied by <span class="mono text-ink-mid">restic forget</span> when the prune job kind lands in P2-05. Mirrors restic's <span class="mono text-ink-mid">--keep-*</span> flags one-for-one.
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Bandwidth <span class="text-ink-fade font-normal">· optional</span></h3>
<div class="grid grid-cols-2 gap-4 mb-7">
<div>
<label class="field-label" for="se-up">Limit upload <span class="text-ink-fade font-normal">· KB/s</span></label>
<input id="se-up" name="limit_up_kbps" type="number" min="0" class="field mono" value="{{$page.LimitUpKBps}}">
</div>
<div>
<label class="field-label" for="se-down">Limit download <span class="text-ink-fade font-normal">· KB/s</span></label>
<input id="se-down" name="limit_down_kbps" type="number" min="0" class="field mono" value="{{$page.LimitDownKBps}}">
</div>
</div>
<div class="pt-6 border-t border-line-soft">
<label class="flex items-center gap-2.5 cursor-pointer text-[13px]">
<input type="checkbox" name="enabled" {{if $page.Enabled}}checked{{end}}>
<span>Enabled</span> <span>Enabled</span>
<span class="text-ink-fade">— uncheck to keep the row but stop it from firing.</span> <span class="text-ink-fade">— uncheck to keep the row but stop firing.</span>
</label> </label>
<div class="mt-6 pt-4 border-t border-line-soft flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create schedule{{else}}Save changes{{end}}</button>
<a href="/hosts/{{$host.ID}}/schedules" class="btn btn-lg">Cancel</a>
</div>
</div> </div>
<div class="flex gap-2 pt-7"> <aside class="col-span-5 border-l border-line-soft pl-6">
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create schedule{{else}}Save changes{{end}}</button> <div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-3">No paths, no retention, no kind</div>
<a href="/hosts/{{$host.ID}}/schedules" class="btn btn-lg">Cancel</a> <p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6]">
</div> That stuff lives on source groups now. A schedule's only job is to be the cron expression and to point at the groups it should fire.
Change a group's retention, every schedule that points at it inherits the change without further edits.
</p>
<p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6] mt-3">
<strong>Forget / prune / check are not schedule kinds anymore.</strong>
They run on host-level cadences from the
<a href="/hosts/{{$host.ID}}/repo" class="text-accent underline">Repo tab</a>.
</p>
<div class="panel rounded-[6px] px-4 py-3.5 mt-4" style="background: var(--bg);">
<div class="text-[11px] uppercase tracking-[0.08em] text-ink-fade">If the agent is offline at fire time</div>
<p class="text-[12px] text-ink-mid mt-1.5 leading-[1.55]">
Server retries per the group's retry policy (max attempts + exponential backoff).
</p>
</div>
</aside>
</div> </div>
<aside class="col-span-5">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">How this works</div>
<ol class="list-none p-0 m-0 space-y-4">
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span>
<div class="text-[13px] text-ink font-medium">Server is the source of truth</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Saving here bumps <span class="mono text-ink-mid">host_schedule_version</span> and pushes the new set to the agent over WS. Offline agents catch up on reconnect.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span>
<div class="text-[13px] text-ink font-medium">Agent fires locally</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">On each tick the agent sends <span class="mono text-ink-mid">schedule.fire</span>; the server creates a job row (<span class="mono text-ink-mid">actor_kind=schedule</span>) and ships <span class="mono text-ink-mid">command.run</span> back. Same job lifecycle as run-now.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span>
<div class="text-[13px] text-ink font-medium">Missed ticks fire on reconnect</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">By design — the operator wants the missed backup to run, not be silently skipped because the agent was bouncing.</div>
</li>
</ol>
</aside>
</form> </form>
</div> </div>
<script>
// Preset chip → fill cron input. Group picker → toggle the
// "checked" class so the visual state tracks the underlying box.
(function () {
var cronInput = document.getElementById('cron');
document.querySelectorAll('#cron-presets .preset-chip').forEach(function (chip) {
chip.addEventListener('click', function () { cronInput.value = chip.dataset.cron; });
});
document.querySelectorAll('#group-pickers .picker').forEach(function (label) {
var box = label.querySelector('input[type="checkbox"]');
box.addEventListener('change', function () {
label.classList.toggle('checked', box.checked);
});
});
})();
</script>
{{end}} {{end}}
-105
View File
@@ -1,105 +0,0 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
{{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pt-7 pb-14">
<div class="crumbs">
<a href="/">Dashboard</a><span class="sep">/</span>
<a href="/hosts/{{$host.ID}}">{{$host.Name}}</a><span class="sep">/</span>
<span class="text-ink-mid">schedules</span>
</div>
{{/* ---------- header ---------- */}}
<div class="flex items-start justify-between mt-3.5">
<div>
<div class="flex items-center gap-3">
{{if eq $host.Status "online"}}
<span class="dot dot-online"></span>
{{else}}
<span class="dot dot-offline"></span>
{{end}}
<h1 class="text-[22px] font-medium tracking-[-0.01em]">
schedules <span class="text-ink-fade">·</span>
<span class="mono text-ink font-medium">{{$host.Name}}</span>
</h1>
<span class="mono text-[11px] text-ink-mute">version {{$page.Version}}{{if and (gt $page.Version 0) (ne $page.Version $page.AppliedVersion)}} <span class="text-warn">· agent at v{{$page.AppliedVersion}}</span>{{else if gt $page.Version 0}} <span class="text-ok">· agent in sync</span>{{end}}</span>
</div>
</div>
<div class="flex items-center gap-2">
<a href="/hosts/{{$host.ID}}/schedules/new" class="btn btn-primary">New schedule</a>
</div>
</div>
{{/* ---------- secondary tabs ---------- */}}
<div class="flex items-end mt-7">
<a class="sub-tab" href="/hosts/{{$host.ID}}">Snapshots <span class="mono text-ink-fade text-[11px] ml-1">{{comma $host.SnapshotCount}}</span></a>
<a class="sub-tab active" href="/hosts/{{$host.ID}}/schedules">Schedules <span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Schedules}}</span></a>
<div class="sub-tab">Jobs</div>
<div class="sub-tab">Repo</div>
<div class="sub-tab">Settings</div>
</div>
{{/* ---------- schedule rows ---------- */}}
<div class="panel rounded-[7px] mt-6 overflow-hidden">
{{if eq (len $page.Schedules) 0}}
<div class="empty-state" style="border: none; background: var(--panel);">
<h3 class="text-base font-medium tracking-[-0.005em]">No schedules yet.</h3>
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
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.
</p>
<div class="mt-5">
<a href="/hosts/{{$host.ID}}/schedules/new" class="btn btn-primary">New schedule</a>
</div>
</div>
{{else}}
<div class="hairline grid items-baseline px-4 py-2.5 text-[11px] text-ink-fade uppercase tracking-[0.08em]"
style="grid-template-columns: 0.55fr 1fr 1.7fr 1.1fr 0.5fr 240px; column-gap: 18px;">
<div>Status</div>
<div>When</div>
<div>Paths</div>
<div>Retention</div>
<div>Tags</div>
<div></div>
</div>
{{range $page.Schedules}}
<div class="grid items-center px-4 py-3 text-[13px] hairline"
style="grid-template-columns: 0.55fr 1fr 1.7fr 1.1fr 0.5fr 240px; column-gap: 18px;">
<div class="flex flex-col gap-0.5">
{{if .Enabled}}
<span class="mono text-[11px] text-ok">enabled</span>
{{else}}
<span class="mono text-[11px] text-ink-fade">disabled</span>
{{end}}
{{if .Manual}}
<span class="mono text-[10.5px] text-ink-mute">manual</span>
{{end}}
</div>
<div class="mono text-ink">{{if .Manual}}<span class="text-ink-fade">— run-now only —</span>{{else}}{{.CronExpr}}{{end}}</div>
<div class="mono text-ink-mid text-[12px] truncate" title="{{joinDot .Paths}}">{{joinDot .Paths}}</div>
<div class="mono text-[12px] text-ink-mid">{{.RetentionPolicy.Summary}}</div>
<div class="flex gap-1.5 flex-wrap">
{{- range .Tags -}}<span class="tag">{{.}}</span>{{- end -}}
</div>
<div class="text-right flex gap-1.5 justify-end">
{{if and .Enabled (eq $host.Status "online")}}
<button class="btn btn-primary whitespace-nowrap"
hx-post="/hosts/{{$host.ID}}/schedules/{{.ID}}/run"
hx-swap="none"
hx-disabled-elt="this">Run now</button>
{{end}}
<a href="/hosts/{{$host.ID}}/schedules/{{.ID}}/edit" class="btn whitespace-nowrap">Edit</a>
<form method="post" action="/hosts/{{$host.ID}}/schedules/{{.ID}}/delete" style="display: inline;"
onsubmit="return confirm('Delete this schedule? Existing snapshots are not affected.');">
<button type="submit" class="btn btn-danger whitespace-nowrap">Delete</button>
</form>
</div>
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
+132
View File
@@ -0,0 +1,132 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
{{$f := $page.Form}}
<div class="max-w-[1280px] mx-auto px-8 pb-24 pt-6">
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-1">
{{if $page.IsNew}}New source group{{else}}Edit source group <span class="mono text-ink-mid">·</span> <span class="mono">{{$f.Name}}</span>{{end}}
</h1>
<p class="text-pretty text-[13px] text-ink-mute max-w-[720px] mt-2 leading-[1.6]">
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.
</p>
{{if $page.Error}}
<div class="mt-5 panel rounded-[6px] px-4 py-3 text-[13px]"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); color: var(--ink);">
{{$page.Error}}
</div>
{{end}}
<form method="post" action="{{$page.SaveAction}}" class="grid grid-cols-12 gap-8 mt-7">
<div class="col-span-7 panel rounded-[7px] p-7">
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Identity</h3>
<div class="mb-5">
<label class="field-label" for="name">Name</label>
<input type="text" id="name" name="name" class="field mono" value="{{$f.Name}}" autofocus
required pattern="[a-z0-9][a-z0-9_-]*" />
<div class="field-help">Used as the snapshot tag. Lowercase, no spaces; matches what <span class="mono text-ink-mid">restic forget --tag</span> sees.</div>
</div>
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-5 pt-4 border-t border-line-soft">Paths</h3>
<div class="mb-4">
<label class="field-label" for="includes">Includes <span class="text-ink-fade">· one path per line</span></label>
<textarea id="includes" name="includes" class="field mono" rows="4" style="resize: vertical;">{{$f.Includes}}</textarea>
<div class="field-help">What <span class="mono text-ink-mid">restic backup</span> walks. Agent runs as root with <span class="mono text-ink-mid">CAP_DAC_READ_SEARCH</span>, so any readable path is fair game.</div>
</div>
<div class="mb-5">
<label class="field-label" for="excludes">Excludes <span class="text-ink-fade">· optional, one pattern per line</span></label>
<textarea id="excludes" name="excludes" class="field mono" rows="3" style="resize: vertical;">{{$f.Excludes}}</textarea>
<div class="field-help">Passed straight through as <span class="mono text-ink-mid">--exclude</span> args.</div>
</div>
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-5 pt-4 border-t border-line-soft">
Retention
<span class="text-ink-fade font-medium normal-case tracking-[0.01em] ml-2">applied nightly · all blank = keep everything</span>
</h3>
{{if and (not $page.IsNew) $f.ConflictDimension}}
<div class="mb-3.5 flex gap-3 items-start rounded-[6px] px-3.5 py-3"
style="border: 1px solid color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">
<div class="text-[16px] leading-none text-warn pt-[1px]"></div>
<div class="text-[12.5px] text-ink-mid leading-[1.55]">
<strong class="text-ink">keep-{{$f.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket.</strong>
Either drop <span class="mono text-ink">keep-{{$f.ConflictDimension}}</span> or add a finer-grained schedule.
</div>
</div>
{{end}}
<div class="grid grid-cols-3 gap-3">
<div class="keep-cell"><label>Keep last</label><input type="number" min="0" name="keep_last" value="{{$f.KeepLast}}" placeholder="—" /></div>
<div class="keep-cell"><label>Hourly</label><input type="number" min="0" name="keep_hourly" value="{{$f.KeepHourly}}" placeholder="—" /></div>
<div class="keep-cell"><label>Daily</label><input type="number" min="0" name="keep_daily" value="{{$f.KeepDaily}}" placeholder="—" /></div>
<div class="keep-cell"><label>Weekly</label><input type="number" min="0" name="keep_weekly" value="{{$f.KeepWeekly}}" placeholder="—" /></div>
<div class="keep-cell"><label>Monthly</label><input type="number" min="0" name="keep_monthly" value="{{$f.KeepMonthly}}" placeholder="—" /></div>
<div class="keep-cell"><label>Yearly</label><input type="number" min="0" name="keep_yearly" value="{{$f.KeepYearly}}" placeholder="—" /></div>
</div>
<div class="text-[11.5px] text-ink-fade mt-3 leading-[1.55]">
Blank fields stay unset (no constraint on that bucket). Forget runs nightly on the cadence configured on the
<a href="/hosts/{{$host.ID}}/repo" class="text-accent underline" style="text-underline-offset: 2px;">Repo tab</a>.
</div>
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-7 pt-4 border-t border-line-soft">
Retry on offline
<span class="text-ink-fade font-medium normal-case tracking-[0.01em] ml-2">cron-fired runs only</span>
</h3>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="retry_max">Max attempts</label>
<input type="number" min="0" id="retry_max" name="retry_max" class="field mono" value="{{$f.RetryMax}}" />
</div>
<div>
<label class="field-label" for="retry_backoff_seconds">Initial backoff (sec)</label>
<input type="number" min="0" id="retry_backoff_seconds" name="retry_backoff_seconds" class="field mono" value="{{$f.RetryBackoffSeconds}}" />
</div>
</div>
<div class="field-help mt-2">
Each retry doubles the wait. <strong>Manual run-now ignores this</strong> — it just fails immediately if the agent is offline.
</div>
<div class="mt-8 pt-4 border-t border-line-soft flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create group{{else}}Save changes{{end}}</button>
<a href="/hosts/{{$host.ID}}/sources" class="btn btn-lg">Cancel</a>
</div>
</div>
<aside class="col-span-5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-3.5">How this fits</div>
<ol class="list-none p-0 m-0 text-[13px]">
<li class="relative pl-9 pb-4">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span>
<div class="font-medium">Save here</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Bumps the host's schedule version; the agent picks up the new paths/retention on its next push (within seconds when online).</div>
</li>
<li class="relative pl-9 pb-4">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span>
<div class="font-medium">Schedules pointing here change behaviour</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Any schedule that includes this group in its picker now backs up the new paths next time it fires.</div>
</li>
<li class="relative pl-9 pb-4">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span>
<div class="font-medium">Retention applies on the next nightly forget</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Existing tagged snapshots get re-evaluated against the new keep-* rules. Untagged or differently-tagged snapshots are untouched.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">4</span>
<div class="font-medium">Run-now from the Sources list</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Want to test? Save, go back to <a href="/hosts/{{$host.ID}}/sources" class="text-accent underline">Sources</a>, hit Run-now on this row.</div>
</li>
</ol>
</aside>
</form>
</div>
{{end}}
+118
View File
@@ -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}}
<div class="max-w-[1280px] mx-auto px-8 pt-7">
<div class="crumbs">
<a href="/">Dashboard</a><span class="sep">/</span>
{{if eq $page.SubTab "snapshots"}}
<span class="text-ink-mid">{{$host.Name}}</span>
{{else}}
<a href="/hosts/{{$host.ID}}">{{$host.Name}}</a><span class="sep">/</span>
<span class="text-ink-mid">{{$page.Crumb}}</span>
{{end}}
</div>
{{/* ---------- header ---------- */}}
<div class="flex items-start justify-between mt-3.5">
<div>
<div class="flex items-center gap-3">
{{if eq $host.Status "online"}}
<span class="dot dot-online{{if $host.CurrentJobID}} pulse{{end}}"></span>
{{else if eq $host.Status "degraded"}}
<span class="dot dot-degraded"></span>
{{else if eq $host.Status "offline"}}
<span class="dot dot-offline"></span>
{{else}}
<span class="dot dot-failed"></span>
{{end}}
<h1 class="mono text-[26px] font-medium tracking-[0.005em] text-ink">{{$host.Name}}</h1>
<div class="flex gap-1.5">{{range $host.Tags}}<span class="tag">{{.}}</span>{{end}}</div>
{{if gt $page.ScheduleVersion 0}}
<span class="mono text-[11px] text-ink-mute ml-2">
version {{$page.ScheduleVersion}}
{{if eq $page.ScheduleVersion $host.AppliedScheduleVersion}}
<span class="text-ok">· agent in sync</span>
{{else}}
<span class="text-warn">· agent at v{{$host.AppliedScheduleVersion}}</span>
{{end}}
</span>
{{end}}
</div>
<div class="flex items-center gap-3 mt-3 text-[13px] text-ink-mute">
<span class="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span>
<span class="text-ink-fade">·</span>
<span>agent <span class="mono text-ink-mid">{{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}}</span></span>
<span class="text-ink-fade">·</span>
<span>restic <span class="mono text-ink-mid">{{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}}</span></span>
<span class="text-ink-fade">·</span>
{{if eq $host.Status "offline"}}
<span>last seen <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
{{else}}
<span>online · last heartbeat <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
{{end}}
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn" disabled title="per-source-group Run-now lives on the Sources tab">Run&nbsp;backup&nbsp;now</button>
<button class="btn">Edit credentials</button>
<button class="btn btn-ghost text-base px-2.5"></button>
</div>
</div>
{{/* ---------- vitals strip ---------- */}}
<div class="grid grid-cols-12 gap-6 mt-6 py-5 border-y border-line-soft">
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Last backup</div>
<div class="mono text-[18px] text-ink mt-1">
{{if eq (deref $host.LastBackupStatus) "succeeded"}}
<span class="text-ok">succeeded</span>
{{else if eq (deref $host.LastBackupStatus) "failed"}}
<span class="text-bad">failed</span>
{{else if eq (deref $host.LastBackupStatus) "cancelled"}}
<span class="text-warn">cancelled</span>
{{else}}
<span class="text-ink-fade italic">never run</span>
{{end}}
{{if $host.LastBackupAt}} · {{relTime $host.LastBackupAt}}{{end}}
</div>
</div>
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Repo size</div>
<div class="mono text-[18px] text-ink mt-1">{{bytes $host.RepoSizeBytes}}</div>
</div>
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Snapshots</div>
<div class="mono text-[18px] text-ink mt-1">{{comma $host.SnapshotCount}}</div>
</div>
<div class="col-span-3">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Open alerts</div>
<div class="mono text-[18px] mt-1 {{if gt $host.OpenAlertCount 0}}text-bad{{else}}text-ok{{end}}">
{{if eq $host.OpenAlertCount 0}}0 · all clear{{else}}{{$host.OpenAlertCount}} · review →{{end}}
</div>
</div>
</div>
{{/* ---------- secondary tabs ---------- */}}
<div class="flex items-end mt-1.5">
<a class="sub-tab {{if eq $page.SubTab "snapshots"}}active{{end}}" href="/hosts/{{$host.ID}}">Snapshots <span class="mono text-ink-fade text-[11px] ml-1">{{comma $host.SnapshotCount}}</span></a>
<a class="sub-tab {{if eq $page.SubTab "sources"}}active{{end}}" href="/hosts/{{$host.ID}}/sources">Sources <span class="mono text-ink-fade text-[11px] ml-1">{{$page.SourceGroupCount}}</span></a>
<a class="sub-tab {{if eq $page.SubTab "schedules"}}active{{end}}" href="/hosts/{{$host.ID}}/schedules">Schedules <span class="mono text-ink-fade text-[11px] ml-1">{{$page.ScheduleCount}}</span></a>
<a class="sub-tab {{if eq $page.SubTab "repo"}}active{{end}}" href="/hosts/{{$host.ID}}/repo">Repo</a>
<div class="sub-tab" title="lands later">Jobs</div>
<div class="sub-tab" title="lands later">Settings</div>
</div>
</div>
{{end}}
+37 -29
View File
@@ -1,58 +1,66 @@
{{define "host_row"}} {{define "host_row"}}
<div class="row-hover host-row clickable hairline {{.Status}}{{if eq (deref .LastBackupStatus) "failed"}} failed{{end}}"> {{$h := .Host}}
<a href="/hosts/{{.ID}}" class="row-link" aria-label="Open {{.Name}}">{{.Name}}</a> <div class="row-hover host-row clickable hairline {{$h.Status}}{{if eq (deref $h.LastBackupStatus) "failed"}} failed{{end}}">
<a href="/hosts/{{$h.ID}}" class="row-link" aria-label="Open {{$h.Name}}">{{$h.Name}}</a>
<div> <div>
{{- if eq .Status "online" -}} {{- if eq $h.Status "online" -}}
<span class="dot dot-online{{if .CurrentJobID}} pulse{{end}}"></span> <span class="dot dot-online{{if $h.CurrentJobID}} pulse{{end}}"></span>
{{- else if eq .Status "degraded" -}} {{- else if eq $h.Status "degraded" -}}
<span class="dot dot-degraded"></span> <span class="dot dot-degraded"></span>
{{- else if eq .Status "offline" -}} {{- else if eq $h.Status "offline" -}}
<span class="dot dot-offline"></span> <span class="dot dot-offline"></span>
{{- else -}} {{- else -}}
<span class="dot dot-failed"></span> <span class="dot dot-failed"></span>
{{- end -}} {{- end -}}
</div> </div>
<div class="mono {{if eq .Status "offline"}}text-ink-mid{{else}}text-ink{{end}} font-medium">{{.Name}}</div> <div class="mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}} font-medium">{{$h.Name}}</div>
<div class="mono text-ink-mid text-[12px]">{{.OS}}/{{.Arch}}</div> <div class="mono text-ink-mid text-[12px]">{{$h.OS}}/{{$h.Arch}}</div>
<div class="text-xs text-ink-mid"> <div class="text-xs text-ink-mid">
{{- if .CurrentJobID -}} {{- if $h.CurrentJobID -}}
<span class="text-accent">backup running…</span><br> <span class="text-accent">backup running…</span><br>
<span class="mono text-ink-fade">started {{relTime .LastBackupAt}}</span> <span class="mono text-ink-fade">started {{relTime $h.LastBackupAt}}</span>
{{- else if eq (deref .LastBackupStatus) "succeeded" -}} {{- else if eq (deref $h.LastBackupStatus) "succeeded" -}}
<span class="text-ok">succeeded</span> · <span class="mono">{{relTime .LastBackupAt}}</span> <span class="text-ok">succeeded</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
{{- else if eq (deref .LastBackupStatus) "failed" -}} {{- else if eq (deref $h.LastBackupStatus) "failed" -}}
<span class="text-bad font-medium">failed</span> · <span class="mono">{{relTime .LastBackupAt}}</span> <span class="text-bad font-medium">failed</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
{{- else if eq (deref .LastBackupStatus) "cancelled" -}} {{- else if eq (deref $h.LastBackupStatus) "cancelled" -}}
<span class="text-warn">cancelled</span> · <span class="mono">{{relTime .LastBackupAt}}</span> <span class="text-warn">cancelled</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
{{- else if eq .Status "offline" -}} {{- else if eq $h.Status "offline" -}}
<span class="text-ink-mute">last seen <span class="mono">{{relTime .LastSeenAt}}</span></span> <span class="text-ink-mute">last seen <span class="mono">{{relTime $h.LastSeenAt}}</span></span>
{{- else -}} {{- else -}}
<span class="text-ink-fade italic">never run</span> <span class="text-ink-fade italic">never run</span>
{{- end -}} {{- end -}}
</div> </div>
<div class="text-right mono {{if eq .Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes .RepoSizeBytes}}</div> <div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes $h.RepoSizeBytes}}</div>
<div class="text-right mono {{if eq .Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}"> <div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
{{- if eq .SnapshotCount 0 -}} {{- if eq $h.SnapshotCount 0 -}}
<span class="text-ink-fade"></span> <span class="text-ink-fade"></span>
{{- else -}} {{- else -}}
{{comma .SnapshotCount}} {{comma $h.SnapshotCount}}
{{- end -}} {{- end -}}
</div> </div>
<div class="text-right mono {{if gt .OpenAlertCount 0}}text-bad font-medium{{else}}text-ink-mute{{end}}"> <div class="text-right mono {{if gt $h.OpenAlertCount 0}}text-bad font-medium{{else}}text-ink-mute{{end}}">
{{- if eq .OpenAlertCount 0 -}}—{{- else -}}{{.OpenAlertCount}}{{- end -}} {{- if eq $h.OpenAlertCount 0 -}}—{{- else -}}{{$h.OpenAlertCount}}{{- end -}}
</div> </div>
<div class="flex gap-1.5 flex-wrap"> <div class="flex gap-1.5 flex-wrap">
{{- range .Tags -}} {{- range $h.Tags -}}
<span class="tag">{{.}}</span> <span class="tag">{{.}}</span>
{{- end -}} {{- end -}}
</div> </div>
<div class="text-right row-action"> <div class="text-right row-action">
{{- if eq .Status "offline" -}} {{- if eq $h.Status "offline" -}}
<span class="mono text-xs text-ink-fade">offline</span> <span class="mono text-xs text-ink-fade">offline</span>
{{- else if .CurrentJobID -}} {{- else if $h.CurrentJobID -}}
<a href="/jobs/{{deref .CurrentJobID}}" class="btn btn-ghost">View job →</a> <a href="/jobs/{{deref $h.CurrentJobID}}" class="btn btn-ghost">View job →</a>
{{- else if .RunAllScheduleID -}}
<button class="btn btn-primary whitespace-nowrap"
hx-post="/hosts/{{$h.ID}}/schedules/{{.RunAllScheduleID}}/run"
hx-swap="none"
hx-disabled-elt="this"
title="fire every backup this host knows about">Run all groups</button>
{{- else -}} {{- else -}}
<a href="/hosts/{{.ID}}" class="btn btn-ghost whitespace-nowrap" title="per-source-group Run-now lands in P2 Phase 4 — open the host">Open →</a> <a href="/hosts/{{$h.ID}}/sources" class="btn btn-ghost whitespace-nowrap"
title="multiple schedules — pick a source group from the host detail">Open →</a>
{{- end -}} {{- end -}}
</div> </div>
</div> </div>