Add scheduled and manual backups
Backups are .tar.gz archives of a consistent database snapshot (VACUUM INTO) plus the images folder. They run on a cron schedule, daily at 3:00 by default, and the newest N scheduled ones are kept (7 by default). Manual backups are never pruned. Settings → Backups offers schedule presets or a custom cron expression, retention, "Back up now", and download/delete for each backup. A small built-in parser handles standard 5-field cron syntax with names, ranges, steps and @daily-style shorthands. Backups go to CANON_BACKUP_DIR (default data/backups). Compose mounts a separate canon-backups volume at /backups, overridable with CANON_BACKUP_PATH. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72b49d709a
commit
f3185ba7bd
18 files changed
+940
-10
No files matched your search
@@ -20,11 +20,17 @@ CANON_IGDB_CLIENT_SECRET=
|
||||
# CANON_HOST=0.0.0.0 # all network interfaces; 127.0.0.1 = this machine only
|
||||
# CANON_PORT=4242
|
||||
# CANON_LOG_LEVEL=debug
|
||||
# CANON_BACKUP_DIR=./data/backups # outside Docker; Compose sets /backups
|
||||
|
||||
# Time zone for backup schedules (default UTC in Docker).
|
||||
# TZ=America/Los_Angeles
|
||||
|
||||
# ---- Docker Compose only (not read by Canon itself) ----
|
||||
# Where the database and images live. Default: the canon_canon-data volume.
|
||||
# Use an absolute host path to keep them in a folder (writable by UID 1000).
|
||||
# CANON_DATA_PATH=/srv/canon
|
||||
# Where backups are written. Default: the canon_canon-backups volume.
|
||||
# CANON_BACKUP_PATH=/mnt/nas/canon-backups
|
||||
# Host address and port to publish on.
|
||||
# CANON_BIND=0.0.0.0
|
||||
# CANON_PUBLISH_PORT=4242
|
||||
|
||||
+2
-2
@@ -10,12 +10,12 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}"
|
||||
FROM alpine:3
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& adduser -D -u 1000 canon \
|
||||
&& mkdir -p /data && chown canon:canon /data
|
||||
&& mkdir -p /data /backups && chown canon:canon /data /backups
|
||||
COPY --from=build /out/canon /usr/local/bin/canon
|
||||
USER canon
|
||||
ENV CANON_DATA_DIR=/data \
|
||||
CANON_PORT=4242
|
||||
VOLUME /data
|
||||
VOLUME /data /backups
|
||||
EXPOSE 4242
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||
CMD wget -qO- http://127.0.0.1:${CANON_PORT}/healthz >/dev/null || exit 1
|
||||
|
||||
@@ -150,10 +150,21 @@ Canon was called TopDeck, and upgrading keeps all your data:
|
||||
|
||||
## Backups
|
||||
|
||||
Everything lives in the data volume: `canon.db` (SQLite) and `images/`. Copy the volume, or while running:
|
||||
Canon backs itself up every day at 3:00 and keeps the last 7. Change this under **Settings → Backups**: pick a preset (hourly, every 6 hours, daily, weekly, monthly), write your own cron expression, or turn scheduled backups off. **Back up now** makes a manual backup; manual backups are never deleted automatically. Every backup can be downloaded or deleted from the same page.
|
||||
|
||||
Each backup is a `.tar.gz` holding `canon.db` (a consistent snapshot, taken while Canon keeps running) and the `images/` folder.
|
||||
|
||||
- **Where:** `CANON_BACKUP_DIR`, by default `data/backups`. Docker Compose mounts the `canon_canon-backups` volume at `/backups`; set `CANON_BACKUP_PATH` to a host folder (a NAS share, say) to keep them off the data volume.
|
||||
- **Time zone:** schedules use the server's time zone, UTC in Docker unless you set `TZ` (e.g. `TZ=America/Los_Angeles`) in `.env`.
|
||||
- **Cron syntax:** `minute hour day month weekday`, with `*`, lists (`1,15`), ranges (`mon-fri`), steps (`*/6`) and `@daily`-style shorthands. For example, `30 2 * * mon-fri` is 2:30 on weekdays.
|
||||
|
||||
To restore, stop Canon, extract a backup into the data folder, and start it again:
|
||||
|
||||
```sh
|
||||
docker compose exec canon sh -c 'cd /data && tar c canon.db* images' > canon-backup.tar
|
||||
docker compose stop canon
|
||||
docker run --rm -v canon_canon-data:/data -v canon_canon-backups:/backups alpine \
|
||||
sh -c 'rm -f /data/canon.db-wal /data/canon.db-shm && tar xzf /backups/<file>.tar.gz -C /data && chown -R 1000:1000 /data'
|
||||
docker compose start canon
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
+5
-1
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/canon/internal/auth"
|
||||
"github.com/cdnutter/canon/internal/backup"
|
||||
"github.com/cdnutter/canon/internal/config"
|
||||
"github.com/cdnutter/canon/internal/imgcache"
|
||||
"github.com/cdnutter/canon/internal/providers"
|
||||
@@ -58,7 +59,8 @@ func run(log *slog.Logger) error {
|
||||
if ver == "dev" {
|
||||
ver = fmt.Sprintf("dev-%d", time.Now().Unix())
|
||||
}
|
||||
srv, err := server.New(cfg, st, images, log, ver)
|
||||
backups := backup.New(cfg.BackupDir, images.Dir(), st, log)
|
||||
srv, err := server.New(cfg, st, images, backups, log, ver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -70,6 +72,8 @@ func run(log *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
go backups.Run(ctx)
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(6 * time.Hour)
|
||||
defer t.Stop()
|
||||
|
||||
@@ -23,6 +23,11 @@ services:
|
||||
# A named volume by default, so a Git sync never touches the database.
|
||||
# Set CANON_DATA_PATH to a host folder (e.g. ./data) to keep it there.
|
||||
- ${CANON_DATA_PATH:-canon-data}:/data
|
||||
# Backups get their own mount so they can live somewhere else, like a
|
||||
# NAS share. Set CANON_BACKUP_PATH to a host folder to use one.
|
||||
- ${CANON_BACKUP_PATH:-canon-backups}:/backups
|
||||
environment:
|
||||
CANON_BACKUP_DIR: /backups
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
@@ -31,6 +36,7 @@ services:
|
||||
|
||||
volumes:
|
||||
canon-data:
|
||||
canon-backups:
|
||||
|
||||
networks:
|
||||
chromanet:
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// Package backup writes the database and cached images to .tar.gz archives,
|
||||
// on demand and on a cron schedule.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/canon/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultCron = "0 3 * * *" // every day at 3:00
|
||||
DefaultKeep = 7
|
||||
MaxKeep = 365
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("backup not found")
|
||||
|
||||
// Scheduled backups are named canon-YYYYMMDD-HHMMSS.tar.gz; ones made with
|
||||
// "Back up now" end in -manual and are never pruned automatically.
|
||||
var nameRe = regexp.MustCompile(`^canon-\d{8}-\d{6}(-\d+)?(-manual)?\.tar\.gz$`)
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Created time.Time `json:"created"`
|
||||
Manual bool `json:"manual"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
LastRun time.Time `json:"lastRun,omitzero"`
|
||||
LastErr string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
dir string
|
||||
imagesDir string
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
|
||||
run sync.Mutex // one backup at a time
|
||||
reload chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
status Status
|
||||
}
|
||||
|
||||
func New(dir, imagesDir string, st *store.Store, log *slog.Logger) *Manager {
|
||||
return &Manager{dir: dir, imagesDir: imagesDir, store: st, log: log, reload: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (m *Manager) Dir() string { return m.dir }
|
||||
|
||||
func (m *Manager) Status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
// Reschedule makes the scheduler re-read its settings.
|
||||
func (m *Manager) Reschedule() {
|
||||
select {
|
||||
case m.reload <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Settings returns the configured schedule and retention, with defaults.
|
||||
func Settings(set map[string]string) (cron string, keep int) {
|
||||
cron = strings.TrimSpace(set[store.SettingBackupCron])
|
||||
if cron == "" {
|
||||
cron = DefaultCron
|
||||
}
|
||||
keep = DefaultKeep
|
||||
if n, err := strconv.Atoi(set[store.SettingBackupKeep]); err == nil && n > 0 {
|
||||
keep = n
|
||||
}
|
||||
return cron, keep
|
||||
}
|
||||
|
||||
// NextRun is when the schedule next fires after t, or nil when backups are
|
||||
// off or the schedule is invalid.
|
||||
func NextRun(cron string, t time.Time) *time.Time {
|
||||
if cron == "off" {
|
||||
return nil
|
||||
}
|
||||
sched, err := ParseCron(cron)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
next := sched.Next(t)
|
||||
if next.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &next
|
||||
}
|
||||
|
||||
// Run backs up on schedule until ctx is canceled.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
for {
|
||||
set, err := m.store.Settings(ctx)
|
||||
if err != nil {
|
||||
m.log.Warn("backup: loading settings", "err", err)
|
||||
}
|
||||
cron, keep := Settings(set)
|
||||
var next time.Time
|
||||
if n := NextRun(cron, time.Now()); n != nil {
|
||||
next = *n
|
||||
} else if cron != "off" {
|
||||
m.log.Warn("backup: invalid schedule, not backing up", "cron", cron)
|
||||
}
|
||||
|
||||
// Wake at least hourly so a changed clock or a suspended host
|
||||
// doesn't delay a backup by long.
|
||||
wait := time.Hour
|
||||
if !next.IsZero() {
|
||||
wait = min(wait, time.Until(next))
|
||||
}
|
||||
timer := time.NewTimer(max(wait, 0))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-m.reload:
|
||||
timer.Stop()
|
||||
continue
|
||||
case <-timer.C:
|
||||
}
|
||||
if next.IsZero() || time.Now().Before(next) {
|
||||
continue
|
||||
}
|
||||
if _, err := m.Create(ctx, false); err != nil {
|
||||
m.log.Error("scheduled backup failed", "err", err)
|
||||
continue
|
||||
}
|
||||
if err := m.prune(keep); err != nil {
|
||||
m.log.Warn("backup: removing old backups", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create writes a new backup and returns it.
|
||||
func (m *Manager) Create(ctx context.Context, manual bool) (info Info, err error) {
|
||||
m.run.Lock()
|
||||
defer m.run.Unlock()
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
m.status.LastRun = time.Now()
|
||||
m.status.LastErr = ""
|
||||
if err != nil {
|
||||
m.status.LastErr = err.Error()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
if err := os.MkdirAll(m.dir, 0o755); err != nil {
|
||||
return info, err
|
||||
}
|
||||
name := m.freeName(time.Now(), manual)
|
||||
tmpDir, err := os.MkdirTemp(m.dir, ".tmp-")
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbCopy := filepath.Join(tmpDir, "canon.db")
|
||||
if err := m.store.SnapshotTo(ctx, dbCopy); err != nil {
|
||||
return info, fmt.Errorf("copying database: %w", err)
|
||||
}
|
||||
partial := filepath.Join(tmpDir, name)
|
||||
if err := m.writeArchive(partial, dbCopy); err != nil {
|
||||
return info, err
|
||||
}
|
||||
final := filepath.Join(m.dir, name)
|
||||
if err := os.Rename(partial, final); err != nil {
|
||||
return info, err
|
||||
}
|
||||
fi, err := os.Stat(final)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info = Info{Name: name, Size: fi.Size(), Created: fi.ModTime(), Manual: manual}
|
||||
m.log.Info("backup written", "file", final, "size", fi.Size(), "manual", manual)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m *Manager) freeName(t time.Time, manual bool) string {
|
||||
base := "canon-" + t.Format("20060102-150405")
|
||||
suffix := ".tar.gz"
|
||||
if manual {
|
||||
suffix = "-manual.tar.gz"
|
||||
}
|
||||
name := base + suffix
|
||||
for i := 2; ; i++ {
|
||||
if _, err := os.Stat(filepath.Join(m.dir, name)); errors.Is(err, os.ErrNotExist) {
|
||||
return name
|
||||
}
|
||||
name = fmt.Sprintf("%s-%d%s", base, i, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) writeArchive(path, dbCopy string) (err error) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
gz := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gz)
|
||||
if err := addFile(tw, dbCopy, "canon.db"); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(m.imagesDir)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.Type().IsRegular() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue // skip in-progress uploads
|
||||
}
|
||||
if err := addFile(tw, filepath.Join(m.imagesDir, e.Name()), "images/"+e.Name()); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue // removed while we were working
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
func addFile(tw *tar.Writer, src, name string) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: name, Mode: 0o644, Size: fi.Size(), ModTime: fi.ModTime(), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.CopyN(tw, f, fi.Size())
|
||||
return err
|
||||
}
|
||||
|
||||
// List returns backups, newest first.
|
||||
func (m *Manager) List() ([]Info, error) {
|
||||
entries, err := os.ReadDir(m.dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Info{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Info{}
|
||||
for _, e := range entries {
|
||||
if !e.Type().IsRegular() || !nameRe.MatchString(e.Name()) {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Info{Name: e.Name(), Size: fi.Size(), Created: fi.ModTime(), Manual: strings.HasSuffix(e.Name(), "-manual.tar.gz")})
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Info) int { return b.Created.Compare(a.Created) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Path returns the file for a backup name, refusing anything that isn't one.
|
||||
func (m *Manager) Path(name string) (string, error) {
|
||||
if !nameRe.MatchString(name) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
p := filepath.Join(m.dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(name string) error {
|
||||
p, err := m.Path(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
// prune keeps the newest keep scheduled backups. Manual ones stay until
|
||||
// deleted by hand.
|
||||
func (m *Manager) prune(keep int) error {
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := 0
|
||||
for _, b := range all {
|
||||
if b.Manual {
|
||||
continue
|
||||
}
|
||||
if n++; n > keep {
|
||||
if err := os.Remove(filepath.Join(m.dir, b.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.log.Info("removed old backup", "file", b.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/cdnutter/canon/internal/store"
|
||||
)
|
||||
|
||||
func TestCreateListPrune(t *testing.T) {
|
||||
data := t.TempDir()
|
||||
st, err := store.Open(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
images := filepath.Join(data, "images")
|
||||
os.MkdirAll(images, 0o755)
|
||||
os.WriteFile(filepath.Join(images, "abc.jpg"), []byte("jpeg"), 0o644)
|
||||
os.WriteFile(filepath.Join(images, ".upload-123"), []byte("partial"), 0o644)
|
||||
|
||||
m := New(filepath.Join(data, "backups"), images, st, slog.New(slog.DiscardHandler))
|
||||
ctx := context.Background()
|
||||
manual, err := m.Create(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := archiveNames(t, filepath.Join(m.Dir(), manual.Name)); !slices.Equal(got, []string{"canon.db", "images/abc.jpg"}) {
|
||||
t.Fatalf("archive holds %v", got)
|
||||
}
|
||||
for range 3 {
|
||||
if _, err := m.Create(ctx, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := m.prune(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := m.List()
|
||||
var scheduled, manuals int
|
||||
for _, b := range list {
|
||||
if b.Manual {
|
||||
manuals++
|
||||
} else {
|
||||
scheduled++
|
||||
}
|
||||
}
|
||||
if scheduled != 2 || manuals != 1 {
|
||||
t.Fatalf("after prune: %d scheduled, %d manual", scheduled, manuals)
|
||||
}
|
||||
if _, err := m.Path("../canon.db"); err == nil {
|
||||
t.Fatal("path traversal accepted")
|
||||
}
|
||||
if err := m.Delete(manual.Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func archiveNames(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var names []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return names
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = append(names, h.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Schedule is a parsed five-field cron expression:
|
||||
// minute hour day-of-month month day-of-week.
|
||||
type Schedule struct {
|
||||
minute, hour, dom, month, dow uint64 // bit n set = value n allowed
|
||||
domAny, dowAny bool
|
||||
}
|
||||
|
||||
var shorthands = map[string]string{
|
||||
"@hourly": "0 * * * *",
|
||||
"@daily": "0 0 * * *",
|
||||
"@midnight": "0 0 * * *",
|
||||
"@weekly": "0 0 * * 0",
|
||||
"@monthly": "0 0 1 * *",
|
||||
"@yearly": "0 0 1 1 *",
|
||||
"@annually": "0 0 1 1 *",
|
||||
}
|
||||
|
||||
var monthNames = map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}
|
||||
var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
|
||||
|
||||
// ParseCron parses standard cron syntax: *, lists (1,15), ranges (1-5),
|
||||
// steps (*/15, 0-30/10), month and weekday names, and the @daily-style
|
||||
// shorthands. Sunday is 0 or 7.
|
||||
func ParseCron(expr string) (Schedule, error) {
|
||||
expr = strings.TrimSpace(strings.ToLower(expr))
|
||||
if s, ok := shorthands[expr]; ok {
|
||||
expr = s
|
||||
}
|
||||
f := strings.Fields(expr)
|
||||
if len(f) != 5 {
|
||||
return Schedule{}, fmt.Errorf("a cron schedule has 5 fields (minute hour day month weekday), got %d", len(f))
|
||||
}
|
||||
var s Schedule
|
||||
var err error
|
||||
if s.minute, err = parseField(f[0], 0, 59, nil); err != nil {
|
||||
return s, fmt.Errorf("minute: %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(f[1], 0, 23, nil); err != nil {
|
||||
return s, fmt.Errorf("hour: %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(f[2], 1, 31, nil); err != nil {
|
||||
return s, fmt.Errorf("day of month: %w", err)
|
||||
}
|
||||
if s.month, err = parseField(f[3], 1, 12, monthNames); err != nil {
|
||||
return s, fmt.Errorf("month: %w", err)
|
||||
}
|
||||
if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil {
|
||||
return s, fmt.Errorf("weekday: %w", err)
|
||||
}
|
||||
if s.dow&(1<<7) != 0 {
|
||||
s.dow |= 1 // 7 is Sunday too
|
||||
}
|
||||
s.domAny = f[2] == "*" || strings.HasPrefix(f[2], "*/")
|
||||
s.dowAny = f[4] == "*" || strings.HasPrefix(f[4], "*/")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseField(field string, min, max int, names map[string]int) (uint64, error) {
|
||||
var bits uint64
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
rng, stepStr, hasStep := strings.Cut(part, "/")
|
||||
step := 1
|
||||
if hasStep {
|
||||
n, err := strconv.Atoi(stepStr)
|
||||
if err != nil || n < 1 {
|
||||
return 0, fmt.Errorf("bad step %q", stepStr)
|
||||
}
|
||||
step = n
|
||||
}
|
||||
lo, hi := min, max
|
||||
if rng != "*" {
|
||||
a, b, isRange := strings.Cut(rng, "-")
|
||||
var err error
|
||||
if lo, err = parseValue(a, min, max, names); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hi = lo
|
||||
if isRange {
|
||||
if hi, err = parseValue(b, min, max, names); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if hi < lo {
|
||||
return 0, fmt.Errorf("range %q runs backwards", rng)
|
||||
}
|
||||
} else if hasStep {
|
||||
hi = max // "5/10" means from 5 to the end, every 10
|
||||
}
|
||||
}
|
||||
for v := lo; v <= hi; v += step {
|
||||
bits |= 1 << uint(v)
|
||||
}
|
||||
}
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
func parseValue(s string, min, max int, names map[string]int) (int, error) {
|
||||
if n, ok := names[s]; ok {
|
||||
return n, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%q isn't a number", s)
|
||||
}
|
||||
if n < min || n > max {
|
||||
return 0, fmt.Errorf("%d is outside %d-%d", n, min, max)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Next returns the first matching minute strictly after t, in t's
|
||||
// location. It returns the zero time if nothing matches within 5 years
|
||||
// (e.g. "0 0 30 2 *").
|
||||
func (s Schedule) Next(t time.Time) time.Time {
|
||||
t = t.Truncate(time.Minute).Add(time.Minute)
|
||||
limit := t.AddDate(5, 0, 0)
|
||||
for t.Before(limit) {
|
||||
if s.month&(1<<uint(t.Month())) == 0 {
|
||||
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
if !s.dayMatches(t) {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
if s.hour&(1<<uint(t.Hour())) == 0 {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
if s.minute&(1<<uint(t.Minute())) == 0 {
|
||||
t = t.Add(time.Minute)
|
||||
continue
|
||||
}
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// dayMatches follows cron's rule: when both day fields are restricted, a
|
||||
// day matching either one counts.
|
||||
func (s Schedule) dayMatches(t time.Time) bool {
|
||||
dom := s.dom&(1<<uint(t.Day())) != 0
|
||||
dow := s.dow&(1<<uint(t.Weekday())) != 0
|
||||
switch {
|
||||
case s.domAny && s.dowAny:
|
||||
return true
|
||||
case s.domAny:
|
||||
return dow
|
||||
case s.dowAny:
|
||||
return dom
|
||||
default:
|
||||
return dom || dow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNext(t *testing.T) {
|
||||
utc := func(s string) time.Time {
|
||||
v, err := time.Parse("2006-01-02 15:04", s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
cases := []struct{ expr, from, want string }{
|
||||
{"0 3 * * *", "2026-09-30 02:59", "2026-09-30 03:00"},
|
||||
{"0 3 * * *", "2026-09-30 03:00", "2026-10-01 03:00"},
|
||||
{"@daily", "2026-12-31 23:30", "2027-01-01 00:00"},
|
||||
{"*/15 * * * *", "2026-09-30 10:07", "2026-09-30 10:15"},
|
||||
{"30 4 * * sun", "2026-09-30 00:00", "2026-10-04 04:30"}, // Wed → Sun
|
||||
{"30 4 * * 7", "2026-09-30 00:00", "2026-10-04 04:30"},
|
||||
{"0 0 1,15 * *", "2026-09-02 00:00", "2026-09-15 00:00"},
|
||||
{"0 9 * * mon-fri", "2026-10-02 10:00", "2026-10-05 09:00"}, // Fri after 9 → Mon
|
||||
{"0 0 29 2 *", "2026-03-01 00:00", "2028-02-29 00:00"},
|
||||
{"0 12 13 * 5", "2026-09-30 00:00", "2026-10-02 12:00"}, // Friday OR the 13th
|
||||
{"5/20 * * * *", "2026-09-30 10:26", "2026-09-30 10:45"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s, err := ParseCron(c.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.expr, err)
|
||||
}
|
||||
if got := s.Next(utc(c.from)); !got.Equal(utc(c.want)) {
|
||||
t.Errorf("%s from %s = %s, want %s", c.expr, c.from, got.Format("2006-01-02 15:04 Mon"), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronErrors(t *testing.T) {
|
||||
for _, expr := range []string{"", "* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "5-1 * * * *", "*/0 * * * *", "a * * * *"} {
|
||||
if _, err := ParseCron(expr); err == nil {
|
||||
t.Errorf("%q: expected an error", expr)
|
||||
}
|
||||
}
|
||||
s, _ := ParseCron("0 0 30 2 *")
|
||||
if !s.Next(time.Now()).IsZero() {
|
||||
t.Error("Feb 30 should never run")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -16,6 +17,7 @@ type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
DataDir string
|
||||
BackupDir string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
SecureCookies bool
|
||||
@@ -35,11 +37,13 @@ func Load() Config {
|
||||
// just this machine. Use 127.0.0.1 to keep it local.
|
||||
host := env("CANON_HOST", "0.0.0.0")
|
||||
port := env("CANON_PORT", "4242")
|
||||
dataDir := env("CANON_DATA_DIR", "./data")
|
||||
return Config{
|
||||
ListenAddr: env("CANON_LISTEN", net.JoinHostPort(host, port)),
|
||||
Host: host,
|
||||
Port: port,
|
||||
DataDir: env("CANON_DATA_DIR", "./data"),
|
||||
DataDir: dataDir,
|
||||
BackupDir: env("CANON_BACKUP_DIR", filepath.Join(dataDir, "backups")),
|
||||
AdminUsername: env("CANON_ADMIN_USERNAME", ""),
|
||||
AdminPassword: env("CANON_ADMIN_PASSWORD", ""),
|
||||
SecureCookies: envBool("CANON_SECURE_COOKIES", false),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/canon/internal/backup"
|
||||
"github.com/cdnutter/canon/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) apiBackups(w http.ResponseWriter, r *http.Request) {
|
||||
set, err := s.store.Settings(r.Context())
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
list, err := s.backups.List()
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
cron, keep := backup.Settings(set)
|
||||
zone, _ := time.Now().Zone()
|
||||
if tz := os.Getenv("TZ"); tz != "" && tz != zone {
|
||||
zone = tz + ", " + zone
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"backups": list,
|
||||
"status": s.backups.Status(),
|
||||
"next": backup.NextRun(cron, time.Now()),
|
||||
"cron": cron,
|
||||
"keep": keep,
|
||||
"dir": s.backups.Dir(),
|
||||
"timezone": zone,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) apiCreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := s.backups.Create(r.Context(), true)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, info)
|
||||
}
|
||||
|
||||
func (s *Server) apiBackupSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Cron string `json:"cron"`
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
cron := strings.Join(strings.Fields(in.Cron), " ")
|
||||
if strings.EqualFold(cron, "off") || cron == "" {
|
||||
cron = "off"
|
||||
} else {
|
||||
sched, err := backup.ParseCron(cron)
|
||||
if err != nil {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Schedule: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if sched.Next(time.Now()).IsZero() {
|
||||
s.writeErr(w, store.ValidationError{Msg: "That schedule never runs"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Keep < 1 || in.Keep > backup.MaxKeep {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Keep between 1 and " + strconv.Itoa(backup.MaxKeep) + " backups"})
|
||||
return
|
||||
}
|
||||
if err := s.store.SetSettings(r.Context(), map[string]string{
|
||||
store.SettingBackupCron: cron,
|
||||
store.SettingBackupKeep: strconv.Itoa(in.Keep),
|
||||
}); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
s.backups.Reschedule()
|
||||
s.apiBackups(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) apiDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.backups.Delete(r.PathValue("name"))
|
||||
if errors.Is(err, backup.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleBackupDownload(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
path, err := s.backups.Path(name)
|
||||
if err != nil {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/canon/internal/auth"
|
||||
"github.com/cdnutter/canon/internal/backup"
|
||||
"github.com/cdnutter/canon/internal/config"
|
||||
"github.com/cdnutter/canon/internal/imgcache"
|
||||
"github.com/cdnutter/canon/internal/providers"
|
||||
@@ -35,6 +36,7 @@ type Server struct {
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
images *imgcache.Cache
|
||||
backups *backup.Manager
|
||||
providers *providers.Registry
|
||||
log *slog.Logger
|
||||
|
||||
@@ -48,11 +50,12 @@ type Server struct {
|
||||
previewClient *http.Client
|
||||
}
|
||||
|
||||
func New(cfg config.Config, st *store.Store, images *imgcache.Cache, log *slog.Logger, version string) (*Server, error) {
|
||||
func New(cfg config.Config, st *store.Store, images *imgcache.Cache, backups *backup.Manager, log *slog.Logger, version string) (*Server, error) {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
images: images,
|
||||
backups: backups,
|
||||
log: log,
|
||||
assetVer: version,
|
||||
loginLimit: auth.NewLimiter(10, 15*time.Minute),
|
||||
@@ -125,6 +128,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /admin/lists/{id}", s.requireUser(s.handleAdminList))
|
||||
mux.HandleFunc("GET /admin/settings", s.requireUser(s.handleAdminSettings))
|
||||
mux.HandleFunc("GET /admin/preview", s.requireUser(s.handleAdminPreview))
|
||||
mux.HandleFunc("GET /admin/backups/{name}", s.requireUser(s.handleBackupDownload))
|
||||
|
||||
// Admin JSON API
|
||||
api := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, s.requireAPI(h)) }
|
||||
@@ -147,6 +151,10 @@ func (s *Server) Handler() http.Handler {
|
||||
api("DELETE /api/settings/avatar", s.apiDeleteAvatar)
|
||||
api("POST /api/integrations/test", s.apiTestIntegration)
|
||||
api("PUT /api/account", s.apiUpdateAccount)
|
||||
api("GET /api/backups", s.apiBackups)
|
||||
api("POST /api/backups", s.apiCreateBackup)
|
||||
api("PUT /api/backups/schedule", s.apiBackupSchedule)
|
||||
api("DELETE /api/backups/{name}", s.apiDeleteBackup)
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.notFound(w, r) })
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ const (
|
||||
SettingShowPreviews = "show_previews"
|
||||
SettingMusicProvider = "music_provider"
|
||||
SettingTVProvider = "tv_provider"
|
||||
SettingBackupCron = "backup_cron" // cron expression, or "off"
|
||||
SettingBackupKeep = "backup_keep" // how many scheduled backups to keep
|
||||
|
||||
SettingTMDBKey = "tmdb_api_key"
|
||||
SettingTVDBKey = "tvdb_api_key"
|
||||
|
||||
@@ -143,3 +143,10 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SnapshotTo writes a consistent copy of the database to path, which must
|
||||
// not exist yet. It's safe to call while the app is serving requests.
|
||||
func (s *Store) SnapshotTo(ctx context.Context, path string) error {
|
||||
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, path)
|
||||
return err
|
||||
}
|
||||
@@ -777,3 +777,11 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ---------- settings: backups ---------- */
|
||||
.backup-status { font-size: 14px; }
|
||||
.backup-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); }
|
||||
.backup-list li { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.backup-list .backup-when { flex: 1; min-width: 0; font-size: 14px; font-weight: 550; }
|
||||
.backup-list .backup-meta { display: block; color: var(--muted); font-size: 12.5px; font-weight: 400; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.backup-list .backup-actions { display: flex; gap: 2px; flex: none; }
|
||||
@@ -43,5 +43,6 @@
|
||||
<symbol id="alert" viewBox="0 0 24 24"><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3ZM12 9v4M12 17h.01"/></symbol>
|
||||
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
|
||||
<symbol id="crown" viewBox="0 0 24 24"><path d="M11.6 3.3a.5.5 0 0 1 .8 0l2.9 5.5a1 1 0 0 0 1.5.3l4.4-3.7a.5.5 0 0 1 .8.5l-2.8 10.4a1 1 0 0 1-1 .7H5.8a1 1 0 0 1-1-.7L2 5.9a.5.5 0 0 1 .8-.5l4.4 3.7a1 1 0 0 0 1.5-.3zM5 21h14"/></symbol>
|
||||
<symbol id="archive" viewBox="0 0 24 24"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/></symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M8 16H3v5"/></symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -1,5 +1,5 @@
|
||||
// Settings: profile, appearance, integrations, account.
|
||||
import { h, icon, api, toast, busy } from "./common.js";
|
||||
// Settings: profile, appearance, integrations, backups, account.
|
||||
import { h, icon, api, toast, busy, confirmDialog } from "./common.js";
|
||||
|
||||
const root = document.getElementById("settings");
|
||||
let settings;
|
||||
@@ -318,6 +318,127 @@ function integrationsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- backups ----------
|
||||
|
||||
const SCHEDULES = [
|
||||
["off", "Off"],
|
||||
["0 * * * *", "Every hour"],
|
||||
["0 */6 * * *", "Every 6 hours"],
|
||||
["0 3 * * *", "Every day at 3:00"],
|
||||
["0 3 * * 0", "Every Sunday at 3:00"],
|
||||
["0 3 1 * *", "The 1st of each month at 3:00"],
|
||||
];
|
||||
|
||||
const when = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
function formatSize(bytes) {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
||||
return `${bytes.toFixed(i && bytes < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function backupsSection() {
|
||||
let data;
|
||||
const preset = h("select", {}, SCHEDULES.map(([v, label]) => h("option", { value: v }, label)), h("option", { value: "custom" }, "Custom (cron)…"));
|
||||
const cron = h("input", { spellcheck: false, placeholder: "30 2 * * mon-fri", style: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" } });
|
||||
const cronField = field("Cron expression", cron);
|
||||
const keep = h("input", { type: "number", min: 1, max: 365, style: { maxWidth: "120px" } });
|
||||
const status = h("p", { class: "backup-status", "aria-live": "polite" });
|
||||
const list = h("ul", { class: "backup-list" });
|
||||
const empty = h("p", { class: "muted" }, "No backups yet.");
|
||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save schedule");
|
||||
const now = h("button", { type: "button", class: "btn" }, icon("archive"), "Back up now");
|
||||
|
||||
const showCustom = () => { cronField.hidden = preset.value !== "custom"; };
|
||||
preset.addEventListener("change", () => {
|
||||
if (preset.value === "custom" && !cron.value) cron.value = data.cron === "off" ? "0 3 * * *" : data.cron;
|
||||
showCustom();
|
||||
});
|
||||
|
||||
const render = (d) => {
|
||||
data = d;
|
||||
const known = SCHEDULES.some(([v]) => v === d.cron);
|
||||
preset.value = known ? d.cron : "custom";
|
||||
cron.value = known ? "" : d.cron;
|
||||
keep.value = d.keep;
|
||||
showCustom();
|
||||
const parts = [d.next ? `Next backup: ${when(d.next)}.` : "Scheduled backups are off."];
|
||||
if (d.status.lastRun) parts.push(d.status.lastError ? `The last backup failed: ${d.status.lastError}` : `Last backup: ${when(d.status.lastRun)}.`);
|
||||
status.textContent = parts.join(" ");
|
||||
status.className = "backup-status" + (d.status.lastError ? " alert alert--error" : "");
|
||||
list.replaceChildren(...d.backups.map(backupRow));
|
||||
empty.hidden = d.backups.length > 0;
|
||||
};
|
||||
const reload = async () => render(await api("GET", "/api/backups"));
|
||||
|
||||
const backupRow = (b) => h("li", {},
|
||||
h("span", { class: "backup-when" }, when(b.created), " ", b.manual ? h("span", { class: "badge badge--muted" }, "Manual") : null,
|
||||
h("span", { class: "backup-meta" }, `${formatSize(b.size)} · ${b.name}`)),
|
||||
h("span", { class: "backup-actions" },
|
||||
h("a", { class: "icon-btn", href: `/admin/backups/${encodeURIComponent(b.name)}`, download: b.name, title: "Download", "aria-label": `Download ${b.name}` }, icon("download")),
|
||||
h("button", {
|
||||
type: "button", class: "icon-btn icon-btn--danger", title: "Delete", "aria-label": `Delete ${b.name}`,
|
||||
onclick: async () => {
|
||||
if (!await confirmDialog({ title: "Delete this backup?", message: `${b.name} will be removed from the server.`, confirm: "Delete", danger: true })) return;
|
||||
try {
|
||||
await api("DELETE", `/api/backups/${encodeURIComponent(b.name)}`);
|
||||
toast("Backup deleted");
|
||||
await reload();
|
||||
} catch (err) { toast(err.message, { error: true }); }
|
||||
},
|
||||
}, icon("trash")),
|
||||
),
|
||||
);
|
||||
|
||||
now.addEventListener("click", async () => {
|
||||
busy(now, true);
|
||||
try {
|
||||
await api("POST", "/api/backups");
|
||||
toast("Backup created");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
busy(now, false);
|
||||
reload().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const form = h("form", { class: "stack" },
|
||||
h("div", { class: "row" },
|
||||
field("Schedule", preset),
|
||||
field("Scheduled backups to keep", keep, "Older ones are deleted. Manual backups are kept until you delete them."),
|
||||
),
|
||||
cronField,
|
||||
status,
|
||||
h("div", { class: "settings-actions" }, now, save),
|
||||
);
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
busy(save, true);
|
||||
try {
|
||||
render(await api("PUT", "/api/backups/schedule", {
|
||||
cron: preset.value === "custom" ? cron.value : preset.value,
|
||||
keep: Number(keep.value),
|
||||
}));
|
||||
toast("Backup schedule saved");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
busy(save, false);
|
||||
}
|
||||
});
|
||||
|
||||
const hint = h("span", { class: "field-hint" });
|
||||
const el = section("backups", "Backups",
|
||||
"Each backup is a .tar.gz holding your database and every image, so it's all you need to restore.",
|
||||
form, h("div", { class: "field" }, h("span", { class: "field-label" }, "Saved backups"), list, empty, hint));
|
||||
reload().then(() => {
|
||||
hint.textContent = `Stored in ${data.dir} on the server. Schedules use the server's time zone, ${data.timezone}.`;
|
||||
}).catch((err) => status.replaceChildren(err.message));
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------- account ----------
|
||||
|
||||
function accountSection() {
|
||||
@@ -358,7 +479,7 @@ async function init() {
|
||||
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
|
||||
return;
|
||||
}
|
||||
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), accountSection());
|
||||
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), backupsSection(), accountSection());
|
||||
root.removeAttribute("aria-busy");
|
||||
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<a href="#profile">{{icon "user"}}Profile</a>
|
||||
<a href="#appearance">{{icon "palette"}}Appearance</a>
|
||||
<a href="#integrations">{{icon "plug"}}Integrations</a>
|
||||
<a href="#backups">{{icon "archive"}}Backups</a>
|
||||
<a href="#account">{{icon "lock"}}Account</a>
|
||||
</nav>
|
||||
<div class="settings-body" id="settings" aria-busy="true">
|
||||
|
||||
Reference in new issue
Block a user