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:
go-version: ${{ env.GO_VERSION }}
cache: true
- uses: golangci/golangci-lint-action@v6
- uses: golangci/golangci-lint-action@v7
with:
# v1.61 was built against Go 1.23 and refuses to load a
# config that targets a newer toolchain — go.mod is on 1.25.
# Bumping to a v2.x release built against current Go.
version: v2.1.6
# Must be built against the same Go release as go.mod targets,
# otherwise the linter refuses to load with "Go language
# version used to build golangci-lint is lower than the
# targeted Go version". v2.5.0 is the first v2.x line built
# with Go 1.25; bump in lockstep with go.mod.
version: v2.5.0
args: --timeout=5m
build:
+24 -18
View File
@@ -1,18 +1,17 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
disable-all: true
default: none
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofumpt
- goimports
- misspell
- revive
- bodyclose
@@ -21,22 +20,29 @@ linters:
- prealloc
- unconvert
- unparam
linters-settings:
goimports:
local-prefixes: gitea.dcglab.co.uk/steve/restic-manager
revive:
settings:
revive:
rules:
- name: exported
arguments: ["disableStutteringCheck"]
misspell:
locale: US
exclusions:
rules:
- name: exported
arguments: ["disableStutteringCheck"]
misspell:
locale: US
- path: _test\.go
linters:
- errcheck
- unparam
formatters:
enable:
- gofumpt
- goimports
settings:
goimports:
local-prefixes:
- gitea.dcglab.co.uk/steve/restic-manager
issues:
exclude-rules:
- path: _test\.go
linters:
- errcheck
- unparam
max-issues-per-linter: 0
max-same-issues: 0
+32 -9
View File
@@ -11,15 +11,38 @@ repos:
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
# Go-specific hooks. Local hooks (rather than third-party repos) so
# the version of each tool tracks whatever is on the developer's
# PATH, matching what they'd use to run the same checks by hand.
# Required tools:
# * go (toolchain matching go.mod)
# * gofumpt — `go install mvdan.cc/gofumpt@latest`
# * golangci-lint — `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6`
#
# Install + activate the hooks once per clone:
# pre-commit install
- repo: local
hooks:
- id: go-fmt
- id: go-imports
- id: go-vet-mod
- id: go-mod-tidy
- id: gofumpt
name: gofumpt
description: Format Go files with gofumpt (stricter superset of gofmt)
entry: bash -c 'PATH="$(go env GOPATH)/bin:$PATH" exec gofumpt -l -w "$@"' --
language: system
types: [go]
pass_filenames: true
- id: go-vet
name: go vet
description: Run go vet across all packages
entry: go vet ./...
language: system
types: [go]
pass_filenames: false
- repo: https://github.com/golangci/golangci-lint
rev: v1.61.0
hooks:
- id: golangci-lint
name: golangci-lint
description: Run golangci-lint against the whole module (matches CI)
entry: bash -c 'PATH="$(go env GOPATH)/bin:$PATH" exec golangci-lint run ./...'
language: system
types: [go]
pass_filenames: false
+8
View File
@@ -2,6 +2,14 @@
Project-specific rules for Claude when working in this repo.
## Run `go vet` before every commit
CI runs `go vet ./...` and will fail the build on any vet error.
Run it locally before staging a commit and fix anything it flags.
A common one is `res, _ := http.Do(...); defer res.Body.Close()`
if `err != nil` then `res` may be nil and the deferred close
panics. Always check the error before touching `res`.
## No `Co-Authored-By` trailers on commits
Don't add `Co-Authored-By: Claude ...` (or any other co-author
+10 -1
View File
@@ -20,7 +20,7 @@ TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/downlo
TAILWIND_INPUT := web/styles/input.css
TAILWIND_OUTPUT := web/static/css/styles.css
.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch
.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch setup hooks
help:
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "};{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
@@ -58,6 +58,15 @@ test-race: ## Run tests with the race detector
lint: ## Run golangci-lint
golangci-lint run ./...
setup: hooks ## One-time per-clone setup (Go tools + git hooks)
@command -v gofumpt >/dev/null 2>&1 || go install mvdan.cc/gofumpt@latest
@command -v golangci-lint >/dev/null 2>&1 || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0
@echo "==> setup complete: gofumpt, golangci-lint, pre-commit hooks installed"
hooks: ## Install the pre-commit hooks defined in .pre-commit-config.yaml
@command -v pre-commit >/dev/null 2>&1 || { echo "pre-commit not found — install with 'pip install pre-commit' or 'brew install pre-commit'" >&2; exit 1; }
pre-commit install
fmt: ## Format with gofumpt + goimports
gofumpt -w .
goimports -local gitea.dcglab.co.uk/steve/restic-manager -w .
+26 -19
View File
@@ -74,31 +74,38 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t
lastProgress := time.Now()
handle := func(stream string, line string, ev any) {
// Forward every line to the server as log.stream.
now := time.Now().UTC()
logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{
JobID: jobID,
Seq: seq.Add(1),
TS: now,
Stream: api.LogStream(stream),
Payload: line,
})
_ = r.tx.Send(logEnv)
// Throttled progress events come from restic's `status` JSON.
// We deliberately do NOT forward the raw status line to
// log.stream — it's emitted ~every 16ms by restic --json and
// would drown the live log in dupes for any short backup. The
// progress widget already covers the same information at a
// sane sample rate.
status, isStatus := ev.(restic.BackupStatus)
if !isStatus {
now := time.Now().UTC()
logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{
JobID: jobID,
Seq: seq.Add(1),
TS: now,
Stream: api.LogStream(stream),
Payload: line,
})
_ = r.tx.Send(logEnv)
}
// Throttled progress events.
if status, ok := ev.(restic.BackupStatus); ok {
if isStatus {
if time.Since(lastProgress) < r.progressMinPeriod {
return
}
lastProgress = time.Now()
progEnv, _ := api.Marshal(api.MsgJobProgress, jobID, api.JobProgressPayload{
JobID: jobID,
PercentDone: status.PercentDone,
FilesDone: status.FilesDone,
TotalFiles: status.TotalFiles,
BytesDone: status.BytesDone,
TotalBytes: status.TotalBytes,
ETASeconds: status.SecondsRem,
JobID: jobID,
PercentDone: status.PercentDone,
FilesDone: status.FilesDone,
TotalFiles: status.TotalFiles,
BytesDone: status.BytesDone,
TotalBytes: status.TotalBytes,
ETASeconds: status.SecondsRem,
ThroughputBps: throughput(status.BytesDone, status.SecondsElapsed),
})
_ = r.tx.Send(progEnv)
+1 -2
View File
@@ -110,7 +110,7 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) {
"received", len(payload.Schedules), "active", added)
// Ack outside the lock — Send() shouldn't take long, but holding
// s.mu across an external call would needlessly serialise other
// s.mu across an external call would needlessly serialize other
// callers (e.g. a future Status() inspection from the UI).
ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{
Version: payload.Version,
@@ -167,4 +167,3 @@ func (s *Scheduler) fire(entry api.Schedule) {
"schedule_id", entry.ID, "err", err)
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ import (
// additionalData binds ciphertexts to the agent-secrets context, so a
// blob lifted from one role's file can't be replayed into another's
// row in some unrelated table that uses the same key. (Defence in
// row in some unrelated table that uses the same key. (Defense in
// depth — the key is per-host today, but cheap to be careful.)
const additionalData = "rm-agent-repo-creds-v1"
+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.
// Output looks like:
// restic 0.17.1 compiled with go1.22.5 on linux/amd64
//
// restic 0.17.1 compiled with go1.22.5 on linux/amd64
//
// Returns the version token (e.g. "0.17.1") or "" if restic isn't
// found. We never block startup on a missing restic — the operator
// might not have installed it yet, and the agent should still be
@@ -74,5 +76,5 @@ func detectResticVersion(ctx context.Context, override string) (string, error) {
if len(parts) >= 2 && parts[0] == "restic" {
return parts[1], nil
}
return "", fmt.Errorf("sysinfo: unrecognised restic version output: %q", first)
return "", fmt.Errorf("sysinfo: unrecognized restic version output: %q", first)
}
+11 -4
View File
@@ -40,7 +40,7 @@ type Config struct {
// Sender is what handlers use to push agent → server messages
// (job.progress, job.finished, log.stream, command.result, …).
// Returned by the WS client to the dispatch handler. Write operations
// serialise behind a single mutex on the conn; concurrent calls are
// serialize behind a single mutex on the conn; concurrent calls are
// safe.
type Sender interface {
Send(env api.Envelope) error
@@ -52,7 +52,7 @@ type Sender interface {
type Handler func(ctx context.Context, env api.Envelope, tx Sender) error
// Run keeps the agent connected indefinitely. Returns when ctx is
// cancelled. Errors during a single connection attempt are logged and
// canceled. Errors during a single connection attempt are logged and
// trigger reconnect-with-backoff; only ctx.Done() ends the loop.
func Run(ctx context.Context, cfg Config, handle Handler) error {
if cfg.HeartbeatPeriod <= 0 {
@@ -69,7 +69,10 @@ func Run(ctx context.Context, cfg Config, handle Handler) error {
slog.Warn("ws agent disconnect", "err", err)
}
if err := sleepCtx(ctx, backoff.next()); err != nil {
return nil
// ctx cancellation mid-backoff means the parent shut us down —
// exit the reconnect loop quietly rather than propagating
// a context error up to a caller that will discard it.
return nil //nolint:nilerr
}
}
}
@@ -100,11 +103,15 @@ func connectOnce(ctx context.Context, cfg Config, handle Handler) error {
}
dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
conn, _, err := websocket.Dial(dialCtx, wsURL, dialOpts)
conn, res, err := websocket.Dial(dialCtx, wsURL, dialOpts)
cancel()
if err != nil {
return fmt.Errorf("dial: %w", err)
}
// websocket.Dial returns the upgrade response separately from the
// conn. Body is empty on a successful upgrade but Go's net/http
// still expects it closed to release the connection.
defer func() { _ = res.Body.Close() }()
defer conn.CloseNow() //nolint:errcheck
// Send hello.
+1 -1
View File
@@ -50,7 +50,7 @@ func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollRe
if err != nil {
return nil, fmt.Errorf("agent enroll: post: %w", err)
}
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
rawRes, _ := io.ReadAll(res.Body)
if res.StatusCode != stdhttp.StatusCreated {
return nil, fmt.Errorf("agent enroll: server returned %d: %s",
+28 -14
View File
@@ -10,13 +10,18 @@ import (
// constants so we don't end up with both "linux" and "Linux" rows.
type HostOS string
// Allowed values for HostOS. Lowercased on the wire so the server
// can use a single CHECK constraint.
const (
OSLinux HostOS = "linux"
OSWindows HostOS = "windows"
)
// HostArch is the agent's CPU architecture; same lowercase-on-wire
// rule as HostOS.
type HostArch string
// Allowed values for HostArch.
const (
ArchAmd64 HostArch = "amd64"
ArchArm64 HostArch = "arm64"
@@ -45,6 +50,9 @@ type HeartbeatPayload struct {
// JobKind is the operation an agent is being asked to run, or just ran.
type JobKind string
// Allowed JobKind values. backup is operator/cron driven; init runs
// once per host on first connect; forget/prune/check fire from the
// server-side maintenance ticker; unlock is operator-only.
const (
JobBackup JobKind = "backup"
JobInit JobKind = "init"
@@ -57,12 +65,16 @@ const (
// JobStatus is the lifecycle state of a job.
type JobStatus string
// Allowed JobStatus values. queued → running → one of {succeeded,
// failed, JobCancelled} as a terminal state. The wire/DB literal for
// the JobCancelled value uses UK spelling — don't "fix" it; existing
// job rows + agent payloads will mismatch. //nolint:misspell
const (
JobQueued JobStatus = "queued"
JobRunning JobStatus = "running"
JobSucceeded JobStatus = "succeeded"
JobFailed JobStatus = "failed"
JobCancelled JobStatus = "cancelled"
JobCancelled JobStatus = "cancelled" //nolint:misspell // wire format
)
// CommandRunPayload is the server → agent dispatch for a run-now job.
@@ -145,6 +157,8 @@ type LogStreamLine struct {
// LogStream identifies which channel a log line came from.
type LogStream string
// Allowed LogStream values. stdout/stderr are passed through verbatim;
// event is the parsed restic --json envelope (summary, error, etc).
const (
LogStdout LogStream = "stdout"
LogStderr LogStream = "stderr"
@@ -175,12 +189,12 @@ type Snapshot struct {
// RepoStatsPayload — agent reports periodic repo health facts derived
// from `restic stats` and lock-file inspection.
type RepoStatsPayload struct {
SizeBytes int64 `json:"size_bytes"`
SnapshotCount int `json:"snapshot_count"`
DedupRatio float64 `json:"dedup_ratio"`
LastCheckAt time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LockState string `json:"lock_state"` // locked|unlocked
SizeBytes int64 `json:"size_bytes"`
SnapshotCount int `json:"snapshot_count"`
DedupRatio float64 `json:"dedup_ratio"`
LastCheckAt time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LockState string `json:"lock_state"` // locked|unlocked
}
// Schedule is the agent-facing view of a slim Schedule row plus its
@@ -220,8 +234,8 @@ type ScheduleSetPayload struct {
// ScheduleAckPayload — agent confirms it has applied a given version.
type ScheduleAckPayload struct {
Version int64 `json:"version"`
AppliedAt time.Time `json:"applied_at"`
Version int64 `json:"version"`
AppliedAt time.Time `json:"applied_at"`
}
// ScheduleFirePayload — agent reports a local cron entry just fired.
@@ -239,11 +253,11 @@ type ScheduleFirePayload struct {
// repo connection details). Empty fields mean "leave existing alone";
// to clear something, send an explicit zero value.
type ConfigUpdatePayload struct {
RepoURL string `json:"repo_url,omitempty"`
RepoPassword string `json:"repo_password,omitempty"` // sensitive
RepoUsername string `json:"repo_username,omitempty"`
RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth)
HookShell string `json:"hook_shell,omitempty"`
RepoURL string `json:"repo_url,omitempty"`
RepoPassword string `json:"repo_password,omitempty"` // sensitive
RepoUsername string `json:"repo_username,omitempty"`
RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth)
HookShell string `json:"hook_shell,omitempty"`
}
// AgentUpdateAvailablePayload — informational only; the agent does
+22 -20
View File
@@ -12,35 +12,35 @@ type MessageType string
// Agent → server message types.
const (
MsgHello MessageType = "hello"
MsgHeartbeat MessageType = "heartbeat"
MsgJobStarted MessageType = "job.started"
MsgJobProgress MessageType = "job.progress"
MsgJobFinished MessageType = "job.finished"
MsgSnapshotsRpt MessageType = "snapshots.report"
MsgRepoStats MessageType = "repo.stats"
MsgLogStream MessageType = "log.stream"
MsgScheduleAck MessageType = "schedule.ack"
MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job
MsgCommandResult MessageType = "command.result" // ack for command.run
MsgError MessageType = "error"
MsgHello MessageType = "hello"
MsgHeartbeat MessageType = "heartbeat"
MsgJobStarted MessageType = "job.started"
MsgJobProgress MessageType = "job.progress"
MsgJobFinished MessageType = "job.finished"
MsgSnapshotsRpt MessageType = "snapshots.report"
MsgRepoStats MessageType = "repo.stats"
MsgLogStream MessageType = "log.stream"
MsgScheduleAck MessageType = "schedule.ack"
MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job
MsgCommandResult MessageType = "command.result" // ack for command.run
MsgError MessageType = "error"
)
// Server → agent message types.
const (
MsgCommandRun MessageType = "command.run"
MsgCommandCancel MessageType = "command.cancel"
MsgScheduleSet MessageType = "schedule.set"
MsgConfigUpdate MessageType = "config.update"
MsgAgentUpdateAvail MessageType = "agent.update.available"
MsgCommandRun MessageType = "command.run"
MsgCommandCancel MessageType = "command.cancel"
MsgScheduleSet MessageType = "schedule.set"
MsgConfigUpdate MessageType = "config.update"
MsgAgentUpdateAvail MessageType = "agent.update.available"
)
// Envelope is the framing for every WS message in either direction.
// Payload is parsed into the concrete struct chosen by Type.
//
// ID is set on RPC-style messages (command.run / command.result) so
// responses can be correlated. For one-shot pushes (heartbeat,
// job.progress) it is empty.
// ID is set on RPC-style messages (command.run / command.result) so
// responses can be correlated. For one-shot pushes (heartbeat,
// job.progress) it is empty.
type Envelope struct {
Type MessageType `json:"type"`
ID string `json:"id,omitempty"`
@@ -71,6 +71,8 @@ func (e Envelope) UnmarshalPayload(v any) error {
// These are stable identifiers; client code may switch on them.
type ErrorCode string
// Stable ErrorCode values surfaced over the wire. Clients switch on
// these; renaming requires a wire-version bump.
const (
ErrProtocolTooOld ErrorCode = "protocol_too_old"
ErrProtocolTooNew ErrorCode = "protocol_too_new"
+5 -2
View File
@@ -16,6 +16,7 @@ import (
// argon2id parameters following RFC 9106 §4 "second
// recommended option" (memory-constrained):
// - 64 MiB memory, 3 iterations, 4 lanes, 32-byte tag.
//
// These are tunable per-deployment if a beefy controller wants to
// crank them; we ship a defensible default.
const (
@@ -27,7 +28,9 @@ const (
)
// HashPassword returns an argon2id-encoded string of the form
// $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>
//
// $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>
//
// safe to store in a TEXT column. The salt is freshly random per call.
func HashPassword(password string) (string, error) {
salt := make([]byte, defaultSaltLen)
@@ -53,7 +56,7 @@ func VerifyPassword(encoded, password string) error {
parts := strings.Split(encoded, "$")
// "$argon2id$v=...$m=...,t=...,p=...$<salt>$<hash>" → 6 parts (leading empty)
if len(parts) != 6 || parts[1] != "argon2id" {
return errors.New("auth: unrecognised hash format")
return errors.New("auth: unrecognized hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
+1 -1
View File
@@ -41,7 +41,7 @@ func TestVerifyRejectsMalformed(t *testing.T) {
"",
"not-a-hash",
"$argon2i$v=19$m=64,t=3,p=4$AAAA$BBBB", // wrong variant
"$argon2id$", // truncated
"$argon2id$", // truncated
"$argon2id$v=99$m=64,t=3,p=4$AAAA$BBBB", // bad version
}
for _, c := range cases {
+1 -1
View File
@@ -65,7 +65,7 @@ func GenerateKeyFile(path string) error {
if err != nil {
return fmt.Errorf("create key file %q: %w", path, err)
}
defer f.Close()
defer func() { _ = f.Close() }()
key := make([]byte, KeyLen)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return fmt.Errorf("read random: %w", err)
+14 -12
View File
@@ -15,7 +15,7 @@ import (
"time"
)
// Locate resolves the path to the restic binary. Honour an explicit
// Locate resolves the path to the restic binary. Honor an explicit
// override if provided, else fall back to PATH.
func Locate(override string) (string, error) {
if override != "" {
@@ -41,12 +41,12 @@ func Locate(override string) (string, error) {
// never assign it back to Env, never pass it to slog. If anything
// in this package ever needs to *log* a URL, use RedactURL.
type Env struct {
Bin string // path to restic binary
RepoURL string // RESTIC_REPOSITORY (no embedded creds)
RepoUsername string // optional HTTP basic-auth user for rest: URLs
RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password
ExtraEnv map[string]string // any other RESTIC_* / passthrough
WorkDir string // CWD; default = current
Bin string // path to restic binary
RepoURL string // RESTIC_REPOSITORY (no embedded creds)
RepoUsername string // optional HTTP basic-auth user for rest: URLs
RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password
ExtraEnv map[string]string // any other RESTIC_* / passthrough
WorkDir string // CWD; default = current
}
// EventKind enumerates what we care about in restic's --json output
@@ -54,10 +54,12 @@ type Env struct {
// switch on message_type.
type EventKind string
// Known message_type values restic --json emits during a backup.
// Kept as constants so callers can switch without typo risk.
const (
EventStatus EventKind = "status" // periodic progress
EventStatus EventKind = "status" // periodic progress
EventVerbose EventKind = "verbose_status"
EventSummary EventKind = "summary" // emitted once at end of backup
EventSummary EventKind = "summary" // emitted once at end of backup
EventErrorEvent EventKind = "error"
)
@@ -90,7 +92,7 @@ type BackupSummary struct {
}
// LineHandler receives every stdout/stderr line. event is non-nil
// when the line is a recognised JSON status; raw always carries the
// when the line is a recognized JSON status; raw always carries the
// original text (so we can also tee to job_logs as `stdout`).
type LineHandler func(stream string, raw string, event any)
@@ -256,7 +258,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error {
// Sniff for "config file already exists" on stderr; if we see it
// we'll treat the non-zero exit as a soft success — running init
// against an already-initialised repo is a no-op semantically,
// against an already-initialized repo is a no-op semantically,
// not a failure. Wraps the caller's handle so the line still
// gets streamed verbatim to the operator-facing log.
alreadyInited := false
@@ -280,7 +282,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error {
if werr := cmd.Wait(); werr != nil {
if alreadyInited {
if handle != nil {
handle("event", "repo already initialised — treating as success", nil)
handle("event", "repo already initialized — treating as success", nil)
}
return nil
}
+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 no user — no-op", "rest:http://h:8000/p/", "", "p", "rest:http://h:8000/p/"},
{"rest creds already inline — no-op",
{
"rest creds already inline — no-op",
"rest:http://existing:secret@h:8000/p/", "u", "p",
"rest:http://existing:secret@h:8000/p/"},
"rest:http://existing:secret@h:8000/p/",
},
{"non-rest s3 — no-op", "s3:s3.amazonaws.com/bucket", "u", "p", "s3:s3.amazonaws.com/bucket"},
{"unparseable — pass through", "rest:not a url", "u", "p", "rest:not a url"},
{"https URL kept intact", "rest:https://h/p/", "u", "p", "rest:https://u:p@h/p/"},
+3 -3
View File
@@ -34,9 +34,9 @@ type Config struct {
}
// Load resolves config in this order:
// 1. defaults
// 2. YAML at the given path (if non-empty and exists)
// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …)
// 1. defaults
// 2. YAML at the given path (if non-empty and exists)
// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …)
//
// The result is validated; a zero-error return means the server is
// safe to start.
+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) {
// 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/")
// Reject any path traversal — must be a flat filename.
if rel == "" || strings.ContainsAny(rel, "/\\") {
+1 -1
View File
@@ -137,7 +137,7 @@ func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) {
return
}
if n > 0 {
writeJSONError(w, stdhttp.StatusConflict, "already_initialised",
writeJSONError(w, stdhttp.StatusConflict, "already_initialized",
"a user already exists; bootstrap is disabled")
return
}
+4 -2
View File
@@ -36,7 +36,7 @@ func newTestServer(t *testing.T, withBootstrapToken bool) (*Server, string) {
aead, _ := crypto.NewAEAD(key)
deps := Deps{
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
Store: st,
AEAD: aead,
}
@@ -125,7 +125,9 @@ func TestLoginAndLogout(t *testing.T) {
bs, _ := json.Marshal(bootstrapRequest{
Token: "test-token", Username: "alice", Password: "averylongpassword",
})
stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)) //nolint:errcheck
if bsRes, err := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)); err == nil {
_ = bsRes.Body.Close()
}
// Login.
body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"})
+6 -6
View File
@@ -3,6 +3,7 @@ package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
stdhttp "net/http"
@@ -142,7 +143,7 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request)
// Seed the host's "default" source group with whatever paths the
// operator typed into Add-host (empty allowed; group is editable
// from the Sources tab post-enrol). Also seed the host's
// from the Sources tab post-enroll). Also seed the host's
// repo-maintenance row with default cadences so forget/prune/check
// start ticking on their own. Auto-init dispatch lands in Phase 6
// of the redesign.
@@ -222,12 +223,11 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt
return
}
token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths)
switch err {
case nil:
switch {
case err == nil:
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt})
case errMissingRepoCreds:
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field",
"repo_url and repo_password are required so the agent can run backups on first connect")
case errors.Is(err, errMissingRepoCreds):
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "repo_url and repo_password are required so the agent can run backups on first connect")
default:
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
}
+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)
}
// pushRepoCredsToAgent serialises blob into a config.update envelope
// pushRepoCredsToAgent serializes blob into a config.update envelope
// and ships it down the agent's WS. Returns an error from the hub
// (no-op if not connected — caller is expected to check first when it
// matters).
+17 -17
View File
@@ -10,23 +10,23 @@ import (
// store row, but with explicit time-strings so wire format is stable
// across DB driver changes.
type hostView struct {
ID string `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
Arch string `json:"arch"`
AgentVersion string `json:"agent_version,omitempty"`
ResticVersion string `json:"restic_version,omitempty"`
ProtocolVersion int `json:"protocol_version"`
EnrolledAt string `json:"enrolled_at"`
LastSeenAt *string `json:"last_seen_at,omitempty"`
Status string `json:"status"`
Tags []string `json:"tags"`
CurrentJobID *string `json:"current_job_id,omitempty"`
LastBackupAt *string `json:"last_backup_at,omitempty"`
LastBackupStatus *string `json:"last_backup_status,omitempty"`
RepoSizeBytes int64 `json:"repo_size_bytes"`
SnapshotCount int `json:"snapshot_count"`
OpenAlertCount int `json:"open_alert_count"`
ID string `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
Arch string `json:"arch"`
AgentVersion string `json:"agent_version,omitempty"`
ResticVersion string `json:"restic_version,omitempty"`
ProtocolVersion int `json:"protocol_version"`
EnrolledAt string `json:"enrolled_at"`
LastSeenAt *string `json:"last_seen_at,omitempty"`
Status string `json:"status"`
Tags []string `json:"tags"`
CurrentJobID *string `json:"current_job_id,omitempty"`
LastBackupAt *string `json:"last_backup_at,omitempty"`
LastBackupStatus *string `json:"last_backup_status,omitempty"`
RepoSizeBytes int64 `json:"repo_size_bytes"`
SnapshotCount int `json:"snapshot_count"`
OpenAlertCount int `json:"open_alert_count"`
}
// handleListHosts returns the full fleet as JSON. Authenticated; the
+2 -2
View File
@@ -16,8 +16,8 @@ import (
// runNowRequest is the body of POST /api/hosts/:id/jobs.
type runNowRequest struct {
Kind api.JobKind `json:"kind"`
Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.)
Kind api.JobKind `json:"kind"`
Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.)
}
type runNowResponse struct {
+32 -14
View File
@@ -215,24 +215,30 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Bad cron → 400.
status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "not-a-cron", "enabled": true,
"source_group_ids": []string{"x"}}, cookie)
map[string]any{
"cron": "not-a-cron", "enabled": true,
"source_group_ids": []string{"x"},
}, cookie)
if status != 400 {
t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
}
// Missing groups → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{}}, cookie)
map[string]any{
"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{},
}, cookie)
if status != 400 {
t.Errorf("missing groups: want 400, got %d", status)
}
// Group not on host → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{"non-existent"}}, cookie)
map[string]any{
"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{"non-existent"},
}, cookie)
if status != 400 {
t.Errorf("bogus group: want 400, got %d", status)
}
@@ -247,8 +253,10 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Happy create.
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{gid}}, cookie)
map[string]any{
"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{gid},
}, cookie)
if status != 201 {
t.Fatalf("create: %d body=%+v", status, body)
}
@@ -269,8 +277,10 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Update — change cron, keep group.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
map[string]any{"cron": "@hourly", "enabled": false,
"source_group_ids": []string{gid}}, cookie)
map[string]any{
"cron": "@hourly", "enabled": false,
"source_group_ids": []string{gid},
}, cookie)
if status != 200 {
t.Fatalf("update: %d body=%+v", status, body)
}
@@ -439,7 +449,10 @@ func TestRunSourceGroupOfflineHost(t *testing.T) {
url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil)
req.AddCookie(cookie)
req.Header.Set("Accept", "application/json")
res, _ := stdhttp.DefaultClient.Do(req)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusServiceUnavailable {
t.Errorf("offline: want 503, got %d", res.StatusCode)
@@ -456,7 +469,10 @@ func TestRunSourceGroupUnknownGroup(t *testing.T) {
url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil)
req.AddCookie(cookie)
req.Header.Set("Accept", "application/json")
res, _ := stdhttp.DefaultClient.Do(req)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusNotFound {
t.Errorf("unknown group: want 404, got %d", res.StatusCode)
@@ -478,5 +494,7 @@ func equalStrings(a, b []string) bool {
}
// keep fmt import live — used for occasional debug.
var _ = fmt.Sprintf
var _ = strings.HasPrefix
var (
_ = fmt.Sprintf
_ = strings.HasPrefix
)
+10 -5
View File
@@ -6,8 +6,8 @@ package http
import (
"context"
"encoding/json"
"net/http/httptest"
stdhttp "net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -29,13 +29,18 @@ func agentDial(t *testing.T, srv *Server, ts *httptest.Server, hostID, token str
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
})
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = c.CloseNow() })
t.Cleanup(func() {
_ = c.CloseNow()
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
})
return c
}
@@ -76,7 +81,7 @@ func drainUntil(t *testing.T, c *websocket.Conn, wantType api.MessageType) api.E
return api.Envelope{}
}
// enrolHostForWS pre-enrols a host with bound repo creds so the server
// enrolHostForWS pre-enrolls a host with bound repo creds so the server
// will treat it as ready to receive command.run.
func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) {
t.Helper()
@@ -97,7 +102,7 @@ func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (ho
if err := st.SetHostCredentials(context.Background(), hostID, enc); err != nil {
t.Fatalf("set creds: %v", err)
}
return
return hostID, token
}
func sendHello(t *testing.T, c *websocket.Conn, hostname string) {
-1
View File
@@ -142,4 +142,3 @@ func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhtt
}
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,
// SourceGroup) pair. Each schedule is sent with its resolved source
// groups inlined so the agent doesn't have to keep its own copy of
// the group catalogue. Cron + enabled drive the agent's local timer;
// the group catalog. Cron + enabled drive the agent's local timer;
// when an entry fires the agent ships back a schedule.fire and
// dispatchScheduledJob below resolves the schedule's groups and
// dispatches one backup command.run per group.
@@ -167,7 +167,12 @@ func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn *
// dispatchBackupForGroup builds and sends a single backup command.run
// envelope on conn for the given group. Persists the job row first so
// the live log viewer can subscribe to it.
func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) {
// dispatchBackupForGroup persists a backup job row, sends the
// command.run envelope to the agent, and audit-logs the dispatch.
// Returns the persisted job ID on success, or "" on any failure
// (failures are slog.Warn-ed). Callers may use the returned ID to,
// e.g., redirect the UI to the live job log.
func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) string {
jobID := ulid.Make().String()
now := time.Now().UTC()
scheduleRef := scheduleID
@@ -181,7 +186,7 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host
}); err != nil {
slog.Warn("schedule.fire: persist job", "host_id", hostID,
"schedule_id", scheduleID, "group", g.Name, "err", err)
return
return ""
}
// Backup ignores RetentionPolicy — the forget cadence lives on
// host_repo_maintenance and is driven by the server-side ticker
@@ -196,14 +201,14 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host
if err != nil {
slog.Warn("schedule.fire: marshal command.run",
"host_id", hostID, "schedule_id", scheduleID, "err", err)
return
return ""
}
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := conn.Send(sendCtx, env); err != nil {
slog.Warn("schedule.fire: send command.run",
"host_id", hostID, "schedule_id", scheduleID, "err", err)
return
return ""
}
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
ID: ulid.Make().String(),
@@ -216,4 +221,5 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host
slog.Info("schedule.fire: dispatched backup",
"host_id", hostID, "schedule_id", scheduleID,
"group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt)
return jobID
}
+1 -1
View File
@@ -212,7 +212,7 @@ func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req
for _, gid := range req.SourceGroupIDs {
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid)
if err != nil || g == nil {
return "invalid_group", "source group "+gid+" not found on this host", false
return "invalid_group", "source group " + gid + " not found on this host", false
}
}
return "", "", true
+18 -1
View File
@@ -126,6 +126,10 @@ func (s *Server) routes(r chi.Router) {
r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
// Host-wide bandwidth caps (host.bandwidth_up_kbps /
// bandwidth_down_kbps). Apply to every restic invocation.
r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
// Per-source-group Run-now (JSON variant). HTMX action is
// mounted at the equivalent path outside /api below — both
// resolve to the same handler, which sniffs HX-Request.
@@ -180,11 +184,24 @@ func (s *Server) routes(r chi.Router) {
// Durable post-Add-host page (operator can refresh / come
// back; password decrypted from the token row each render).
// Polled fragment under /awaiting flips to "connected" once
// the agent enrols.
// the agent enrolls.
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail)
// Sources tab + source-group CRUD forms.
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
// Repo tab — connection / bandwidth / maintenance. Three
// independent forms so saving one doesn't touch the others.
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
// Schedules tab + create/edit/delete forms.
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
+138 -41
View File
@@ -44,7 +44,11 @@ func staticHandler() stdhttp.Handler {
func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, nil
// Missing or invalid cookie just means the caller isn't logged
// in — that's a normal state, not a server error. Return
// (nil, nil) so callers can decide between "redirect to login"
// and "treat as anonymous".
return nil, nil //nolint:nilerr
}
sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
if err != nil {
@@ -81,11 +85,13 @@ func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui
}
// baseView populates the fields the nav partial needs on every
// authenticated page.
func (s *Server) baseView(u *ui.User, active string) ui.ViewData {
// authenticated page. Every UI page sits under the dashboard primary
// nav today; if a future page lives under a different primary nav
// tab (e.g. Settings, Audit), accept an Active arg again.
func (s *Server) baseView(u *ui.User) ui.ViewData {
return ui.ViewData{
User: u,
Active: active,
Active: "dashboard",
Version: s.version(),
}
}
@@ -103,11 +109,64 @@ func (s *Server) version() string {
// dashboardPage is the data the dashboard template renders against.
type dashboardPage struct {
Hosts []store.Host
Hosts []dashboardHostRow
HostCount int
Summary store.FleetSummary
}
// dashboardHostRow carries a host plus the per-row Run-now decision
// the host_row partial needs. The decision is computed server-side
// once per render rather than recomputed in the template.
type dashboardHostRow struct {
Host store.Host
// RunAllScheduleID is the ID of the single schedule that covers
// every source group on the host. Empty when zero or 2+ schedules
// match — in that case the row shows "Open →" instead of a Run-now
// button (the operator picks per-group from the host detail).
RunAllScheduleID string
}
// pickRunAllSchedule returns the ID of the single schedule whose
// source-group set ⊇ every source group on the host. Returns "" when
// zero or 2+ such "covering" schedules exist (operator-disambiguation
// belongs on the host detail, not the dashboard one-click).
func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string {
if len(groups) == 0 || len(scheds) == 0 {
return ""
}
groupIDs := make(map[string]struct{}, len(groups))
for _, g := range groups {
groupIDs[g.ID] = struct{}{}
}
matched := ""
for _, sc := range scheds {
if !sc.Enabled {
continue
}
// Treat sc.SourceGroupIDs as a set; check it covers every group.
got := make(map[string]struct{}, len(sc.SourceGroupIDs))
for _, gid := range sc.SourceGroupIDs {
got[gid] = struct{}{}
}
covers := true
for gid := range groupIDs {
if _, ok := got[gid]; !ok {
covers = false
break
}
}
if !covers {
continue
}
if matched != "" {
// Two distinct covering schedules — ambiguous, bail out.
return ""
}
matched = sc.ID
}
return matched
}
// handleUIDashboard is the root page. Auth-gated; falls through to
// /login if there is no session.
func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@@ -129,10 +188,28 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
return
}
view := s.baseView(u, "dashboard")
// Per-host: pick the single covering schedule (if any) so the row
// can render a one-click Run-now where it's unambiguous. Two store
// calls per host — fine at fleet sizes we care about.
rows := make([]dashboardHostRow, 0, len(hosts))
for _, h := range hosts {
row := dashboardHostRow{Host: h}
groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID)
if gerr != nil {
slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr)
}
scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID)
if serr != nil {
slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr)
}
row.RunAllScheduleID = pickRunAllSchedule(scheds, groups)
rows = append(rows, row)
}
view := s.baseView(u)
view.OpenAlerts = summary.OpenAlerts
view.Page = dashboardPage{
Hosts: hosts,
Hosts: rows,
HostCount: len(hosts),
Summary: summary,
}
@@ -178,16 +255,16 @@ type addHostPage struct {
}
// pendingHostPage is the GET /hosts/pending/{token} view. Lives
// for as long as the token does (1h ttl); once the agent enrols,
// for as long as the token does (1h ttl); once the agent enrolls,
// the handler redirects to /hosts/{host_id} and this page is gone.
type pendingHostPage struct {
Token string
ServerURL string
ExpiresAt time.Time
RepoURL string
RepoUsername string
RepoPassword string
InitialPaths []string
Token string
ServerURL string
ExpiresAt time.Time
RepoURL string
RepoUsername string
RepoPassword string
InitialPaths []string
}
// handleUIAddHostGet renders the empty Add host form.
@@ -196,7 +273,7 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request
if u == nil {
return
}
view := s.baseView(u, "dashboard")
view := s.baseView(u)
view.Title = "Add host · restic-manager"
view.Page = addHostPage{ServerURL: s.publicURL(r)}
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
@@ -256,11 +333,11 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
if page.Error == "" {
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths))
switch err {
case nil:
switch {
case err == nil:
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
return
case errMissingRepoCreds:
case errors.Is(err, errMissingRepoCreds):
page.Error = "Repo URL and password are both required."
default:
slog.Error("ui add_host: mint token", "err", err)
@@ -268,7 +345,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
}
}
view := s.baseView(u, "dashboard")
view := s.baseView(u)
view.Title = "Add host · restic-manager"
view.Page = page
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
@@ -279,7 +356,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
// handleUIPendingHost serves the durable Add-host result page —
// shown after a successful POST /hosts/new and reachable until the
// agent enrols (the page redirects to /hosts/{id} once that
// agent enrolls (the page redirects to /hosts/{id} once that
// happens) or the token expires (1h ttl). The password is
// re-decrypted from the encrypted token row on every render so
// the operator can refresh, bookmark, navigate away and come back.
@@ -335,7 +412,7 @@ func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Reques
}
}
view := s.baseView(u, "dashboard")
view := s.baseView(u)
view.Title = "Pending host · restic-manager"
view.Page = page
if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
@@ -397,9 +474,44 @@ type awaitingFragment struct {
LastSeenAt *time.Time
}
// hostChromeData is the field set the host_chrome partial reads from
// every host-detail-tab page's Page struct. Embed it as the first
// (anonymous) field of the page struct so .Page.Host / .Page.SubTab
// resolve via field promotion in the template.
type hostChromeData struct {
Host store.Host
SubTab string // snapshots | sources | schedules | repo
Crumb string // breadcrumb tail ("snapshots" / "sources" / etc)
SourceGroupCount int
ScheduleCount int
ScheduleVersion int64 // host_schedule_version (latest desired)
}
// loadHostChrome fetches the per-tab counts that every host-detail tab
// renders in the chrome (sub-tab badges + version indicator). On any
// non-fatal store error it logs and degrades to zeros — better to
// render the page with stale counts than 500 the whole tab.
func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData {
d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb}
if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil {
d.SourceGroupCount = len(groups)
} else {
slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err)
}
if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil {
d.ScheduleCount = len(scheds)
} else {
slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err)
}
if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil {
d.ScheduleVersion = v
}
return d
}
// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
Host store.Host
hostChromeData
Snapshots []store.Snapshot
// SnapshotsShown is the number rendered (we cap at ~50 for the
// first slice; pagination lands when it matters).
@@ -440,10 +552,10 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request
shown = shown[:cap]
}
view := s.baseView(u, "dashboard")
view := s.baseView(u)
view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{
Host: *host,
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
Snapshots: shown,
SnapshotsShown: len(shown),
}
@@ -539,7 +651,7 @@ func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request)
nextSeq = logs[n-1].Seq
}
view := s.baseView(u, "dashboard")
view := s.baseView(u)
view.Title = job.Kind + " · " + host.Name + " · restic-manager"
view.Page = jobDetailPage{
Job: *job,
@@ -636,21 +748,6 @@ func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) {
})
}
// userByID fetches the full store.User the UI session represents.
// Returns the user, ok-flag, error. Used by handlers that need the
// store-side row (e.g. for audit_log.user_id) rather than just the
// projected ui.User.
func (s *Server) userByID(r *stdhttp.Request, id string) (*store.User, bool, error) {
u, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, false, nil
}
return nil, false, err
}
return u, true, nil
}
// handleUILoginGet renders the login form. If the user is already
// signed in we redirect them home — login is for the unauthenticated.
func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+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
import (
"context"
"encoding/json"
"errors"
"log/slog"
stdhttp "net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// ui_schedules.go — HTML form-driven schedule CRUD.
//
// Stubbed during the P2 redesign template rewrite. Phase 4 of the
// redesign rebuilds the schedule editor against the new slim shape
// (cron + source-group multi-select + enabled), the source-group
// list/edit pages, and the repo-maintenance tab. Until then these
// routes return 501; the dashboard's host-row "View →" link is the
// only operator entry point that still works.
// ui_schedules.go — HTML form-driven schedule CRUD against the slim
// shape (cron + source-group multi-select + enabled).
// hostSchedulesPage backs the list view. GroupNames maps source-group
// ID → name for the per-row tag rendering, populated once on load so
// the template doesn't need to do per-row store lookups.
type hostSchedulesPage struct {
hostChromeData
Schedules []store.Schedule
GroupNames map[string]string
}
// scheduleFormData mirrors the form's wire shape — strings + bool for
// round-trip on validation re-render.
type scheduleFormData struct {
CronExpr string
Enabled bool
}
// scheduleEditPage backs both the new and edit form views.
type scheduleEditPage struct {
hostChromeData
IsNew bool
ScheduleID string // empty when IsNew
Form scheduleFormData
AvailableGroups []store.SourceGroup
SelectedGroupIDs map[string]bool // gid → checked
SaveAction string
Error string
}
func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID)
if err != nil {
slog.Error("ui schedules: list", "host_id", host.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
if err != nil {
slog.Error("ui schedules: list groups", "host_id", host.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
names := make(map[string]string, len(groups))
for _, g := range groups {
names[g.ID] = g.Name
}
chrome := s.loadHostChrome(r, *host, "schedules", "schedules")
chrome.ScheduleCount = len(scheds)
chrome.SourceGroupCount = len(groups)
view := s.baseView(u)
view.Title = host.Name + " schedules · restic-manager"
view.Page = hostSchedulesPage{
hostChromeData: chrome,
Schedules: scheds,
GroupNames: names,
}
if err := s.deps.UI.Render(w, "host_schedules", view); err != nil {
slog.Error("ui: render host_schedules", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
if err != nil {
slog.Error("ui schedule new: list groups", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
view := s.baseView(u)
view.Title = "New schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"),
IsNew: true,
Form: scheduleFormData{Enabled: true},
AvailableGroups: groups,
SelectedGroupIDs: map[string]bool{},
SaveAction: "/hosts/" + host.ID + "/schedules/new",
}
if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
slog.Error("ui: render schedule_edit (new)", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
sid := chi.URLParam(r, "sid")
sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui schedule edit: get", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
if err != nil {
slog.Error("ui schedule edit: list groups", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
selected := make(map[string]bool, len(sc.SourceGroupIDs))
for _, gid := range sc.SourceGroupIDs {
selected[gid] = true
}
view := s.baseView(u)
view.Title = "Edit schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"),
IsNew: false,
ScheduleID: sid,
Form: scheduleFormData{
CronExpr: sc.CronExpr,
Enabled: sc.Enabled,
},
AvailableGroups: groups,
SelectedGroupIDs: selected,
SaveAction: "/hosts/" + host.ID + "/schedules/" + sid + "/edit",
}
if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
slog.Error("ui: render schedule_edit", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUIScheduleSave handles both create and update. On validation
// error, re-renders with input intact + a banner.
func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
sid := chi.URLParam(r, "sid")
isNew := sid == ""
form := scheduleFormData{
CronExpr: strings.TrimSpace(r.PostForm.Get("cron")),
Enabled: r.PostForm.Get("enabled") == "1",
}
pickedIDs := r.PostForm["source_group_ids"]
selected := make(map[string]bool, len(pickedIDs))
for _, gid := range pickedIDs {
selected[gid] = true
}
// --- validation ---
var errMsg string
switch {
case form.CronExpr == "":
errMsg = "Cron expression is required."
case len(pickedIDs) == 0:
errMsg = "Pick at least one source group — a schedule has to know what to back up."
}
if errMsg == "" {
if _, err := cronParser.Parse(form.CronExpr); err != nil {
errMsg = "Cron didn't parse: " + err.Error()
}
}
// Verify every picked group belongs to this host.
if errMsg == "" {
for _, gid := range pickedIDs {
g, gerr := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid)
if gerr != nil || g == nil {
errMsg = "One of the picked source groups isn't on this host — refresh and try again."
break
}
}
}
if errMsg != "" {
s.renderScheduleFormError(w, r, u, host, sid, isNew, form, selected, errMsg)
return
}
sc := store.Schedule{
ID: sid,
HostID: host.ID,
CronExpr: form.CronExpr,
Enabled: form.Enabled,
SourceGroupIDs: pickedIDs,
}
if isNew {
sc.ID = ulid.Make().String()
if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil {
slog.Error("ui schedule save: create", "err", err)
s.renderScheduleFormError(w, r, u, host, "", true, form, selected,
"Couldn't create — see the server log for details.")
return
}
} else {
if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil {
slog.Error("ui schedule save: update", "err", err)
s.renderScheduleFormError(w, r, u, host, sid, false, form, selected,
"Couldn't save — see the server log for details.")
return
}
}
s.pushScheduleSetAsync(host.ID)
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther)
}
func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
sid := chi.URLParam(r, "sid")
if err := s.deps.Store.DeleteSchedule(r.Context(), host.ID, sid); err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui schedule delete", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
s.pushScheduleSetAsync(host.ID)
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther)
}
// handleUIScheduleRun is the per-schedule Run-now action: dispatch
// every source group the schedule references in a single shot,
// reusing dispatchScheduledJob (the same path real cron fires take).
// HTMX only — falls back to a 405 for non-HTMX callers (per-group
// Run-now via the Sources tab is the JSON path).
func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
if u := s.requireUIUser(w, r); u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
sid := chi.URLParam(r, "sid")
sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if len(sc.SourceGroupIDs) == 0 {
stdhttp.Error(w, "this schedule has no source groups attached", stdhttp.StatusConflict)
return
}
if s.deps.Hub == nil {
stdhttp.Error(w, "ws hub not configured", stdhttp.StatusServiceUnavailable)
return
}
conn := s.deps.Hub.Conn(host.ID)
if conn == nil {
stdhttp.Error(w, "host is offline — reconnect the agent and try again",
stdhttp.StatusConflict)
return
}
// Manual Run-now ignores Enabled. "Disabled" only suppresses
// cron-tick firing; an ad-hoc one-off run is a separate intent
// (and the dispatch is audit-logged inside dispatchBackupForGroup).
// We dispatch inline rather than calling dispatchScheduledJob,
// which short-circuits on !Enabled.
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
now := time.Now().UTC()
type fired struct{ groupName, jobID string }
dispatched := make([]fired, 0, len(sc.SourceGroupIDs))
for _, gid := range sc.SourceGroupIDs {
g, gerr := s.deps.Store.GetSourceGroup(ctx, host.ID, gid)
if gerr != nil {
slog.Warn("ui schedule run: load source group",
"host_id", host.ID, "schedule_id", sid, "group_id", gid, "err", gerr)
continue
}
jobID := s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now)
if jobID != "" {
dispatched = append(dispatched, fired{groupName: g.Name, jobID: jobID})
}
}
if wantsHTML(r) {
switch len(dispatched) {
case 0:
stdhttp.Error(w, "no backup jobs dispatched — see server log", stdhttp.StatusInternalServerError)
return
case 1:
// Single-group schedule: jump straight to the live job log,
// same UX as per-source-group Run-now from the Sources tab.
w.Header().Set("HX-Redirect", "/jobs/"+dispatched[0].jobID)
default:
// Multi-group: stay on the schedules tab and toast the
// summary. Direct the operator to one of the job logs via
// the toast (the most recent job ID is fine).
names := make([]string, 0, len(dispatched))
for _, f := range dispatched {
names = append(names, f.groupName)
}
msg := strconv.Itoa(len(dispatched)) + " backups dispatched: " + strings.Join(names, ", ")
payload, _ := json.Marshal(map[string]any{
"rm:toast": map[string]string{"level": "success", "message": msg},
})
w.Header().Set("HX-Trigger", string(payload))
}
}
w.WriteHeader(stdhttp.StatusNoContent)
}
func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, sid string, isNew bool, form scheduleFormData, selected map[string]bool, msg string) {
groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
if err != nil {
slog.Error("ui schedule re-render: list groups", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
saveAction := "/hosts/" + host.ID + "/schedules/new"
crumb := "new schedule"
if !isNew {
saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit"
crumb = "edit schedule"
}
view := s.baseView(u)
view.Title = "Schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb),
IsNew: isNew,
ScheduleID: sid,
Form: form,
AvailableGroups: groups,
SelectedGroupIDs: selected,
SaveAction: saveAction,
Error: msg,
}
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
slog.Error("ui: render schedule_edit (error)", "err", err)
}
}
// loadHostForUI is a small helper shared across the host-detail tab
// handlers — fetches the host by URL param, writing the appropriate
// 404/500 + returning ok=false on failure.
func (s *Server) loadHostForUI(w stdhttp.ResponseWriter, r *stdhttp.Request) (*store.Host, bool) {
hostID := chi.URLParam(r, "id")
if hostID == "" {
stdhttp.NotFound(w, r)
return nil, false
}
host, err := s.deps.Store.GetHost(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return nil, false
}
slog.Error("ui host tab: get host", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return nil, false
}
return host, true
}
+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.
func funcMap() template.FuncMap {
return template.FuncMap{
"bytes": formatBytes,
"relTime": formatRelTime,
"comma": formatComma,
"deref": derefStr,
"bytes": formatBytes,
"relTime": formatRelTime,
"comma": formatComma,
"deref": derefStr,
"timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() },
"joinDot": func(parts []string) string { return strings.Join(parts, " · ") },
"absTime": func(t time.Time) string {
+1
View File
@@ -91,6 +91,7 @@ func New() (*Renderer, error) {
"templates/partials/host_row.html",
"templates/partials/toast.html",
"templates/partials/awaiting_agent.html",
"templates/partials/host_chrome.html",
}
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
+9 -9
View File
@@ -42,14 +42,14 @@ type HandlerDeps struct {
// enrollment) before the WS upgrade.
//
// Lifecycle:
// 1. Bearer token resolves to a Host row.
// 2. Upgrade.
// 3. First message must be `hello`; protocol_version checked here.
// 4. Loop: read messages, dispatch by type. Heartbeats touch the
// host row; job/log/repo messages forward to the relevant
// handlers (TODO: lands with P1-18 onward).
// 5. On Read error or context cancel, mark host offline, unregister
// from the hub.
// 1. Bearer token resolves to a Host row.
// 2. Upgrade.
// 3. First message must be `hello`; protocol_version checked here.
// 4. Loop: read messages, dispatch by type. Heartbeats touch the
// host row; job/log/repo messages forward to the relevant
// handlers (TODO: lands with P1-18 onward).
// 5. On Read error or context cancel, mark host offline, unregister
// from the hub.
func AgentHandler(deps HandlerDeps) stdhttp.Handler {
return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
host, ok := authenticateAgent(r, deps.Store)
@@ -204,7 +204,7 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil {
slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err)
}
// repo_initialised_at projection has been removed — auto-init
// repo_initialized_at projection has been removed — auto-init
// at host enrolment makes "is the repo init'd" derivable from
// the latest init job's status, no separate column needed.
if deps.JobHub != nil {
+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
// are serialised; the underlying socket is not safe for parallel
// are serialized; the underlying socket is not safe for parallel
// writers.
func (c *Conn) Send(ctx context.Context, env api.Envelope) error {
c.writeMu.Lock()
+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)
}
url = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
return
return url, token, hostID, st, hub
}
func TestWSHelloAndHeartbeat(t *testing.T) {
@@ -57,13 +57,18 @@ func TestWSHelloAndHeartbeat(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.CloseNow()
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
// Send hello.
hello := api.HelloPayload{
@@ -125,13 +130,18 @@ func TestWSRejectsOldProtocol(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.CloseNow()
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
hello := api.HelloPayload{ProtocolVersion: 0} // below minimum
env, _ := api.Marshal(api.MsgHello, "", hello)
@@ -170,6 +180,13 @@ func TestWSRejectsBadToken(t *testing.T) {
_, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer wrong"}},
})
if res != nil {
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
}
if err == nil {
t.Fatal("dial should fail")
}
+2 -2
View File
@@ -33,7 +33,7 @@ func NewJobHub() *JobHub {
// the hub's set (so concurrent Broadcasts will reach it), but no
// pump goroutine runs yet. The caller can prime the channel via Send
// — useful for late-subscriber catch-up — and then call Run to start
// the pump. Run blocks until ctx is cancelled or conn dies, and
// the pump. Run blocks until ctx is canceled or conn dies, and
// unregisters on return.
type Subscriber struct {
hub *JobHub
@@ -73,7 +73,7 @@ func (s *Subscriber) Send(env api.Envelope) {
}
// Run pumps messages from the subscriber's channel onto conn until
// ctx is cancelled or conn dies. Unregisters on return. Caller is
// ctx is canceled or conn dies. Unregisters on return. Caller is
// expected to invoke this from the goroutine that owns conn.
func (s *Subscriber) Run(ctx context.Context, conn *Conn) {
defer s.unregister()
+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.
// SQLite's driver treats Go nil as NULL but treats *string("") as ''.
// SQLite's driver treats Go nil as NULL but treats *string("") as .
// We want NULL semantics for "absent."
func nullable(p *string) any {
if p == nil || *p == "" {
-1
View File
@@ -172,4 +172,3 @@ func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error)
n, _ := res.RowsAffected()
return n, nil
}
+2 -2
View File
@@ -57,7 +57,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) {
if err != nil {
return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
for rows.Next() {
var status string
var n int
@@ -70,7 +70,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) {
fs.JobsLast24hSucceeded = n
case "failed":
fs.JobsLast24hFailed = n
case "cancelled":
case "cancelled": //nolint:misspell // matches the DB CHECK constraint and api.JobCancelled wire value
fs.JobsLast24hCancelled = n
}
}
+6 -6
View File
@@ -121,7 +121,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) {
if err != nil {
return nil, fmt.Errorf("store: list hosts: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var out []Host
for rows.Next() {
h, err := scanHostRow(rows)
@@ -150,11 +150,11 @@ func scanHost(row *sql.Row) (*Host, error) {
func scanHostRow(s hostScanner) (*Host, error) {
var h Host
var (
lastSeen, lastBackupAt sql.NullString
repoID, currentJob, lastBkSt sql.NullString
enrolled string
tags string
bwUp, bwDown sql.NullInt64
lastSeen, lastBackupAt sql.NullString
repoID, currentJob, lastBkSt sql.NullString
enrolled string
tags string
bwUp, bwDown sql.NullInt64
)
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
+10 -10
View File
@@ -118,7 +118,7 @@ func (s *Store) ListJobLogs(ctx context.Context, jobID string, afterSeq int64, l
if err != nil {
return nil, fmt.Errorf("store: list job logs: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var out []JobLogLine
for rows.Next() {
var l JobLogLine
@@ -143,15 +143,15 @@ func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) {
started_at, finished_at, exit_code, stats, error, created_at
FROM jobs WHERE id = ?`, id)
var (
j Job
schedID sql.NullString
actorID sql.NullString
startedAt sql.NullString
finishedAt sql.NullString
exitCode sql.NullInt64
stats sql.NullString
errMsg sql.NullString
createdAt string
j Job
schedID sql.NullString
actorID sql.NullString
startedAt sql.NullString
finishedAt sql.NullString
exitCode sql.NullInt64
stats sql.NullString
errMsg sql.NullString
createdAt string
)
if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID,
&j.ActorKind, &actorID, &startedAt, &finishedAt,
+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
FROM host_repo_maintenance WHERE host_id = ?`, hostID)
var (
m HostRepoMaintenance
m HostRepoMaintenance
forgetEnabled, pruneEnabled, checkEnabled int
)
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/.
type migration struct {
version int // parsed from filename prefix (0001, 0002, …)
name string // full filename, for error messages
sql string
version int // parsed from filename prefix (0001, 0002, …)
name string // full filename, for error messages
sql string
}
// loadMigrations reads every migrations/*.sql file in lexical order
+1 -1
View File
@@ -52,7 +52,7 @@ func (st *Store) DuePendingRuns(ctx context.Context, now time.Time, limit int) (
if err != nil {
return nil, fmt.Errorf("store: due pending runs: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
out := []PendingRun{}
for rows.Next() {
var p PendingRun
+3 -3
View File
@@ -144,7 +144,7 @@ func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Sche
if err != nil {
return nil, fmt.Errorf("store: list schedules: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
out := []Schedule{}
for rows.Next() {
s, err := scanScheduleRow(rows)
@@ -247,7 +247,7 @@ func (st *Store) scheduleGroupIDs(ctx context.Context, scheduleID string) ([]str
if err != nil {
return nil, fmt.Errorf("store: read schedule junction: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
out := []string{}
for rows.Next() {
var id string
@@ -269,7 +269,7 @@ func (st *Store) SchedulesUsingGroup(ctx context.Context, groupID string) ([]str
if err != nil {
return nil, fmt.Errorf("store: schedules using group: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
out := []string{}
for rows.Next() {
var id string
+2 -2
View File
@@ -51,7 +51,7 @@ func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps [
if err != nil {
return fmt.Errorf("store: prepare snapshot insert: %w", err)
}
defer stmt.Close()
defer func() { _ = stmt.Close() }()
refreshed := when.UTC().Format(time.RFC3339Nano)
for _, snap := range snaps {
@@ -92,7 +92,7 @@ func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snaps
if err != nil {
return nil, fmt.Errorf("store: list snapshots: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var out []Snapshot
for rows.Next() {
+15 -13
View File
@@ -30,20 +30,20 @@ func TestReplaceHostSnapshotsRoundTrip(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
in := []Snapshot{
{
ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "deadbeef",
Time: now.Add(-2 * time.Hour),
Hostname: "snap-host",
Paths: []string{"/etc", "/home"},
Tags: []string{"daily"},
ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "deadbeef",
Time: now.Add(-2 * time.Hour),
Hostname: "snap-host",
Paths: []string{"/etc", "/home"},
Tags: []string{"daily"},
SizeBytes: 4096, FileCount: 12,
},
{
ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "cafef00d",
Time: now.Add(-1 * time.Hour),
Hostname: "snap-host",
Paths: []string{"/etc"},
ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "cafef00d",
Time: now.Add(-1 * time.Hour),
Hostname: "snap-host",
Paths: []string{"/etc"},
SizeBytes: 8192, FileCount: 24,
},
}
@@ -129,9 +129,11 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) {
// First a non-empty replace.
if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{
{ID: "1111111111111111111111111111111111111111111111111111111111111111",
{
ID: "1111111111111111111111111111111111111111111111111111111111111111",
ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host",
Paths: []string{"/x"}},
Paths: []string{"/x"},
},
}, time.Now().UTC()); err != nil {
t.Fatalf("replace 1: %v", err)
}
+5 -5
View File
@@ -183,7 +183,7 @@ func (st *Store) ListSourceGroupsByHost(ctx context.Context, hostID string) ([]S
if err != nil {
return nil, fmt.Errorf("store: list source groups: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
out := []SourceGroup{}
for rows.Next() {
g, err := scanSourceGroupRow(rows)
@@ -220,10 +220,10 @@ type sourceGroupScanner interface {
func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
var (
out SourceGroup
includes, excludes, retention string
conflict sql.NullString
createdAt, updatedAt string
out SourceGroup
includes, excludes, retention string
conflict sql.NullString
createdAt, updatedAt string
)
err := s.Scan(&out.ID, &out.HostID, &out.Name,
&includes, &excludes, &retention,
+1 -1
View File
@@ -177,7 +177,7 @@ func TestPendingRunQueue(t *testing.T) {
now := time.Now().UTC()
if err := s.EnqueuePendingRun(ctx, &PendingRun{
ID: "01HPEND00000000000000001",
ID: "01HPEND00000000000000001",
ScheduleID: schedID, SourceGroupID: gid, HostID: hostID,
NextAttemptAt: now.Add(-time.Second), // already due
ScheduledAt: now.Add(-time.Minute),
+4 -2
View File
@@ -34,10 +34,12 @@ func TestOpenAppliesMigrations(t *testing.T) {
}
// Spot-check a few tables exist with expected columns.
tables := []string{"users", "sessions", "hosts", "repos",
tables := []string{
"users", "sessions", "hosts", "repos",
"credentials", "schedules", "jobs", "job_logs",
"snapshots", "alerts", "audit_log",
"enrollment_tokens", "host_schedule_version"}
"enrollment_tokens", "host_schedule_version",
}
for _, tbl := range tables {
row := s.DB().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl)
+15 -14
View File
@@ -20,6 +20,7 @@ type User struct {
// Role enumerates the access tiers from spec.md §7.2.
type Role string
// Defined Role values, in descending order of privilege.
const (
RoleAdmin Role = "admin"
RoleOperator Role = "operator"
@@ -73,12 +74,12 @@ type Host struct {
// only. forget/prune/check are repo-level cadences on
// HostRepoMaintenance, not schedule kinds.
type Schedule struct {
ID string
HostID string
CronExpr string
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
ID string
HostID string
CronExpr string
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
// SourceGroupIDs is populated by ListSchedulesByHost (joins
// schedule_source_groups) and accepted on Create / Update so the
// caller passes the desired junction state in one shape.
@@ -160,14 +161,14 @@ type HostRepoMaintenance struct {
// PendingRun queues a missed cron tick (agent was offline) for the
// server-side retry ticker to dispatch later.
type PendingRun struct {
ID string
ScheduleID string
SourceGroupID string
HostID string
Attempt int
NextAttemptAt time.Time
ScheduledAt time.Time // original cron tick — forensic / audit
LastError string
ID string
ScheduleID string
SourceGroupID string
HostID string
Attempt int
NextAttemptAt time.Time
ScheduledAt time.Time // original cron tick — forensic / audit
LastError string
}
// EnrollmentToken is the issuer's view of a one-time token.
+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.
- **Tests**: rewrite the deleted `schedules_test.go` and `schedule_push_test.go` against new endpoints; new `source_groups_test.go`, `repo_maintenance_test.go`, `auto_init_test.go`. End-to-end: enrol → server pushes creds → server dispatches init → agent runs it → schedule reconcile fires → operator hits per-source-group Run-now → backup runs → snapshots refresh.
### P2 redesign — Phase 4 (UI rewire, against v4 wireframes) — TODO
### P2 redesign — Phase 4 (UI rewire, against v4 wireframes)
- [ ] **P2R-02** (L) UI templates rebuilt against the new model:
- `/hosts/{id}/sources` — list of source groups with per-row meta (includes/excludes count, retention summary via `RetentionPolicy.Summary()`, usage = which schedules reference this group, snapshot count for `tag = group.name`). Run-now / Edit / Delete actions per row.
- `/hosts/{id}/sources/{gid}/edit` (and `/sources/new`) — name (= snapshot tag), includes/excludes textareas, retention as a 3×2 keep-* grid, retry-on-offline, inline conflict banner above retention when granularity ↔ cadence mismatch detected (uses `SourceGroup.conflict_dimension` cache).
- `/hosts/{id}/schedules` — slim list (status / cron / source-tags / actions) plus new-schedule form (cron with quick-pick chips, source-group multi-select via clickable check pickers, enabled toggle).
- `/hosts/{id}/repo` — connection (URL/user/password — pre-filled from `GET /api/hosts/{id}/repo-credentials` redacted view; cert pin), bandwidth caps (host-wide), maintenance rows (forget/prune/check cadence + check subset %), danger-zone re-init.
- **Re-enable the four host-detail sub-tabs** (Snapshots is already live; Schedules / Sources / Repo become real links again; Settings stays inert until later). Drop the stop-gap inert-div hack from P2R-00.4.
- **Per-source-group Run-now buttons** replace today's per-host `Run backup now` buttons (right-rail + dashboard row + empty-snapshots state). Dashboard row's Run-now becomes either "Run all groups" (if exactly one schedule covers all groups) or "Open →" (multi-group hosts).
- Header "version N · agent in sync / agent at vM" indicator preserved (still backed by `host_schedule_version` + `applied_schedule_version`).
> **Row-design rule (binding for every list-row template in this app, current and future):**
> Whole-row click navigates to the row's primary detail/edit page —
> mirror `.host-row.clickable` on the dashboard
> (`partials/host_row.html`): an absolute-positioned `.row-link`
> overlay with `text-indent: -9999px` covers the row, action buttons
> live in `.row-action` cells that sit above via z-index. **Do not
> add an explicit "Edit" button** when the row is clickable — it
> duplicates the affordance and dilutes the click target. Action
> cells are reserved for verbs that aren't "open this row" (Run-now,
> Delete, Pause, etc).
- [x] **P2R-02** (L) UI templates rebuilt against the new model:
- **Slice 1 ✅** Sub-tab navigation skeleton — extract header/vitals/sub-tabs into a `host_chrome` partial; Sources / Schedules / Repo become real `<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.
### P2 redesign — Phase 5 (server-side maintenance ticker) — TODO
+4
View File
@@ -7,5 +7,9 @@ package web
import "embed"
// FS is the embedded view of every template + static asset under
// this package. Consumed by internal/server/ui (templates) and
// internal/server/http (static handler).
//
//go:embed templates/* static/*
var FS embed.FS
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-action { pointer-events: auto; }
/* ---------- source-group rows (Sources tab) ---------- */
.src-row {
display: grid; align-items: center;
grid-template-columns: 1fr auto;
column-gap: 18px;
padding: 14px 18px;
}
/* Whole-row click edit page, mirroring .host-row.clickable on the
dashboard. Action cells sit above via z-index so their buttons
keep working. */
.src-row.clickable { position: relative; }
.src-row.clickable .row-link {
position: absolute; inset: 0; z-index: 0;
text-indent: -9999px; overflow: hidden;
}
.src-row.clickable:hover { background: var(--panel-hi); cursor: pointer; }
.src-row.clickable > * { position: relative; z-index: 1; pointer-events: none; }
.src-row.clickable > .row-link { pointer-events: auto; }
.src-row.clickable > .row-action { pointer-events: auto; }
/* ---------- schedule rows (Schedules tab) ---------- */
.schd-row {
display: grid; align-items: center;
grid-template-columns: 90px 1fr 2fr auto;
column-gap: 18px;
padding: 12px 18px; font-size: 13px;
}
.schd-row.head {
padding-top: 10px; padding-bottom: 10px;
font-size: 11px; color: var(--ink-fade);
text-transform: uppercase; letter-spacing: 0.08em;
}
/* Whole-row click → edit page (matches .host-row.clickable). */
.schd-row.clickable { position: relative; }
.schd-row.clickable .row-link {
position: absolute; inset: 0; z-index: 0;
text-indent: -9999px; overflow: hidden;
}
.schd-row.clickable:hover { background: var(--panel-hi); cursor: pointer; }
.schd-row.clickable > * { position: relative; z-index: 1; pointer-events: none; }
.schd-row.clickable > .row-link { pointer-events: auto; }
.schd-row.clickable > .row-action { pointer-events: auto; }
/* ---------- cron preset chips ---------- */
.preset-chip {
font-family: 'JetBrains Mono', monospace; font-size: 11.5px;
padding: 4px 9px; border-radius: 4px;
border: 1px solid var(--line-soft); color: var(--ink-mid);
background: var(--bg);
cursor: pointer; user-select: none;
transition: border-color 100ms ease, color 100ms ease;
}
.preset-chip:hover { border-color: var(--accent); color: var(--ink); }
/* ---------- source-group picker (Schedule new/edit) ---------- */
.picker {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--line-soft);
border-radius: 5px;
font-size: 13px; cursor: pointer;
transition: border-color 100ms ease, background 100ms ease;
}
.picker:hover { border-color: var(--ink-mute); }
.picker .check {
display: inline-block; width: 14px; height: 14px;
border: 1px solid var(--line); border-radius: 3px;
flex-shrink: 0; position: relative;
}
.picker.checked {
border-color: color-mix(in oklch, var(--accent), transparent 50%);
background: color-mix(in oklch, var(--accent), transparent 92%);
}
.picker.checked .check {
background: var(--accent); border-color: var(--accent);
}
.picker.checked .check::after {
content: ""; position: absolute;
left: 4px; top: 1px; width: 4px; height: 8px;
border: solid oklch(0.18 0.01 195);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
.picker input[type="checkbox"] {
position: absolute; opacity: 0; pointer-events: none;
}
/* ---------- retention 3×2 keep-* grid (source-group edit) ---------- */
.keep-cell {
background: var(--bg);
border: 1px solid var(--line-soft);
border-radius: 5px;
padding: 9px 11px;
display: flex; flex-direction: column; gap: 4px;
}
.keep-cell label {
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--ink-fade);
}
.keep-cell input {
background: transparent; border: none; outline: none;
font-family: 'JetBrains Mono', monospace; font-size: 14px;
color: var(--ink); padding: 0; width: 100%;
}
/* ---------- log viewer ---------- */
.log {
background: var(--bg); border: 1px solid var(--line-soft);
+8 -91
View File
@@ -1,93 +1,13 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "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><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>
<div class="max-w-[1280px] mx-auto px-8 pb-14">
{{/* ---------- 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="flex items-center justify-between mb-3">
@@ -106,7 +26,7 @@
Once a backup completes, the agent will refresh this list automatically.
</p>
<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>
{{else}}
@@ -150,13 +70,10 @@
<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="flex flex-col gap-1.5">
<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>
<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>
<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>
<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>
<p class="text-[12px] text-ink-mute leading-[1.55] mb-2">
Run-now lives on individual source groups now —
<a href="/hosts/{{$host.ID}}/sources" class="underline">open Sources →</a>
</p>
</div>
<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 "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$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">
<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">
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-1">
{{if $page.IsNew}}New schedule{{else}}Edit schedule{{end}}
<span class="text-ink-fade">·</span>
<span class="mono text-ink">{{$host.Name}}</span>
</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}}
<div class="mt-6 px-4 py-3 rounded-[5px] text-[13px]"
style="background: color-mix(in oklch, var(--bad), transparent 88%);
border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);
color: oklch(0.85 0.10 25);">
<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="{{if $page.IsNew}}/hosts/{{$host.ID}}/schedules/new{{else}}/hosts/{{$host.ID}}/schedules/{{$page.ScheduleID}}/edit{{end}}"
class="grid grid-cols-12 gap-8 mt-7">
<form method="post" action="{{$page.SaveAction}}" class="panel rounded-[7px] p-7 mt-6">
<div class="grid grid-cols-12 gap-6">
<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>
<div class="mb-7">
{{if $page.IsNew}}
<label class="field-label" for="se-kind">What does this schedule do?</label>
<select id="se-kind" name="kind" class="field mono"
onchange="document.querySelectorAll('[data-kind]').forEach(el => { el.style.display = el.dataset.kind === this.value ? '' : 'none'; });">
<option value="backup" {{if eq $page.Kind "backup"}}selected{{end}}>backup — snapshot the configured paths</option>
<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.
<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">
What — pick one or more source groups
</h3>
{{if eq (len $page.AvailableGroups) 0}}
<div class="text-[12.5px] text-ink-mute leading-[1.6]">
This host has no source groups yet — <a href="/hosts/{{$host.ID}}/sources/new" class="text-accent underline">create one first</a>
so this schedule has something to back up.
</div>
{{else}}
<input type="hidden" name="kind" value="{{$page.Kind}}">
<div class="text-[13px] text-ink-mid">
Kind: <span class="mono text-ink">{{$page.Kind}}</span>
<span class="text-ink-fade">— immutable on edit; delete and recreate to switch kind.</span>
<div class="grid grid-cols-1 gap-1.5" id="group-pickers">
{{range $page.AvailableGroups}}
{{$checked := index $page.SelectedGroupIDs .ID}}
<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>
{{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>
<div class="mb-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}}>
<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">
<input type="checkbox" name="enabled" value="1" {{if $f.Enabled}}checked{{end}} class="w-3.5 h-3.5" />
<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>
<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 class="flex gap-2 pt-7">
<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>
<aside class="col-span-5 border-l border-line-soft pl-6">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-3">No paths, no retention, no kind</div>
<p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6]">
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>
<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>
</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}}
-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"}}
<div class="row-hover host-row clickable hairline {{.Status}}{{if eq (deref .LastBackupStatus) "failed"}} failed{{end}}">
<a href="/hosts/{{.ID}}" class="row-link" aria-label="Open {{.Name}}">{{.Name}}</a>
{{$h := .Host}}
<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>
{{- if eq .Status "online" -}}
<span class="dot dot-online{{if .CurrentJobID}} pulse{{end}}"></span>
{{- else if eq .Status "degraded" -}}
{{- if eq $h.Status "online" -}}
<span class="dot dot-online{{if $h.CurrentJobID}} pulse{{end}}"></span>
{{- else if eq $h.Status "degraded" -}}
<span class="dot dot-degraded"></span>
{{- else if eq .Status "offline" -}}
{{- else if eq $h.Status "offline" -}}
<span class="dot dot-offline"></span>
{{- else -}}
<span class="dot dot-failed"></span>
{{- end -}}
</div>
<div class="mono {{if eq .Status "offline"}}text-ink-mid{{else}}text-ink{{end}} font-medium">{{.Name}}</div>
<div class="mono text-ink-mid text-[12px]">{{.OS}}/{{.Arch}}</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]">{{$h.OS}}/{{$h.Arch}}</div>
<div class="text-xs text-ink-mid">
{{- if .CurrentJobID -}}
{{- if $h.CurrentJobID -}}
<span class="text-accent">backup running…</span><br>
<span class="mono text-ink-fade">started {{relTime .LastBackupAt}}</span>
{{- else if eq (deref .LastBackupStatus) "succeeded" -}}
<span class="text-ok">succeeded</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
{{- else if eq (deref .LastBackupStatus) "failed" -}}
<span class="text-bad font-medium">failed</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
{{- else if eq (deref .LastBackupStatus) "cancelled" -}}
<span class="text-warn">cancelled</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
{{- else if eq .Status "offline" -}}
<span class="text-ink-mute">last seen <span class="mono">{{relTime .LastSeenAt}}</span></span>
<span class="mono text-ink-fade">started {{relTime $h.LastBackupAt}}</span>
{{- else if eq (deref $h.LastBackupStatus) "succeeded" -}}
<span class="text-ok">succeeded</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
{{- else if eq (deref $h.LastBackupStatus) "failed" -}}
<span class="text-bad font-medium">failed</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
{{- else if eq (deref $h.LastBackupStatus) "cancelled" -}}
<span class="text-warn">cancelled</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
{{- else if eq $h.Status "offline" -}}
<span class="text-ink-mute">last seen <span class="mono">{{relTime $h.LastSeenAt}}</span></span>
{{- else -}}
<span class="text-ink-fade italic">never run</span>
{{- end -}}
</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 .Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
{{- if eq .SnapshotCount 0 -}}
<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 $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
{{- if eq $h.SnapshotCount 0 -}}
<span class="text-ink-fade"></span>
{{- else -}}
{{comma .SnapshotCount}}
{{comma $h.SnapshotCount}}
{{- end -}}
</div>
<div class="text-right mono {{if gt .OpenAlertCount 0}}text-bad font-medium{{else}}text-ink-mute{{end}}">
{{- if eq .OpenAlertCount 0 -}}—{{- else -}}{{.OpenAlertCount}}{{- end -}}
<div class="text-right mono {{if gt $h.OpenAlertCount 0}}text-bad font-medium{{else}}text-ink-mute{{end}}">
{{- if eq $h.OpenAlertCount 0 -}}—{{- else -}}{{$h.OpenAlertCount}}{{- end -}}
</div>
<div class="flex gap-1.5 flex-wrap">
{{- range .Tags -}}
{{- range $h.Tags -}}
<span class="tag">{{.}}</span>
{{- end -}}
</div>
<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>
{{- else if .CurrentJobID -}}
<a href="/jobs/{{deref .CurrentJobID}}" class="btn btn-ghost">View job →</a>
{{- else if $h.CurrentJobID -}}
<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 -}}
<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 -}}
</div>
</div>