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