P2R-02: UI rewire against the slim-schedule + source-group model #2

Merged
steve merged 16 commits from p2r-02-ui-rebuild into main 2026-05-03 21:34:02 +01:00
72 changed files with 2707 additions and 718 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 .
+1 -1
View File
@@ -5,4 +5,4 @@ All have restic installed on them
I need to build a browser based management service that allows me to have a central single-plane-of-glass to monitor and manage all teh endpoints
All endpoints will be enabled for SSH (unless other methods are better?)
Plan out how we would go about this please?
Plan out how we would go about this please?
+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>