feat(backups): 主人和管理员可删除单个备份,归档由 reaper 下一轮删除、异地副本随下次同步删除,恢复可能在读时拒绝
This commit is contained in:
23 files changed
+969
-45
No files matched your search
@@ -3803,6 +3803,46 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/api/v1/backups/{id}:
|
||||
delete:
|
||||
tags: [backups]
|
||||
operationId: deleteBackup
|
||||
summary: Delete one world backup (admin, or the user who owned the world).
|
||||
description: >-
|
||||
The backup leaves every list, restore and the backup budget at once;
|
||||
the reaper's next daily run deletes the archive and the off-site copy's
|
||||
next sync removes the bucket's copy. A user gets 404 for a backup
|
||||
outside their scope, as their list never shows it. Refused while a
|
||||
restore on the backup's server may still read it.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: app
|
||||
security: [{ sessionCookie: [] }]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: The backup is deleted; its archive goes at the reaper's next run.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id, status]
|
||||
properties:
|
||||
id: { type: string }
|
||||
status: { type: string, const: expired }
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
description: No present backup with this id in the caller's scope (no_backup).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'409':
|
||||
description: A restore running on the backup's server may be reading it (restore_in_progress).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/servers/{name}/restore-backup:
|
||||
post:
|
||||
tags: [backups]
|
||||
|
||||
@@ -1082,6 +1082,32 @@ the error its container exited on under Recent operations on the server's
|
||||
backup page. [GO-TESTED: `TestBackupNow`, `TestCheckRoom`,
|
||||
`TestReaperConfigManualKeys`, `TestLatestJobsExplainsFailures`]
|
||||
|
||||
### Deleting one backup
|
||||
|
||||
The delete button on a backup row (`DELETE /api/v1/backups/{id}`) takes that backup
|
||||
out of every list, every restore and the `max_local_bytes` count at once: its row
|
||||
turns `expired`, with `expires_at` pulled back to the moment of the delete. An admin
|
||||
may delete any backup, a user only one of a world they owned (the scope that lists
|
||||
it); any other id is `404 no_backup`. While a restore on that server may be reading
|
||||
the archive (a restore Job still running, or a safety snapshot whose restore of this
|
||||
backup has yet to start) the delete is `409 restore_in_progress`. The audit action
|
||||
is `backup.delete`, with the backup id, former owner and size in its payload.
|
||||
|
||||
The archive stays on the backup volume until the reaper's next daily run, whose
|
||||
retention pass deletes every `expired` row's archive whatever its `expires_at`; the
|
||||
off-site copy goes at the sync after the delete. Until that run an admin can take
|
||||
the delete back, with the id from the audit entry; clearing `offsite_at` has the
|
||||
next sync copy it off site again in case its copy is already gone:
|
||||
|
||||
```sh
|
||||
sudo k3s kubectl -n felis exec deploy/felis-postgres -c postgres -- psql -U postgres felis -c \
|
||||
"UPDATE world_backups SET status = 'present', expires_at = now() + interval '30 days', offsite_at = NULL
|
||||
WHERE id = '<backup id>' AND status = 'expired'"
|
||||
```
|
||||
|
||||
[GO-TESTED: `TestDeleteBackup`, `TestExpiredBackupsDeleted`,
|
||||
`TestSyncExpiresOnlyPastRetention`; PG-TESTED: `TestOwnerDeletedBackup`]
|
||||
|
||||
### Every world at once: `felis backup-now`
|
||||
|
||||
A world lives only in its volume, and the off-site copy holds only its archives.
|
||||
|
||||
+4
-3
@@ -533,11 +533,12 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// identity (including email_verified) to drive the setup flow.
|
||||
{Method: "GET", Pattern: "/api/v1/me", SetupAllowed: true, h: a.handleMe},
|
||||
{Method: "GET", Pattern: "/api/v1/me/servers", SetupAllowed: true, h: a.handleMyServers},
|
||||
// World backups (spec §7, §466). Both are app-tier: GET /backups is scoped
|
||||
// World backups (spec §7, §466). All are app-tier: GET /backups is scoped
|
||||
// inside the handler (admin sees all; a user sees only worlds they formerly
|
||||
// owned), and restore is gated by owner-or-admin PLUS a former-owner match, so
|
||||
// neither sits behind adminOnly.
|
||||
// owned), DELETE takes the same scope, and restore is gated by owner-or-admin
|
||||
// PLUS a former-owner match, so none sits behind adminOnly.
|
||||
{Method: "GET", Pattern: "/api/v1/backups", h: a.handleListBackups},
|
||||
{Method: "DELETE", Pattern: "/api/v1/backups/{id}", h: a.handleDeleteBackup},
|
||||
{Method: "GET", Pattern: "/api/v1/servers/{name}/jobs", h: a.handleServerJobs},
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/restore-backup", h: a.handleRestoreBackup},
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/backup", h: a.handleBackupNow},
|
||||
|
||||
@@ -1058,6 +1058,20 @@ func (f *fakeRepo) BackupByID(_ context.Context, id string) (*BackupRecord, erro
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeRepo) ExpireBackup(_ context.Context, id string, at time.Time) error {
|
||||
for i := range f.backups {
|
||||
b := &f.backups[i]
|
||||
if b.view.Status == "present" && b.view.ID == id {
|
||||
b.view.Status = "expired"
|
||||
if at.Before(b.view.ExpiresAt) {
|
||||
b.view.ExpiresAt = at
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// ---- staff / session auth fakes (spec §B, passwordless) ----
|
||||
// Each method mirrors the PGRepo contract: a returned StaffUser is copied so a
|
||||
// test cannot mutate the stored row by reference, SessionUser re-reads the
|
||||
|
||||
@@ -74,6 +74,75 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"backups": backups, "total": total})
|
||||
}
|
||||
|
||||
// handleDeleteBackup deletes one world backup (DELETE /api/v1/backups/{id}). An
|
||||
// admin may delete any; a user only one of a world they owned, the scope that
|
||||
// lists and restores it, and any other id reads as unknown (404), as it does in
|
||||
// their list. The row turns expired at once, so no list, restore or backup
|
||||
// budget counts it again; the reaper's next retention pass deletes the archive
|
||||
// and the off-site copy's next sync the bucket's copy. A reaped world's backup
|
||||
// is that world's only copy, which the panel says before it asks.
|
||||
//
|
||||
// A restore running on the backup's server may be reading the archive, so the
|
||||
// delete waits for it: a restore Job still running, or a safety snapshot whose
|
||||
// restore of this backup has yet to start, is 409 restore_in_progress. Without
|
||||
// a JobStatus reader there is nothing to ask and the delete goes ahead: the
|
||||
// reaper removes the archive at its next daily run, long after any restore
|
||||
// admitted before the delete has read it.
|
||||
func (a *API) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
p := principalFromContext(r.Context())
|
||||
backup, err := a.Repo.BackupByID(r.Context(), r.PathValue("id"))
|
||||
if err == nil && !p.IsAdmin() && (p.UserID == "" || backup.FormerOwner != p.UserID) {
|
||||
err = ErrNotFound
|
||||
}
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, r, newError(http.StatusNotFound, "no_backup", "no matching backup exists"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if a.JobStatus != nil {
|
||||
jobs, err := a.JobStatus.LatestJobs(r.Context(), backup.ServerName)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if restoreMayRead(jobs, backup.ID) {
|
||||
writeError(w, r, newError(http.StatusConflict, "restore_in_progress",
|
||||
"a restore is running on this backup's server and may be reading it; delete it once the restore finishes"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.Repo.ExpireBackup(r.Context(), backup.ID, a.now()); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, r, newError(http.StatusNotFound, "no_backup", "no matching backup exists"))
|
||||
return
|
||||
}
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
e := AuditEntry{Actor: auditActor(p), ActorUserID: p.UserID, Action: "backup.delete", ServerName: backup.ServerName}
|
||||
e.Payload = auditPayload(map[string]any{"backup_id": backup.ID, "former_owner": backup.FormerOwner, "size_bytes": backup.SizeBytes})
|
||||
a.auditEntry(r, e)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": backup.ID, "status": "expired"})
|
||||
}
|
||||
|
||||
// restoreMayRead reports whether one of a server's Jobs may still read backup
|
||||
// id's archive: any restore Job not yet finished (which archive it extracts is
|
||||
// not on the Job), or a safety snapshot whose restore of id has not started.
|
||||
func restoreMayRead(jobs []AsyncJob, id string) bool {
|
||||
for _, j := range jobs {
|
||||
if j.Kind == "restore" && j.State == "running" {
|
||||
return true
|
||||
}
|
||||
if j.ThenRestore == maintenance.ThenRestorePending && j.RestoreBackupID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleRestoreBackup starts restoring a server's world from a backup (spec §7
|
||||
// POST /servers/{name}/restore-backup; spec §466). It accepts an optional JSON
|
||||
// body with a backup_id; when absent it restores the latest backup for the server
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -542,3 +543,226 @@ func TestRestoreBackup(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteBackup covers DELETE /api/v1/backups/{id}: the scope that lists a
|
||||
// backup deletes it, the row turns expired (out of lists, restores and the
|
||||
// budget, expires_at pulled to now so the reaper and the off-site sync take it
|
||||
// next), an out-of-scope id reads as unknown, and a restore that may be reading
|
||||
// the archive holds the delete off.
|
||||
func TestDeleteBackup(t *testing.T) {
|
||||
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
|
||||
admin := &Principal{UserID: "admin1", Email: "[email protected]", Role: "admin", ViaAdminAccess: true}
|
||||
expires := time.Unix(1_706_000_000, 0)
|
||||
|
||||
mk := func(p *Principal) (*API, *fakeRepo) {
|
||||
repo := newFakeRepo()
|
||||
repo.backups = []fakeBackup{
|
||||
{view: BackupView{ID: "bk1", ServerName: "survival", FormerOwner: "owner1",
|
||||
Status: "present", Reason: "manual", SizeBytes: 1024,
|
||||
CreatedAt: time.Unix(1_699_000_000, 0), ExpiresAt: expires}, ref: "ref-bk1"},
|
||||
{view: BackupView{ID: "bk2", ServerName: "creative", FormerOwner: "owner2",
|
||||
Status: "present", Reason: "inactive_15d", SizeBytes: 2048,
|
||||
CreatedAt: time.Unix(1_699_500_000, 0), ExpiresAt: expires}, ref: "ref-bk2"},
|
||||
}
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: p}
|
||||
return api, repo
|
||||
}
|
||||
del := func(api *API, id string) *httptest.ResponseRecorder {
|
||||
return do(api.ExternalHandler(), "DELETE", "/api/v1/backups/"+id, "", nil)
|
||||
}
|
||||
status := func(repo *fakeRepo, id string) string {
|
||||
for _, b := range repo.backups {
|
||||
if b.view.ID == id {
|
||||
return b.view.Status
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
t.Run("former owner deletes -> 200, expired, audited", func(t *testing.T) {
|
||||
api, repo := mk(owner)
|
||||
w := del(api, "bk1")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if len(resp) != 2 || resp["id"] != "bk1" || resp["status"] != "expired" {
|
||||
t.Fatalf("body = %v, want {id: bk1, status: expired}", resp)
|
||||
}
|
||||
b := repo.backups[0].view
|
||||
if b.Status != "expired" || !b.ExpiresAt.Equal(api.now()) {
|
||||
t.Fatalf("row = %s expiring %v, want expired expiring %v", b.Status, b.ExpiresAt, api.now())
|
||||
}
|
||||
if status(repo, "bk2") != "present" {
|
||||
t.Fatal("deleting bk1 touched bk2")
|
||||
}
|
||||
if n, _ := repo.BackupStoreBytes(t.Context()); n != 2048 {
|
||||
t.Fatalf("backup budget counts %d bytes, want 2048", n)
|
||||
}
|
||||
lw := do(api.ExternalHandler(), "GET", "/api/v1/backups", "", nil)
|
||||
if strings.Contains(lw.Body.String(), `"bk1"`) {
|
||||
t.Fatalf("deleted backup still listed: %s", lw.Body.String())
|
||||
}
|
||||
if len(repo.audits) != 1 {
|
||||
t.Fatalf("audits = %+v, want one", repo.audits)
|
||||
}
|
||||
a := repo.audits[0]
|
||||
if a.Action != "backup.delete" || a.Actor != "[email protected]" || a.ActorUserID != "owner1" || a.ServerName != "survival" {
|
||||
t.Fatalf("audit = %+v", a)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(a.Payload, &payload); err != nil {
|
||||
t.Fatalf("payload not JSON: %v (%s)", err, a.Payload)
|
||||
}
|
||||
if payload["backup_id"] != "bk1" || payload["former_owner"] != "owner1" || payload["size_bytes"] != float64(1024) {
|
||||
t.Fatalf("payload = %v", payload)
|
||||
}
|
||||
if w := del(api, "bk1"); w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" {
|
||||
t.Fatalf("second delete: code = %d body %s, want 404 no_backup", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expires_at already past stays put", func(t *testing.T) {
|
||||
api, repo := mk(owner)
|
||||
past := api.now().Add(-time.Hour)
|
||||
repo.backups[0].view.ExpiresAt = past
|
||||
if w := del(api, "bk1"); w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := repo.backups[0].view.ExpiresAt; !got.Equal(past) {
|
||||
t.Fatalf("expires_at = %v, want %v", got, past)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin deletes another's backup -> 200", func(t *testing.T) {
|
||||
api, repo := mk(admin)
|
||||
if w := del(api, "bk2"); w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if status(repo, "bk2") != "expired" {
|
||||
t.Fatalf("bk2 = %s, want expired", status(repo, "bk2"))
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
p *Principal
|
||||
id string
|
||||
prep func(*fakeRepo)
|
||||
}{
|
||||
{"another user's backup", owner, "bk2", nil},
|
||||
{"unknown id", admin, "nope", nil},
|
||||
{"already deleted", admin, "bk1", func(r *fakeRepo) { r.backups[0].view.Status = "deleted" }},
|
||||
{"already expired", owner, "bk1", func(r *fakeRepo) { r.backups[0].view.Status = "expired" }},
|
||||
{"no user id against no former owner", &Principal{Role: "user"}, "bk1",
|
||||
func(r *fakeRepo) { r.backups[0].view.FormerOwner = "" }},
|
||||
} {
|
||||
t.Run(tc.name+" -> 404 no_backup", func(t *testing.T) {
|
||||
api, repo := mk(tc.p)
|
||||
if tc.prep != nil {
|
||||
tc.prep(repo)
|
||||
}
|
||||
before := []string{status(repo, "bk1"), status(repo, "bk2")}
|
||||
w := del(api, tc.id)
|
||||
if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" {
|
||||
t.Fatalf("code = %d body %s, want 404 no_backup", w.Code, w.Body.String())
|
||||
}
|
||||
if after := []string{status(repo, "bk1"), status(repo, "bk2")}; after[0] != before[0] || after[1] != before[1] {
|
||||
t.Fatalf("statuses %v -> %v, want unchanged", before, after)
|
||||
}
|
||||
if len(repo.audits) != 0 {
|
||||
t.Fatalf("refused delete audited: %+v", repo.audits)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
jobs []AsyncJob
|
||||
want int
|
||||
}{
|
||||
{"restore running", []AsyncJob{{Kind: "restore", State: "running"}}, http.StatusConflict},
|
||||
{"snapshot before restoring this backup", []AsyncJob{{Kind: "backup", State: "running",
|
||||
ThenRestore: "pending", RestoreBackupID: "bk1"}}, http.StatusConflict},
|
||||
{"snapshot done, restore of this backup still to start", []AsyncJob{{Kind: "backup", State: "succeeded",
|
||||
ThenRestore: "pending", RestoreBackupID: "bk1"}}, http.StatusConflict},
|
||||
{"snapshot before restoring another backup", []AsyncJob{{Kind: "backup", State: "running",
|
||||
ThenRestore: "pending", RestoreBackupID: "bk9"}}, http.StatusOK},
|
||||
{"restore of this backup started and finished", []AsyncJob{
|
||||
{Kind: "restore", State: "succeeded"},
|
||||
{Kind: "backup", State: "succeeded", ThenRestore: "started", RestoreBackupID: "bk1"}}, http.StatusOK},
|
||||
{"chain abandoned", []AsyncJob{{Kind: "backup", State: "failed",
|
||||
ThenRestore: "abandoned", RestoreBackupID: "bk1"}}, http.StatusOK},
|
||||
{"restore failed", []AsyncJob{{Kind: "restore", State: "failed"}}, http.StatusOK},
|
||||
{"plain backup running", []AsyncJob{{Kind: "backup", State: "running"}}, http.StatusOK},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api, repo := mk(owner)
|
||||
js := &fakeJobStatus{jobs: tc.jobs}
|
||||
api.JobStatus = js
|
||||
w := del(api, "bk1")
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("code = %d, want %d (%s)", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
if js.got != "survival" {
|
||||
t.Fatalf("jobs read for %q, want survival", js.got)
|
||||
}
|
||||
want := "expired"
|
||||
if tc.want == http.StatusConflict {
|
||||
want = "present"
|
||||
if decodeErr(t, w) != "restore_in_progress" {
|
||||
t.Fatalf("error code %q, want restore_in_progress", decodeErr(t, w))
|
||||
}
|
||||
}
|
||||
if status(repo, "bk1") != want {
|
||||
t.Fatalf("bk1 = %s, want %s", status(repo, "bk1"), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("deleted by someone else after the lookup -> 404, not audited", func(t *testing.T) {
|
||||
api, repo := mk(owner)
|
||||
api.Repo = expireFails{repo, ErrNotFound}
|
||||
if w := del(api, "bk1"); w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" {
|
||||
t.Fatalf("code = %d body %s, want 404 no_backup", w.Code, w.Body.String())
|
||||
}
|
||||
if len(repo.audits) != 0 {
|
||||
t.Fatalf("lost race audited: %+v", repo.audits)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expire fails -> 500, not audited", func(t *testing.T) {
|
||||
api, repo := mk(owner)
|
||||
api.Repo = expireFails{repo, errors.New("connection reset")}
|
||||
if w := del(api, "bk1"); w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("code = %d, want 500 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if len(repo.audits) != 0 {
|
||||
t.Fatalf("failed delete audited: %+v", repo.audits)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("job status error -> 500, kept", func(t *testing.T) {
|
||||
api, repo := mk(owner)
|
||||
api.JobStatus = &fakeJobStatus{err: errors.New("apiserver down")}
|
||||
if w := del(api, "bk1"); w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("code = %d, want 500 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if status(repo, "bk1") != "present" || len(repo.audits) != 0 {
|
||||
t.Fatalf("bk1 = %s audits %d, want present and none", status(repo, "bk1"), len(repo.audits))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// expireFails is a repo whose ExpireBackup answers err after the handler's
|
||||
// lookup found the backup: ErrNotFound is a concurrent delete winning.
|
||||
type expireFails struct {
|
||||
*fakeRepo
|
||||
err error
|
||||
}
|
||||
|
||||
func (f expireFails) ExpireBackup(context.Context, string, time.Time) error { return f.err }
|
||||
@@ -968,6 +968,25 @@ func (p *PGRepo) BackupByID(ctx context.Context, id string) (*BackupRecord, erro
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// ExpireBackup marks one present backup expired. The reaper's ListExpiredBackups
|
||||
// picks expired rows up whatever their expires_at; pulling expires_at in to at is
|
||||
// what lets the off-site copy drop the bucket's copy (KeptRefs, ExpiredRefs)
|
||||
// instead of keeping it to the original date.
|
||||
func (p *PGRepo) ExpireBackup(ctx context.Context, id string, at time.Time) error {
|
||||
res, err := p.db.ExecContext(ctx,
|
||||
`UPDATE world_backups SET status = 'expired', expires_at = LEAST(expires_at, $2)
|
||||
WHERE id = $1 AND status = 'present'`, id, at)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastBackupRequest reads the newest backup.create audit row for the server
|
||||
// since the given time; the created_at index bounds the scan to that window.
|
||||
func (p *PGRepo) LastBackupRequest(ctx context.Context, serverName string, since time.Time) (time.Time, error) {
|
||||
|
||||
@@ -413,6 +413,11 @@ type Repo interface {
|
||||
// none matches. Like LatestBackup the returned BackupRecord carries the
|
||||
// server-side backup_ref the restore path needs; the client never sees it.
|
||||
BackupByID(ctx context.Context, id string) (*BackupRecord, error)
|
||||
// ExpireBackup takes one present backup out of every list, restore and budget
|
||||
// at once (status expired, expires_at no later than at), leaving its archive
|
||||
// for the reaper's next retention pass and its off-site copy for the next
|
||||
// sync. ErrNotFound when no present backup has that id.
|
||||
ExpireBackup(ctx context.Context, id string, at time.Time) error
|
||||
// LastBackupRequest returns when an on-demand backup of the server was last
|
||||
// accepted (its newest backup.create audit row) at or after since, or the zero
|
||||
// time when there was none. The since bound keeps the lookup inside the
|
||||
|
||||
@@ -233,7 +233,7 @@ func (c *fakeCatalog) MarkOffsite(_ context.Context, id string, at time.Time) er
|
||||
func (c *fakeCatalog) ExpiredRefs(_ context.Context, now time.Time) ([]string, error) {
|
||||
var out []string
|
||||
for _, r := range c.rows {
|
||||
if r.status == "deleted" && r.expires.Before(now) && !r.offsite.IsZero() {
|
||||
if (r.status == "deleted" || r.status == "expired") && r.expires.Before(now) && !r.offsite.IsZero() {
|
||||
out = append(out, r.Ref)
|
||||
}
|
||||
}
|
||||
@@ -374,23 +374,26 @@ func TestSyncFailureLeavesRowPending(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSyncExpiresOnlyPastRetention: a remote archive goes once its row has
|
||||
// expired; one evicted early from the local disk stays until then, and an
|
||||
// expired, or once its owner deleted it (status expired, expires_at pulled to
|
||||
// the delete); one evicted early from the local disk stays until then, and an
|
||||
// object with no row at all is left alone.
|
||||
func TestSyncExpiresOnlyPastRetention(t *testing.T) {
|
||||
cat := &fakeCatalog{rows: []*row{
|
||||
{WorldBackup: WorldBackup{ID: "old", Ref: "/a/old.tar.gz"}, status: "deleted", expires: now.Add(-time.Hour), offsite: now.Add(-100 * 24 * time.Hour)},
|
||||
{WorldBackup: WorldBackup{ID: "evicted", Ref: "/a/evicted.tar.gz"}, status: "deleted", expires: now.Add(30 * 24 * time.Hour), offsite: now.Add(-24 * time.Hour)},
|
||||
{WorldBackup: WorldBackup{ID: "dropped", Ref: "/a/dropped.tar.gz"}, status: "expired", expires: now.Add(-time.Minute), offsite: now.Add(-24 * time.Hour)},
|
||||
}}
|
||||
s, b := newSyncer(t, cat)
|
||||
for _, k := range []string{"worlds/old.tar.gz.fenc", "worlds/evicted.tar.gz.fenc", "worlds/unknown.tar.gz.fenc"} {
|
||||
for _, k := range []string{"worlds/old.tar.gz.fenc", "worlds/evicted.tar.gz.fenc", "worlds/dropped.tar.gz.fenc", "worlds/unknown.tar.gz.fenc"} {
|
||||
b.objs[k] = []byte("x")
|
||||
}
|
||||
res, err := s.Run(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.WorldsExpired != 1 || len(b.removed) != 1 || b.removed[0] != "worlds/old.tar.gz.fenc" {
|
||||
t.Fatalf("removed %v (expired %d), want only the expired archive", b.removed, res.WorldsExpired)
|
||||
sort.Strings(b.removed)
|
||||
if res.WorldsExpired != 2 || len(b.removed) != 2 || b.removed[0] != "worlds/dropped.tar.gz.fenc" || b.removed[1] != "worlds/old.tar.gz.fenc" {
|
||||
t.Fatalf("removed %v (expired %d), want the expired and the owner-deleted archive", b.removed, res.WorldsExpired)
|
||||
}
|
||||
if res.RemoteWorlds != 2 {
|
||||
t.Fatalf("remote worlds = %d, want 2 left", res.RemoteWorlds)
|
||||
|
||||
@@ -44,7 +44,7 @@ func (c PGCatalog) MarkOffsite(ctx context.Context, id string, at time.Time) err
|
||||
|
||||
func (c PGCatalog) ExpiredRefs(ctx context.Context, now time.Time) ([]string, error) {
|
||||
return c.refs(ctx, `SELECT backup_ref FROM world_backups
|
||||
WHERE status = 'deleted' AND expires_at < $1 AND offsite_at IS NOT NULL`, now)
|
||||
WHERE status IN ('deleted', 'expired') AND expires_at < $1 AND offsite_at IS NOT NULL`, now)
|
||||
}
|
||||
|
||||
func (c PGCatalog) refs(ctx context.Context, q string, args ...any) ([]string, error) {
|
||||
|
||||
@@ -78,7 +78,8 @@ type Catalog interface {
|
||||
// MarkOffsite records that the archive of row id is in the bucket.
|
||||
MarkOffsite(ctx context.Context, id string, at time.Time) error
|
||||
// ExpiredRefs lists the backup_ref of every row past its retention
|
||||
// (deleted and expires_at < now) whose archive was copied off-site.
|
||||
// (deleted, or expired: deleted through the API; and expires_at < now)
|
||||
// whose archive was copied off-site.
|
||||
ExpiredRefs(ctx context.Context, now time.Time) ([]string, error)
|
||||
// PresentWorlds lists every present archive, for a restore of the volume.
|
||||
PresentWorlds(ctx context.Context) ([]WorldBackup, error)
|
||||
|
||||
@@ -4,10 +4,12 @@ package pgint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
"felis.lolicon.best/internal/offsite"
|
||||
"felis.lolicon.best/internal/reaper"
|
||||
)
|
||||
@@ -58,3 +60,112 @@ func TestOffsiteCatalogNewestCopyAndKeptRefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerDeletedBackup: ExpireBackup turns one present backup expired with
|
||||
// expires_at pulled to the delete (never pushed later), after which no read
|
||||
// finds it, the backup budget drops its bytes, the reaper's retention pass
|
||||
// lists it whatever its expires_at, and the off-site sweep drops its copy.
|
||||
func TestOwnerDeletedBackup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sfx := suffix(t)
|
||||
server := "owner-del-" + sfx
|
||||
t.Cleanup(func() { db.ExecContext(ctx, `DELETE FROM world_backups WHERE server_name = $1`, server) })
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
insert := func(id string, size int64, expires time.Time) (string, string) {
|
||||
t.Helper()
|
||||
id += "-" + sfx
|
||||
ref := "/archives/" + id + ".tar.gz"
|
||||
mustExec(t, `INSERT INTO world_backups (id, server_name, backup_ref, size_bytes, reason, status, created_at, expires_at, offsite_at)
|
||||
VALUES ($1, $2, $3, $4, 'manual', 'present', $5, $6, $7)`,
|
||||
id, server, ref, size, now.Add(-reaper.Day), expires, now.Add(-time.Hour))
|
||||
return id, ref
|
||||
}
|
||||
later := now.Add(90 * reaper.Day)
|
||||
del, delRef := insert("del", 700, later)
|
||||
keep, keepRef := insert("keep", 11, later)
|
||||
past, _ := insert("past", 13, now.Add(-time.Minute))
|
||||
row := func(id string) (string, time.Time) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var expires time.Time
|
||||
if err := db.QueryRowContext(ctx, `SELECT status, expires_at FROM world_backups WHERE id = $1`, id).Scan(&status, &expires); err != nil {
|
||||
t.Fatalf("read %s: %v", id, err)
|
||||
}
|
||||
return status, expires
|
||||
}
|
||||
|
||||
before, err := repo.BackupStoreBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("BackupStoreBytes: %v", err)
|
||||
}
|
||||
if err := repo.ExpireBackup(ctx, del, now); err != nil {
|
||||
t.Fatalf("ExpireBackup: %v", err)
|
||||
}
|
||||
if s, e := row(del); s != "expired" || !e.Equal(now) {
|
||||
t.Fatalf("deleted row = %s expiring %v, want expired expiring %v", s, e, now)
|
||||
}
|
||||
if s, e := row(keep); s != "present" || !e.Equal(later) {
|
||||
t.Fatalf("other row = %s expiring %v, want untouched", s, e)
|
||||
}
|
||||
if after, err := repo.BackupStoreBytes(ctx); err != nil || after != before-700 {
|
||||
t.Fatalf("BackupStoreBytes = %d, %v; want %d", after, err, before-700)
|
||||
}
|
||||
if _, err := repo.BackupByID(ctx, del); !errors.Is(err, api.ErrNotFound) {
|
||||
t.Fatalf("BackupByID(deleted) = %v, want ErrNotFound", err)
|
||||
}
|
||||
for _, id := range []string{del, "missing-" + sfx} {
|
||||
if err := repo.ExpireBackup(ctx, id, now); !errors.Is(err, api.ErrNotFound) {
|
||||
t.Fatalf("ExpireBackup(%s) = %v, want ErrNotFound", id, err)
|
||||
}
|
||||
}
|
||||
if err := repo.ExpireBackup(ctx, past, now); err != nil {
|
||||
t.Fatalf("ExpireBackup(past): %v", err)
|
||||
}
|
||||
if s, e := row(past); s != "expired" || !e.Equal(now.Add(-time.Minute)) {
|
||||
t.Fatalf("past row = %s expiring %v, want expired keeping %v", s, e, now.Add(-time.Minute))
|
||||
}
|
||||
|
||||
// The reaper takes it even at a time before its expires_at.
|
||||
exp, err := reaper.NewPGStore(db).ListExpiredBackups(ctx, now.Add(-reaper.Day))
|
||||
if err != nil {
|
||||
t.Fatalf("ListExpiredBackups: %v", err)
|
||||
}
|
||||
var ids []string
|
||||
for _, b := range exp {
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
if !slices.Contains(ids, del) || slices.Contains(ids, keep) {
|
||||
t.Fatalf("ListExpiredBackups = %v, want %s and not %s", ids, del, keep)
|
||||
}
|
||||
|
||||
cat := offsite.PGCatalog{DB: db}
|
||||
gone, err := cat.ExpiredRefs(ctx, now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("ExpiredRefs: %v", err)
|
||||
}
|
||||
if !slices.Contains(gone, delRef) || slices.Contains(gone, keepRef) {
|
||||
t.Fatalf("ExpiredRefs = %v, want %s and not %s", gone, delRef, keepRef)
|
||||
}
|
||||
kept, err := cat.KeptRefs(ctx, now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("KeptRefs: %v", err)
|
||||
}
|
||||
if slices.Contains(kept, delRef) || !slices.Contains(kept, keepRef) {
|
||||
t.Fatalf("KeptRefs = %v, want %s and not %s", kept, keepRef, delRef)
|
||||
}
|
||||
|
||||
// The undo troubleshooting.md §10 gives, before the reaper has run: the backup
|
||||
// is restorable again and the next sync copies it off site anew.
|
||||
mustExec(t, `UPDATE world_backups SET status = 'present', expires_at = now() + interval '30 days', offsite_at = NULL
|
||||
WHERE id = '`+del+`' AND status = 'expired'`)
|
||||
if b, err := repo.BackupByID(ctx, del); err != nil || b.BackupRef != delRef {
|
||||
t.Fatalf("BackupByID after the undo = (%+v, %v), want %s", b, err, delRef)
|
||||
}
|
||||
pending, err := cat.PendingWorlds(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingWorlds: %v", err)
|
||||
}
|
||||
if !slices.ContainsFunc(pending, func(w offsite.WorldBackup) bool { return w.ID == del }) {
|
||||
t.Fatalf("PendingWorlds after the undo lacks %s", del)
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func (s *PGStore) ExcessBackups(ctx context.Context, server, owner, reason strin
|
||||
|
||||
func (s *PGStore) ListExpiredBackups(ctx context.Context, now time.Time) ([]StoredBackup, error) {
|
||||
const q = `SELECT id, server_name, backup_ref, size_bytes, reason, COALESCE(sha256, '') FROM world_backups
|
||||
WHERE status = 'present' AND expires_at < $1 ORDER BY expires_at ASC`
|
||||
WHERE (status = 'present' AND expires_at < $1) OR status = 'expired' ORDER BY expires_at ASC`
|
||||
return s.queryBackups(ctx, q, now)
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,9 @@ type Store interface {
|
||||
// copy of a deleted world and is never listed.
|
||||
EvictableBackups(ctx context.Context) ([]StoredBackup, error)
|
||||
|
||||
// ListExpiredBackups lists status=present backups whose expires_at < now.
|
||||
// ListExpiredBackups lists status=present backups whose expires_at < now,
|
||||
// and every status=expired one: a backup its owner deleted (the API marks it
|
||||
// so and leaves the archive to this pass).
|
||||
ListExpiredBackups(ctx context.Context, now time.Time) ([]StoredBackup, error)
|
||||
|
||||
// MarkBackupDeleted flips a backup to status=deleted, deleted_at=at.
|
||||
|
||||
@@ -343,7 +343,7 @@ func isArchive(reason string) bool { return reason == ReasonInactive || reason =
|
||||
func (s *fakeStore) ListExpiredBackups(_ context.Context, now time.Time) ([]StoredBackup, error) {
|
||||
var out []StoredBackup
|
||||
for _, b := range s.backups {
|
||||
if b.status == "present" && b.expires.Before(now) {
|
||||
if b.status == "present" && b.expires.Before(now) || b.status == "expired" {
|
||||
out = append(out, StoredBackup{ID: b.id, ServerName: b.server, BackupRef: b.ref, SizeBytes: b.size})
|
||||
}
|
||||
}
|
||||
@@ -932,27 +932,29 @@ func TestCapacityEvictionOrderSparesSoleCopies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Retention pass: backups past expires_at are deleted from the backend and
|
||||
// marked deleted; unexpired backups are untouched.
|
||||
// Retention pass: backups past expires_at, and any its owner deleted (status
|
||||
// expired) whatever its expires_at, are deleted from the backend and marked
|
||||
// deleted; unexpired backups are untouched.
|
||||
func TestExpiredBackupsDeleted(t *testing.T) {
|
||||
r, st, _, ar := newReaper(DefaultConfig())
|
||||
st.backups = []*fakeBackup{
|
||||
{id: "gone", server: "s1", ref: "ref-gone", size: 5, status: "present", createdAt: idleBy(120 * Day), expires: idleBy(1 * Day)},
|
||||
{id: "keep", server: "s2", ref: "ref-keep", size: 5, status: "present", createdAt: idleBy(10 * Day), expires: testNow.Add(80 * Day)},
|
||||
{id: "dropped", server: "s3", ref: "ref-dropped", size: 5, status: "expired", createdAt: idleBy(10 * Day), expires: testNow.Add(80 * Day)},
|
||||
}
|
||||
|
||||
sum := mustRun(t, r)
|
||||
if sum.BackupsExpired != 1 {
|
||||
t.Fatalf("BackupsExpired = %d, want 1", sum.BackupsExpired)
|
||||
if sum.BackupsExpired != 2 {
|
||||
t.Fatalf("BackupsExpired = %d, want 2", sum.BackupsExpired)
|
||||
}
|
||||
if len(ar.deletes) != 1 || ar.deletes[0] != "ref-gone" {
|
||||
t.Fatalf("deleted archives = %v, want [ref-gone]", ar.deletes)
|
||||
if len(ar.deletes) != 2 || ar.deletes[0] != "ref-gone" || ar.deletes[1] != "ref-dropped" {
|
||||
t.Fatalf("deleted archives = %v, want [ref-gone ref-dropped]", ar.deletes)
|
||||
}
|
||||
byID := map[string]string{}
|
||||
for _, b := range st.backups {
|
||||
byID[b.id] = b.status
|
||||
}
|
||||
if byID["gone"] != "deleted" || byID["keep"] != "present" {
|
||||
if byID["gone"] != "deleted" || byID["keep"] != "present" || byID["dropped"] != "deleted" {
|
||||
t.Fatalf("expiry hit wrong rows: %v", byID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,6 +1277,26 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
sendJSON(ctx.res, 200, { ok: true, signed_out: signedOut });
|
||||
return true;
|
||||
}
|
||||
if (ctx.method === "DELETE" && ctx.parts[2] === "backups" && ctx.parts[3] && ctx.parts.length === 4) {
|
||||
// Mirrors handleDeleteBackup: the scope that lists a backup deletes it and
|
||||
// any other id is 404 no_backup; a restore on its server that may still be
|
||||
// reading it is 409 restore_in_progress. The row turns expired at once.
|
||||
const b = ctx.state.backups.find((x) => x.id === ctx.parts[3] && x.status === "present");
|
||||
if (!b || !(isAdmin(ctx.account.role) || b.former_owner === ctx.account.id)) {
|
||||
sendError(ctx.res, 404, "no_backup", "no matching backup exists");
|
||||
return true;
|
||||
}
|
||||
const reading = (ctx.state.jobs[b.server_name] ?? []).some(
|
||||
(j) => (j.kind === "restore" && j.state === "running") || (j.then_restore === "pending" && j.restore_backup_id === b.id),
|
||||
);
|
||||
if (reading) {
|
||||
sendError(ctx.res, 409, "restore_in_progress", "a restore is running on this backup's server and may be reading it; delete it once the restore finishes");
|
||||
return true;
|
||||
}
|
||||
b.status = "expired";
|
||||
sendJSON(ctx.res, 200, { id: b.id, status: "expired" });
|
||||
return true;
|
||||
}
|
||||
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
|
||||
// The real API asks for the re-auth before it looks the passkey up.
|
||||
if (refusedForReauth(ctx)) return true;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"subtitle": "A world that was played is backed up automatically once a day after its server stops, and again before it is archived after long inactivity. You can use these backups to restore the world — the server must be stopped first.",
|
||||
"back_to_console": "Back to console",
|
||||
"not_yours_title": "No permission to access backups",
|
||||
"not_yours_body": "Only the owner or an admin can view and restore this server's backups.",
|
||||
"not_yours_body": "Only the owner or an admin can view, restore and delete this server's backups.",
|
||||
"latest_title": "Latest backup",
|
||||
"history_note": "Backups can be restored until they expire, then they are cleaned up automatically. Each server keeps only its most recent manual backups and its most recent scheduled backups (older ones of the same kind are removed once a new one finishes), and its last 3 pre-restore snapshots.",
|
||||
"history_note": "Backups can be restored until they expire, then they are cleaned up automatically. Each server keeps only its most recent manual backups and its most recent scheduled backups (older ones of the same kind are removed once a new one finishes), and its last 3 pre-restore snapshots. Backups you no longer need can be deleted by hand.",
|
||||
"reason_inactive": "Idle archive",
|
||||
"reason_released": "Archived when given up or deleted",
|
||||
"reason_manual": "Manual backup",
|
||||
@@ -57,7 +57,7 @@
|
||||
"restoring": "Restoring…",
|
||||
"stop_timeout": "The server did not stop in time. Close this window and try again shortly.",
|
||||
"corrupt_badge": "Corrupt",
|
||||
"corrupt_hint": "A read-back found this archive no longer matches what was written, so it can't be restored intact. It stays listed until it expires so the loss is visible.",
|
||||
"corrupt_hint": "A read-back found this archive no longer matches what was written, so it can't be restored intact. It stays listed until it expires so the loss is visible, unless you delete it.",
|
||||
"corrupt_short": "Corrupt",
|
||||
"verified_at": "Verified {{when}}",
|
||||
"skipped_entries_one": "{{count}} entry not archived",
|
||||
@@ -67,5 +67,15 @@
|
||||
"col_size": "Size",
|
||||
"col_expires": "Expires",
|
||||
"col_owner": "Former Owner",
|
||||
"col_actions": "Actions"
|
||||
"col_actions": "Actions",
|
||||
"delete_btn": "Delete backup",
|
||||
"delete_title": "Delete this backup?",
|
||||
"delete_confirm": "The backup from {{relative}} ({{absolute}}, {{size}}) leaves this list at once and can no longer be restored. This cannot be undone.",
|
||||
"delete_cleanup_note": "Its archive is removed at the next daily cleanup, and its off-site copy at the next sync.",
|
||||
"delete_archive_warning": "This backup is the archive kept when the world was reclaimed or released. Unless the world on the server has been restored from it, deleting it loses that world for good.",
|
||||
"delete_only_warning": "This is the only backup this server has.",
|
||||
"delete_confirm_yes": "Delete backup",
|
||||
"delete_restore_busy": "A restore may be reading this backup. Delete it once the restore finishes.",
|
||||
"deleted": "Backup deleted.",
|
||||
"delete_gone": "This backup was already gone; the list has been refreshed."
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
"subtitle": "玩过的世界会在服务器停止后每天自动备份一次,长期闲置被自动回收前也会自动备份。可以使用备份随时恢复世界,恢复前需要先停止服务器。",
|
||||
"back_to_console": "返回控制台",
|
||||
"not_yours_title": "无权访问备份",
|
||||
"not_yours_body": "只有所有者或管理员才能查看并恢复该服务器的备份。",
|
||||
"not_yours_body": "只有所有者或管理员才能查看、恢复和删除该服务器的备份。",
|
||||
"latest_title": "最新备份",
|
||||
"history_note": "历史备份在过期前均可用于恢复,到期后自动清理。手动备份和定时备份每台服务器各只保留最近几份,新备份完成后会自动删除更早的同类备份;恢复前快照保留最近 3 份。",
|
||||
"history_note": "历史备份在过期前均可用于恢复,到期后自动清理。手动备份和定时备份每台服务器各只保留最近几份,新备份完成后会自动删除更早的同类备份;恢复前快照保留最近 3 份。不再需要的备份可以手动删除。",
|
||||
"reason_inactive": "闲置自动回收",
|
||||
"reason_released": "放弃或删除时归档",
|
||||
"reason_manual": "手动备份",
|
||||
@@ -57,7 +57,7 @@
|
||||
"restoring": "正在恢复备份……",
|
||||
"stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。",
|
||||
"corrupt_badge": "已损坏",
|
||||
"corrupt_hint": "回读校验发现这份归档与写入时不一致,已无法完整恢复。它会保留到过期,方便排查。",
|
||||
"corrupt_hint": "回读校验发现这份归档与写入时不一致,已无法完整恢复。它会保留到过期,方便排查,不需要时也可以直接删除。",
|
||||
"corrupt_short": "已损坏",
|
||||
"verified_at": "{{when}}已校验",
|
||||
"skipped_entries": "{{count}} 个条目未归档",
|
||||
@@ -66,5 +66,15 @@
|
||||
"col_size": "大小",
|
||||
"col_expires": "过期时间",
|
||||
"col_owner": "原所有者",
|
||||
"col_actions": "操作"
|
||||
"col_actions": "操作",
|
||||
"delete_btn": "删除备份",
|
||||
"delete_title": "删除这份备份?",
|
||||
"delete_confirm": "{{relative}}({{absolute}})的这份备份({{size}})会立刻从列表中移除,之后不能再用它恢复,也无法撤销。",
|
||||
"delete_cleanup_note": "归档文件在下一次每日清理时删除,异地副本在下一次同步时删除。",
|
||||
"delete_archive_warning": "这份备份是世界被回收或放弃时存下的归档。服务器上的世界如果还没从它恢复回来,删掉它就再也找不回这个世界了。",
|
||||
"delete_only_warning": "这是这台服务器目前唯一的一份备份。",
|
||||
"delete_confirm_yes": "删除备份",
|
||||
"delete_restore_busy": "有一次恢复可能正在读取这份备份,等恢复完成后再删除。",
|
||||
"deleted": "备份已删除。",
|
||||
"delete_gone": "这份备份已经不在了,列表已刷新。"
|
||||
}
|
||||
@@ -792,6 +792,17 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect(String(url)).toBe("/backups");
|
||||
});
|
||||
|
||||
it("deleteBackup sends DELETE to the escaped backup id", async () => {
|
||||
const fetchSpy = fakeFetch({ id: "bk/1", status: "expired" });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.deleteBackup("bk/1");
|
||||
expect(res).toEqual({ id: "bk/1", status: "expired" });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/backups/bk%2F1");
|
||||
expect((opts as RequestInit).method).toBe("DELETE");
|
||||
expect((opts as RequestInit).body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restoreBackup sends backup_id, and safety_snapshot only when turned off", async () => {
|
||||
const fetchSpy = fakeFetch(
|
||||
{ name: "survival", status: "restoring", backup_id: "bk1", safety_snapshot: true },
|
||||
|
||||
@@ -631,6 +631,14 @@ export const api = rejectingSync({
|
||||
);
|
||||
},
|
||||
|
||||
// deleteBackup deletes one backup, in the same scope that lists it (an admin any,
|
||||
// a user only a world they formerly owned; any other id is 404 no_backup). It
|
||||
// leaves every list and restore at once; the reaper deletes the archive on its
|
||||
// next daily run and the off-site sync its copy. 409 restore_in_progress while a
|
||||
// restore on that server may still be reading it.
|
||||
deleteBackup: (id: string) =>
|
||||
request<{ id: string; status: "expired" }>("DELETE", urlPath`/backups/${id}`),
|
||||
|
||||
// restoreBackup starts an ASYNC restore of a server's world from a backup
|
||||
// (spec §7 POST restore-backup). It accepts an optional backupId in the body: when
|
||||
// absent the backend restores the latest backup and resolves its opaque ref
|
||||
|
||||
@@ -1174,6 +1174,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/backups/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete one world backup (admin, or the user who owned the world).
|
||||
* @description The backup leaves every list, restore and the backup budget at once; the reaper's next daily run deletes the archive and the off-site copy's next sync removes the bucket's copy. A user gets 404 for a backup outside their scope, as their list never shows it. Refused while a restore on the backup's server may still read it.
|
||||
*/
|
||||
delete: operations["deleteBackup"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/restore-backup": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5685,6 +5705,51 @@ export interface operations {
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
deleteBackup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The backup is deleted; its archive goes at the reaper's next run. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
id: string;
|
||||
/** @constant */
|
||||
status: "expired";
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description No present backup with this id in the caller's scope (no_backup). */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description A restore running on the backup's server may be reading it (restore_in_progress). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
restoreBackup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
@@ -15,6 +15,7 @@ const calls = vi.hoisted(() => ({
|
||||
listBackups: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
restoreBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/tier", () => ({
|
||||
useTier: () => ({
|
||||
@@ -217,3 +218,154 @@ describe("ServerBackups back up now", () => {
|
||||
expect(backUp().disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerBackups delete", () => {
|
||||
const row = (when: string) => screen.getByText(when).closest("tr") as HTMLElement;
|
||||
const deleteIn = (when: string) =>
|
||||
within(row(when)).getByRole("button", { name: "Delete backup" }) as HTMLButtonElement;
|
||||
const archiveWarning = () => screen.queryByText(i18next.t("backups:delete_archive_warning"));
|
||||
const onlyWarning = () => screen.queryByText(i18next.t("backups:delete_only_warning"));
|
||||
|
||||
it("deletes the picked backup after the confirm, then rereads the list", async () => {
|
||||
calls.listBackups
|
||||
.mockResolvedValueOnce({ backups: [backup("bk-new", 1), backup("bk-ok", 2)], total: 2 })
|
||||
.mockResolvedValue({ backups: [backup("bk-new", 1)], total: 1 });
|
||||
calls.deleteBackup.mockResolvedValue({ id: "bk-ok", status: "expired" });
|
||||
renderPage();
|
||||
await screen.findByText("2 hours ago");
|
||||
await userEvent.click(deleteIn("2 hours ago"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText("Delete this backup?")).toBeTruthy();
|
||||
expect(dialog.textContent).toContain("1.0 MiB");
|
||||
expect(dialog.textContent).toContain(i18next.t("backups:delete_cleanup_note"));
|
||||
expect(archiveWarning()).toBeNull();
|
||||
expect(onlyWarning()).toBeNull();
|
||||
expect(calls.deleteBackup).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Delete backup" }));
|
||||
expect(calls.deleteBackup).toHaveBeenCalledExactlyOnceWith("bk-ok");
|
||||
expect(await screen.findByText("Backup deleted.")).toBeTruthy();
|
||||
await vi.waitFor(() => expect(screen.queryByText("2 hours ago")).toBeNull());
|
||||
expect(calls.listBackups).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["inactive_15d", "released"])("warns that a %s archive may be the world's only copy", async (reason) => {
|
||||
calls.listBackups.mockResolvedValue({
|
||||
backups: [{ ...backup("bk-arc", 4), reason }, backup("bk-man", 5)],
|
||||
total: 2,
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("4 hours ago");
|
||||
await userEvent.click(deleteIn("4 hours ago"));
|
||||
await screen.findByRole("dialog");
|
||||
expect(archiveWarning()).toBeTruthy();
|
||||
expect(onlyWarning()).toBeNull();
|
||||
});
|
||||
|
||||
it("says when it is the server's only backup", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3)], total: 1 });
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await userEvent.click(deleteIn("3 hours ago"));
|
||||
await screen.findByRole("dialog");
|
||||
expect(onlyWarning()).toBeTruthy();
|
||||
expect(archiveWarning()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the dialog open and says why when a restore may be reading it", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 });
|
||||
calls.deleteBackup.mockRejectedValue({ status: 409, code: "restore_in_progress", message: "busy" });
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await userEvent.click(deleteIn("3 hours ago"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Delete backup" }));
|
||||
expect(await within(dialog).findByText(i18next.t("backups:delete_restore_busy"))).toBeTruthy();
|
||||
expect(screen.getByRole("dialog")).toBe(dialog);
|
||||
expect(calls.listBackups).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Backup deleted.")).toBeNull();
|
||||
});
|
||||
|
||||
it("rereads the list when the backup was already gone", async () => {
|
||||
calls.listBackups
|
||||
.mockResolvedValueOnce({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 })
|
||||
.mockResolvedValue({ backups: [backup("bk-2", 4)], total: 1 });
|
||||
calls.deleteBackup.mockRejectedValue({ status: 404, code: "no_backup", message: "no matching backup exists" });
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await userEvent.click(deleteIn("3 hours ago"));
|
||||
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete backup" }));
|
||||
expect(await screen.findByText(i18next.t("backups:delete_gone"))).toBeTruthy();
|
||||
await vi.waitFor(() => expect(screen.queryByText("3 hours ago")).toBeNull());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("waits while any restore runs on the server", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 });
|
||||
calls.serverJobs.mockResolvedValue([{ name: "restore-survival-aa", kind: "restore", state: "running" }]);
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await vi.waitFor(() => expect(deleteIn("3 hours ago").disabled).toBe(true));
|
||||
expect(deleteIn("4 hours ago").disabled).toBe(true);
|
||||
expect(deleteIn("3 hours ago").parentElement?.title).toBe(i18next.t("backups:delete_restore_busy"));
|
||||
});
|
||||
|
||||
it("waits only on the backup a safety snapshot is about to restore", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 });
|
||||
calls.serverJobs.mockResolvedValue([
|
||||
{ name: "backup-survival-bb", kind: "backup", state: "succeeded", then_restore: "pending", restore_backup_id: "bk-2" },
|
||||
{ name: "restore-survival-cc", kind: "restore", state: "succeeded" },
|
||||
]);
|
||||
renderPage();
|
||||
await screen.findByText("4 hours ago");
|
||||
await vi.waitFor(() => expect(deleteIn("4 hours ago").disabled).toBe(true));
|
||||
expect(deleteIn("3 hours ago").disabled).toBe(false);
|
||||
expect(deleteIn("3 hours ago").parentElement?.title).toBe("");
|
||||
});
|
||||
|
||||
it("steps back a page when the delete empties the last one", async () => {
|
||||
let lastGone = false;
|
||||
calls.listBackups.mockImplementation(async ({ offset }: { offset: number }) => ({
|
||||
backups: offset === 40 ? (lastGone ? [] : [backup("bk-last", 90)]) : [backup(`bk-${offset}`, 50)],
|
||||
total: lastGone ? 40 : 41,
|
||||
}));
|
||||
calls.deleteBackup.mockImplementation(async () => {
|
||||
lastGone = true;
|
||||
return { id: "bk-last", status: "expired" };
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("Page 1 of 3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByText("Page 3 of 3");
|
||||
await screen.findByText("4 days ago");
|
||||
await userEvent.click(deleteIn("4 days ago"));
|
||||
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete backup" }));
|
||||
expect(await screen.findByText("Page 2 of 2")).toBeTruthy();
|
||||
expect(calls.listBackups).toHaveBeenLastCalledWith({ server: "survival", limit: 20, offset: 20 });
|
||||
});
|
||||
|
||||
it("keeps stepping back past pages emptied meanwhile", async () => {
|
||||
let gone = false;
|
||||
calls.listBackups.mockImplementation(async ({ offset }: { offset: number }) => ({
|
||||
backups: offset === 0 ? [backup("bk-0", 50)] : gone ? [] : [backup(`bk-${offset}`, 90)],
|
||||
total: gone ? 20 : 41,
|
||||
}));
|
||||
calls.deleteBackup.mockImplementation(async () => {
|
||||
gone = true;
|
||||
return { id: "bk-40", status: "expired" };
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("Page 1 of 3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByText("Page 3 of 3");
|
||||
await screen.findByText("4 days ago");
|
||||
await userEvent.click(deleteIn("4 days ago"));
|
||||
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete backup" }));
|
||||
await vi.waitFor(() => expect(calls.listBackups).toHaveBeenLastCalledWith({ server: "survival", limit: 20, offset: 0 }));
|
||||
expect(await screen.findByText("2 days ago")).toBeTruthy();
|
||||
expect(screen.queryByText(/^Page \d+ of/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -17,6 +18,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||
import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||
import {
|
||||
@@ -43,12 +45,13 @@ const BACKUP_PAGE_SIZE = 20;
|
||||
|
||||
const CELL = "whitespace-nowrap md:px-4 md:py-3.5";
|
||||
|
||||
/** BackupRow is one backup in the table, with its own restore action. `isLatest`
|
||||
/** BackupRow is one backup in the table, with its own restore and delete actions. `isLatest`
|
||||
* marks the row a restore with no pick recovers: the newest one that is not
|
||||
* corrupt, which is what the backend's LatestBackup selects. Under the reason it
|
||||
* shows what the reaper's read-back found — corrupt (restore refused), verified,
|
||||
* or entries the archive could not hold. `showOwner` surfaces the former owner
|
||||
* (admins list every world's backups; a user only ever sees their own). */
|
||||
* (admins list every world's backups; a user only ever sees their own). `only`
|
||||
* says this is the one backup the server has, which the delete confirm names. */
|
||||
function BackupRow({
|
||||
b,
|
||||
isLatest,
|
||||
@@ -56,7 +59,10 @@ function BackupRow({
|
||||
locale,
|
||||
showOwner,
|
||||
serverName,
|
||||
jobs,
|
||||
only,
|
||||
onReloadStatus,
|
||||
onDeleted,
|
||||
}: {
|
||||
b: BackupView;
|
||||
isLatest: boolean;
|
||||
@@ -64,7 +70,10 @@ function BackupRow({
|
||||
locale: string;
|
||||
showOwner?: boolean;
|
||||
serverName: string;
|
||||
jobs: ServerJob[];
|
||||
only: boolean;
|
||||
onReloadStatus: () => void;
|
||||
onDeleted: (gone: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("backups");
|
||||
const expired = isExpired(b.expires_at, now);
|
||||
@@ -132,29 +141,136 @@ function BackupRow({
|
||||
</td>
|
||||
)}
|
||||
<td className={cn(CELL, "ml-auto text-right")}>
|
||||
{b.corrupt ? (
|
||||
<span className="text-xs text-destructive/70 font-medium px-3 py-1.5">
|
||||
{t("corrupt_short")}
|
||||
</span>
|
||||
) : !expired ? (
|
||||
<RestoreControls
|
||||
serverName={serverName}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{b.corrupt ? (
|
||||
<span className="text-xs text-destructive/70 font-medium px-3 py-1.5">
|
||||
{t("corrupt_short")}
|
||||
</span>
|
||||
) : !expired ? (
|
||||
<RestoreControls
|
||||
serverName={serverName}
|
||||
backup={b}
|
||||
now={now}
|
||||
locale={locale}
|
||||
onReloadStatus={onReloadStatus}
|
||||
buttonVariant={isLatest ? "destructive" : "outline"}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
|
||||
{t("expired")}
|
||||
</span>
|
||||
)}
|
||||
<DeleteBackupButton
|
||||
backup={b}
|
||||
now={now}
|
||||
locale={locale}
|
||||
onReloadStatus={onReloadStatus}
|
||||
buttonVariant={isLatest ? "destructive" : "outline"}
|
||||
only={only}
|
||||
busy={restoreMayRead(jobs, b.id)}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
|
||||
{t("expired")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** restoreMayRead mirrors the backend's delete gate: a restore Job still running on
|
||||
* the server (which archive it extracts is not on the Job), or a safety snapshot
|
||||
* whose restore of this backup has yet to start, may be reading the archive. */
|
||||
function restoreMayRead(jobs: ServerJob[], id: string): boolean {
|
||||
return jobs.some(
|
||||
(j) =>
|
||||
(j.kind === "restore" && j.state === "running") ||
|
||||
(j.then_restore === "pending" && j.restore_backup_id === id),
|
||||
);
|
||||
}
|
||||
|
||||
/** DeleteBackupButton deletes one backup behind a confirm that says what goes: the
|
||||
* row leaves the list at once and can no longer be restored, and the archive and
|
||||
* its off-site copy follow at the next cleanup and sync. A reclaimed or released
|
||||
* world's archive may be the only copy of that world, and the server's only backup
|
||||
* is the last way back, so the dialog says either before it asks. While a restore
|
||||
* may be reading the archive the button waits, since the API would refuse (409);
|
||||
* a backup someone else deleted meanwhile just refreshes the list. */
|
||||
function DeleteBackupButton({
|
||||
backup,
|
||||
now,
|
||||
locale,
|
||||
only,
|
||||
busy,
|
||||
onDeleted,
|
||||
}: {
|
||||
backup: BackupView;
|
||||
now: number;
|
||||
locale: string;
|
||||
only: boolean;
|
||||
busy: boolean;
|
||||
onDeleted: (gone: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("backups");
|
||||
const [open, setOpen] = useState(false);
|
||||
const archive = backup.reason === "inactive_15d" || backup.reason === "released";
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await api.deleteBackup(backup.id);
|
||||
} catch (e: any) {
|
||||
if (e && e.code === "no_backup") {
|
||||
onDeleted(true);
|
||||
return;
|
||||
}
|
||||
if (e && e.code === "restore_in_progress") throw new Error(t("delete_restore_busy"));
|
||||
throw e;
|
||||
}
|
||||
onDeleted(false);
|
||||
}
|
||||
|
||||
// A disabled button takes no pointer events, so the reason sits on a wrapper.
|
||||
return (
|
||||
<>
|
||||
<span title={busy ? t("delete_restore_busy") : undefined}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={busy}
|
||||
aria-label={t("delete_btn")}
|
||||
title={t("delete_btn")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("delete_title")}
|
||||
description={
|
||||
<>
|
||||
{t("delete_confirm", {
|
||||
relative: formatRelative(backup.created_at, now, locale),
|
||||
absolute: formatAbsolute(backup.created_at, locale),
|
||||
size: formatBytes(backup.size_bytes),
|
||||
})}
|
||||
{(archive || only) && (
|
||||
<span className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="flex flex-col gap-1">
|
||||
{archive && <span>{t("delete_archive_warning")}</span>}
|
||||
{only && <span>{t("delete_only_warning")}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-3 block text-xs">{t("delete_cleanup_note")}</span>
|
||||
</>
|
||||
}
|
||||
confirmLabel={t("delete_confirm_yes")}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** IntegrityNote is what the reaper's read-back says about one archive: corrupt
|
||||
* (it no longer matches what was written, so it cannot be restored), when it
|
||||
* was last read back intact, and how many entries of the world it could not
|
||||
@@ -481,6 +597,12 @@ export function ServerBackups() {
|
||||
[name, page],
|
||||
{ keepPrevious: true },
|
||||
);
|
||||
// Deleting the last row of a later page leaves it empty: step back, one settled
|
||||
// read at a time, to a page that still has backups.
|
||||
const pageEmpty = !backupsQ.loading && !!backupsQ.data && backupsQ.data.backups.length === 0 && page > 1;
|
||||
useEffect(() => {
|
||||
if (pageEmpty) setPage((p) => Math.max(1, p - 1));
|
||||
}, [pageEmpty]);
|
||||
|
||||
// Ownership resolves from /me/servers for a non-admin (status carries no `owned`).
|
||||
// While it is pending show the header with a spinner rather than flashing the list
|
||||
@@ -539,6 +661,12 @@ export function ServerBackups() {
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted backup (or one already gone when asked) leaves the list, reread now.
|
||||
function handleDeleted(gone: boolean) {
|
||||
setBackupMsg({ kind: "success", text: t(gone ? "delete_gone" : "deleted") });
|
||||
backupsQ.reload();
|
||||
}
|
||||
|
||||
const back = (
|
||||
<BackLink to={`/servers/${name}`} label={t("back_to_console")} />
|
||||
);
|
||||
@@ -657,10 +785,13 @@ export function ServerBackups() {
|
||||
locale={locale}
|
||||
showOwner={isAdmin}
|
||||
serverName={name}
|
||||
jobs={jobsQ.data ?? []}
|
||||
only={total === 1}
|
||||
onReloadStatus={() => {
|
||||
statusQ.reload();
|
||||
jobsQ.reload();
|
||||
}}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in new issue
Block a user