feat(admin): the nightly backup, made visible
It has run since the beginning and nothing showed it. A backup nobody can see is a backup nobody knows has been failing, and the failure is quiet: a volume gone read-only, a disk that filled, a container that has not been up at midnight all week. The first anybody hears of it is the day they need one. `ListBackupRuns` was already in the store and exposed nowhere. Now there is a list, a download, and a button that takes one now — the same work the nightly job does without its "one a day" check, because waiting until tonight to learn whether backups work at all is how somebody finds out on the day they need one, and it is the only way to get a snapshot from *before* something you are about to do. Restoring is deliberately not a button. Swapping a database under a server that is writing to it means stopping every writer, replacing the file and reopening, and a half-done version of that destroys the thing it was restoring. The panel says what to do instead. `RunBackupNow` turns out to have been a second copy of the backup logic, written for a button that did not exist yet, and the two had already drifted: it never created the backups directory and logged nothing. One implementation now. Administrators only, and sessions only. A backup is a complete copy of the database, so this hands out everybody's data in one file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
04a7ba1024
commit
1da7ea64bf
@@ -0,0 +1,158 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The nightly backup, made visible.
|
||||||
|
//
|
||||||
|
// It has run since the beginning and nothing showed it. A backup nobody can see is
|
||||||
|
// a backup nobody knows has been failing — and the failure mode is quiet: a volume
|
||||||
|
// that went read-only, a disk that filled, a container that has not been up at
|
||||||
|
// midnight for a week. The first time anybody finds out is the day they need one.
|
||||||
|
//
|
||||||
|
// Administrators only, and behind a session as well as the admin check. A backup
|
||||||
|
// file is a complete copy of the database, so downloading one is downloading
|
||||||
|
// everybody's data; a leaked API token must not be able to.
|
||||||
|
|
||||||
|
type backupRunJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Started string `json:"started_at"`
|
||||||
|
// Finished is absent while a backup is still running, and on one that was
|
||||||
|
// interrupted — which is the same shape from outside and honestly so: a run
|
||||||
|
// the process died in the middle of never finished.
|
||||||
|
Finished string `json:"finished_at,omitempty"`
|
||||||
|
Size int64 `json:"size_bytes"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
// Present says the file is still on disk. Rotation keeps the most recent
|
||||||
|
// fourteen, so an older row is a true record of a backup that was made and a
|
||||||
|
// file that is no longer there — and a download link for it would 404.
|
||||||
|
Present bool `json:"present"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
runs, err := s.db.ListBackupRuns(r.Context(), parseLimit(r.URL.Query().Get("limit"), 30, 100))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list backups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]backupRunJSON, 0, len(runs))
|
||||||
|
for _, b := range runs {
|
||||||
|
j := backupRunJSON{
|
||||||
|
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
||||||
|
Size: b.SizeBytes, Error: b.Error,
|
||||||
|
}
|
||||||
|
if b.FinishedAt != nil {
|
||||||
|
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if b.Path != "" {
|
||||||
|
if _, err := os.Stat(b.Path); err == nil {
|
||||||
|
j.Present = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, j)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"backups": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunBackup takes one now.
|
||||||
|
//
|
||||||
|
// The same function the nightly job runs, without the "has one run today" check.
|
||||||
|
// Waiting until tonight to learn whether backups work at all is how somebody finds
|
||||||
|
// out on the day they need one — and it is the only way to get a snapshot from
|
||||||
|
// *before* something you are about to do.
|
||||||
|
//
|
||||||
|
// Synchronous. A backup of a homelab database is a second or two, and a button
|
||||||
|
// that returns immediately and tells you nothing is a button you press twice.
|
||||||
|
func (s *Server) handleRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := jobs.Backup(r.Context(), s.cfg, s.db, s.log); err != nil {
|
||||||
|
s.internal(w, r, "run backup", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runs, err := s.db.ListBackupRuns(r.Context(), 1)
|
||||||
|
if err != nil || len(runs) == 0 {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := runs[0]
|
||||||
|
j := backupRunJSON{
|
||||||
|
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
||||||
|
Size: b.SizeBytes, Error: b.Error, Present: true,
|
||||||
|
}
|
||||||
|
if b.FinishedAt != nil {
|
||||||
|
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDownloadBackup sends one backup file.
|
||||||
|
//
|
||||||
|
// The path comes from the row rather than from the request, and is then checked to
|
||||||
|
// be inside the backups directory anyway. The row is written by this process and
|
||||||
|
// should always be safe; the check is there because "should always" is how a path
|
||||||
|
// traversal gets shipped, and the thing on the other side of it is every account's
|
||||||
|
// data in one file.
|
||||||
|
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
runs, err := s.db.ListBackupRuns(r.Context(), 100)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list backups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "backupID")
|
||||||
|
var path string
|
||||||
|
for _, b := range runs {
|
||||||
|
if b.ID == id {
|
||||||
|
path = b.Path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := filepath.Abs(s.cfg.BackupsDir())
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "backups dir", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
full, err := filepath.Abs(path)
|
||||||
|
if err != nil || !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := os.Open(full)
|
||||||
|
if err != nil {
|
||||||
|
// Rotation keeps the most recent fourteen, so a row older than that is a
|
||||||
|
// true record with no file behind it. Not an error — a 404 is the honest
|
||||||
|
// answer to "send me that one".
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "stat backup", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same treatment attachments get, and for a stronger reason: this is a
|
||||||
|
// SQLite database, and nothing should ever be tempted to render it inline.
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Disposition",
|
||||||
|
fmt.Sprintf("attachment; filename=%q", filepath.Base(full)))
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
http.ServeContent(w, r, "", info.ModTime(), file)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The backup panel exists because the job was invisible, so the thing to check is
|
||||||
|
// that it can be seen and asked for — and that the file it hands out is guarded
|
||||||
|
// like what it is, which is every account's data in one download.
|
||||||
|
func TestABackupCanBeTakenListedAndDownloaded(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// Nothing yet, and that is a state the list has to survive rather than a case
|
||||||
|
// nobody hits: it is what a fresh instance looks like.
|
||||||
|
_, empty := ts.do(t, "GET", "/api/v1/backups", nil)
|
||||||
|
if got, _ := empty["backups"].([]any); len(got) != 0 {
|
||||||
|
t.Fatalf("a fresh instance has %d backups", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, made := ts.do(t, "POST", "/api/v1/backups", nil)
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("run backup: status %d, body %v", resp.StatusCode, made)
|
||||||
|
}
|
||||||
|
id, _ := made["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("the new run has no id")
|
||||||
|
}
|
||||||
|
if size, _ := made["size_bytes"].(float64); size <= 0 {
|
||||||
|
t.Errorf("size_bytes = %v — a backup of a database with rows in it is not empty", made["size_bytes"])
|
||||||
|
}
|
||||||
|
if made["error"] != nil {
|
||||||
|
t.Errorf("the backup reported an error: %v", made["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/backups", nil)
|
||||||
|
rows, _ := listed["backups"].([]any)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("listed %d backups, want 1", len(rows))
|
||||||
|
}
|
||||||
|
row := rows[0].(map[string]any)
|
||||||
|
if row["id"] != id {
|
||||||
|
t.Errorf("the listed run is not the one that was made")
|
||||||
|
}
|
||||||
|
if row["present"] != true {
|
||||||
|
t.Error("present = false on a backup that was just written")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the file itself, which must arrive as a download rather than as
|
||||||
|
// something a browser might try to render.
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/backups/"+id, nil)
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
dl, err := ts.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dl.Body.Close()
|
||||||
|
if dl.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("download: status %d", dl.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/octet-stream", ct)
|
||||||
|
}
|
||||||
|
if cd := dl.Header.Get("Content-Disposition"); cd == "" || cd[:10] != "attachment" {
|
||||||
|
t.Errorf("Content-Disposition = %q, want an attachment", cd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An id that is not a backup is a 404, not a path to try opening.
|
||||||
|
if resp, _ := ts.do(t, "GET", "/api/v1/backups/../../etc/passwd", nil); resp.StatusCode == http.StatusOK {
|
||||||
|
t.Error("a traversal in the id was served")
|
||||||
|
}
|
||||||
|
if resp, _ := ts.do(t, "GET", "/api/v1/backups/nogetsomhelst", nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("an unknown id: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Administrators only, and sessions only. A backup is a complete copy of the
|
||||||
|
// database — a leaked token that could download one turns a stolen credential into
|
||||||
|
// a copy of everybody's data.
|
||||||
|
func TestBackupsAreAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
for _, c := range []struct{ method, path string }{
|
||||||
|
{"GET", "/api/v1/backups"},
|
||||||
|
{"POST", "/api/v1/backups"},
|
||||||
|
{"GET", "/api/v1/backups/whichever"},
|
||||||
|
} {
|
||||||
|
if resp, _ := other.do(t, c.method, c.path, nil); resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s %s as an ordinary user: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, admin.ID)
|
||||||
|
for _, c := range []struct{ method, path string }{
|
||||||
|
{"GET", "/api/v1/backups"},
|
||||||
|
{"POST", "/api/v1/backups"},
|
||||||
|
} {
|
||||||
|
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s %s with an admin's API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-36
@@ -162,22 +162,41 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
|||||||
if time.Since(last) < 23*time.Hour {
|
if time.Since(last) < 23*time.Hour {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return Backup(ctx, r.cfg, r.db, r.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup takes one snapshot now, records the run and rotates the old files.
|
||||||
|
//
|
||||||
|
// Exported and free of the Runner, because the settings page can ask for one too:
|
||||||
|
// waiting until tonight to find out whether backups work at all is how somebody
|
||||||
|
// discovers on the day they need one that the volume has been read-only for a
|
||||||
|
// month. The nightly job is this function behind a "has one run today" check; the
|
||||||
|
// button is this function without it.
|
||||||
|
//
|
||||||
|
// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a
|
||||||
|
// live WAL-mode database can miss the most recent writes or catch a page
|
||||||
|
// mid-update; VACUUM INTO produces a consistent, already-compacted database
|
||||||
|
// without blocking writers for the duration.
|
||||||
|
func Backup(ctx context.Context, cfg *config.Config, db *store.DB, log *slog.Logger) error {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
||||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
path := filepath.Join(cfg.BackupsDir(), name)
|
||||||
|
|
||||||
runID, err := r.db.StartBackupRun(ctx, started)
|
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("backups dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runID, err := db.StartBackupRun(ctx, started)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.db.Snapshot(ctx, path); err != nil {
|
if err := db.Snapshot(ctx, path); err != nil {
|
||||||
// A failed attempt can leave a partial file, which would then look like a
|
// A failed attempt can leave a partial file, which would then look like a
|
||||||
// backup to anybody reading the directory.
|
// backup to anybody reading the directory.
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
if recErr := r.db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
||||||
r.log.Warn("record backup failure", "err", recErr)
|
log.Warn("record backup failure", "err", recErr)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("snapshot: %w", err)
|
return fmt.Errorf("snapshot: %w", err)
|
||||||
}
|
}
|
||||||
@@ -186,13 +205,13 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
|||||||
if info, err := os.Stat(path); err == nil {
|
if info, err := os.Stat(path); err == nil {
|
||||||
size = info.Size()
|
size = info.Size()
|
||||||
}
|
}
|
||||||
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||||
r.log.Warn("record backup", "err", err)
|
log.Warn("record backup", "err", err)
|
||||||
}
|
}
|
||||||
r.log.Info("backup written", "path", path, "bytes", size,
|
log.Info("backup written", "path", path, "bytes", size,
|
||||||
"took", time.Since(started).Round(time.Millisecond).String())
|
"took", time.Since(started).Round(time.Millisecond).String())
|
||||||
|
|
||||||
return r.rotateBackups()
|
return rotateBackups(cfg, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotateBackups keeps the most recent fourteen and deletes the rest.
|
// rotateBackups keeps the most recent fourteen and deletes the rest.
|
||||||
@@ -200,8 +219,8 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
|||||||
// Counted by file, not by age: a container that was off for a month would
|
// Counted by file, not by age: a container that was off for a month would
|
||||||
// otherwise come back, find every backup older than fourteen days, and delete all
|
// otherwise come back, find every backup older than fourteen days, and delete all
|
||||||
// of them — leaving none at exactly the moment they were most likely to be needed.
|
// of them — leaving none at exactly the moment they were most likely to be needed.
|
||||||
func (r *Runner) rotateBackups() error {
|
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
|
||||||
entries, err := os.ReadDir(r.cfg.BackupsDir())
|
entries, err := os.ReadDir(cfg.BackupsDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -216,12 +235,12 @@ func (r *Runner) rotateBackups() error {
|
|||||||
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
|
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
|
||||||
|
|
||||||
for _, name := range backups[min(len(backups), backupKeepDays):] {
|
for _, name := range backups[min(len(backups), backupKeepDays):] {
|
||||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
path := filepath.Join(cfg.BackupsDir(), name)
|
||||||
if err := os.Remove(path); err != nil {
|
if err := os.Remove(path); err != nil {
|
||||||
r.log.Warn("remove old backup", "err", err, "path", path)
|
log.Warn("remove old backup", "err", err, "path", path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r.log.Info("old backup removed", "path", path)
|
log.Info("old backup removed", "path", path)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -291,27 +310,12 @@ func (r *Runner) purgeSessions(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunBackupNow is what the "back up now" button calls, and what the tests use.
|
// RunBackupNow is the Runner's way in, for the tests that already hold one.
|
||||||
|
//
|
||||||
|
// It was a second copy of Backup — written when the button that would call it did
|
||||||
|
// not exist yet — and the two had already drifted: this one never created the
|
||||||
|
// backups directory, and logged nothing. One implementation, so a fix to the
|
||||||
|
// nightly path is a fix to the button as well.
|
||||||
func (r *Runner) RunBackupNow(ctx context.Context) error {
|
func (r *Runner) RunBackupNow(ctx context.Context) error {
|
||||||
started := time.Now()
|
return Backup(ctx, r.cfg, r.db, r.log)
|
||||||
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
|
||||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
|
||||||
|
|
||||||
runID, err := r.db.StartBackupRun(ctx, started)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := r.db.Snapshot(ctx, path); err != nil {
|
|
||||||
os.Remove(path)
|
|
||||||
_ = r.db.FinishBackupRun(ctx, runID, "", 0, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var size int64
|
|
||||||
if info, err := os.Stat(path); err == nil {
|
|
||||||
size = info.Size()
|
|
||||||
}
|
|
||||||
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return r.rotateBackups()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func TestBackupsRotateByCountNotAge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runner.rotateBackups(); err != nil {
|
if err := rotateBackups(cfg, runner.log); err != nil {
|
||||||
t.Fatalf("rotate: %v", err)
|
t.Fatalf("rotate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,67 @@
|
|||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
// --- backups ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let backups = $state([]);
|
||||||
|
let backupsLoaded = $state(false);
|
||||||
|
let backingUp = $state(false);
|
||||||
|
let lastBackup = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!app.user?.is_admin) return;
|
||||||
|
api
|
||||||
|
.listBackups()
|
||||||
|
.then((r) => {
|
||||||
|
backups = r.backups;
|
||||||
|
backupsLoaded = true;
|
||||||
|
})
|
||||||
|
.catch((e) => app.toast(humanMessage(e)));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes one now.
|
||||||
|
*
|
||||||
|
* Worth a button of its own rather than trusting the nightly job: waiting until
|
||||||
|
* tonight to find out whether backups work at all is how somebody finds out on
|
||||||
|
* the day they need one. It is also the only way to get a snapshot from *before*
|
||||||
|
* something you are about to do.
|
||||||
|
*/
|
||||||
|
async function backupNow() {
|
||||||
|
backingUp = true;
|
||||||
|
lastBackup = '';
|
||||||
|
try {
|
||||||
|
const made = await api.runBackup();
|
||||||
|
backups = [made, ...backups];
|
||||||
|
lastBackup = `Kopi taget — ${size(made.size_bytes)}`;
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
backingUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function size(bytes) {
|
||||||
|
if (!bytes) return 'tom';
|
||||||
|
const mb = bytes / 1024 / 1024;
|
||||||
|
if (mb >= 1) return `${mb.toFixed(1)} MB`;
|
||||||
|
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
const then = new Date(iso);
|
||||||
|
const seconds = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (seconds < 60) return 'lige nu';
|
||||||
|
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||||
|
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||||
|
return then.toLocaleString('da-DK', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Todoist import ------------------------------------------------------------
|
// --- Todoist import ------------------------------------------------------------
|
||||||
|
|
||||||
let importing = $state(false);
|
let importing = $state(false);
|
||||||
@@ -208,6 +269,68 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
||||||
|
is a complete copy of the database, so this panel hands out everybody's data. -->
|
||||||
|
{#if app.user?.is_admin}
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>Sikkerhedskopier</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Databasen kopieres hver nat og de seneste fjorten gemmes. Kopierne er
|
||||||
|
talt, ikke dateret: en container, der har stået slukket en måned, må ikke
|
||||||
|
komme tilbage og slette dem alle for at være for gamle.
|
||||||
|
</p>
|
||||||
|
<p class="hint">
|
||||||
|
Vedhæftede filer er ikke med. De er indholdsadresserede og bliver kun lagt
|
||||||
|
til, så fjorten ens kopier af dem ville fylde det drev op, de skulle
|
||||||
|
beskytte — tag hele <code>/data</code>, hvis du vil have det hele.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="secondary" onclick={backupNow} disabled={backingUp}>
|
||||||
|
{backingUp ? 'Kopierer…' : 'Tag en kopi nu'}
|
||||||
|
</button>
|
||||||
|
{#if lastBackup}<span class="saved">{lastBackup}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if backups.length}
|
||||||
|
<ul class="list">
|
||||||
|
{#each backups as backup (backup.id)}
|
||||||
|
<li>
|
||||||
|
<div class="what">
|
||||||
|
<span class="primary-line">
|
||||||
|
{when(backup.started_at)}
|
||||||
|
{#if backup.error}<span class="failed">mislykkedes</span>{/if}
|
||||||
|
</span>
|
||||||
|
<span class="secondary">
|
||||||
|
{#if backup.error}
|
||||||
|
{backup.error}
|
||||||
|
{:else}
|
||||||
|
{size(backup.size_bytes)}{#if !backup.present}{' · '}ryddet væk{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if backup.present && !backup.error}
|
||||||
|
<a class="link" href={api.backupURL(backup.id)} download>Hent</a>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if backupsLoaded}
|
||||||
|
<p class="empty">Der er ikke taget nogen kopi endnu.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
At lægge en kopi tilbage er ikke en knap her, og det er med vilje: en database
|
||||||
|
kan ikke skiftes ud under en server, der skriver i den, og en halvt gennemført
|
||||||
|
udskiftning ødelægger netop det, den skulle redde. Stop containeren, læg filen
|
||||||
|
i stedet for <code>verdande.db</code> i datamappen, slet <code>-wal</code> og
|
||||||
|
<code>-shm</code> ved siden af, og start den igen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>Eksport</h2>
|
<h2>Eksport</h2>
|
||||||
@@ -323,6 +446,39 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* The backup list, which is a list of rows with a right-hand action — the same
|
||||||
|
shape the settings pages use elsewhere, said once here because this is the
|
||||||
|
only panel on the page that has one. */
|
||||||
|
.what {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 560;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user