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