diff --git a/CHANGELOG.md b/CHANGELOG.md index b26bc9f..e6d2d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,20 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. - `occ backup:user:list [location] [user]` shows the user exports on a location, with the manifest path `backup:user:restore` expects. Before, that path was only printed once by `backup:user:backup` (#22). +- A "Docs" tab on the admin page, with links to the how-to, the disaster recovery guide and the + roadmap. It also shows before the backup key is set up. +- A label for a backup started with "Back up now" (optional; `occ backup:run -l` already had one). + It shows with the restore point. +- An explanation in the trash of restore points of what "removed by retention policy" means: the + policy keeps the newest restore points and the last one of each recent day, week and month, and + moves the others to the trash, where they can be brought back until they are deleted + permanently. `occ backup:trash` says the same below its list. ### Changed +- `occ backup:status`, `backup:list`, `backup:trash` and `backup:browse` show times in the time + zone of the daily schedule and name it, as the admin page shows them in the administrator's. + Before, they showed the server's UTC, which read as a wrong start time. +- The admin page no longer says NG Backup is in beta. - `occ backup:user:restore` also takes a user id instead of the manifest path, when that user has exactly one export on the location. With more, and for a manifest path that is not on the location, the error lists that user's exports; for a user without exports it names the @@ -36,6 +48,11 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. wrote to the database just as the restore began (a background job that was already running). The restore now takes the write lock first and waits for the other process instead. The full-restore test on SQLite runs with a second process writing alongside. +- `occ backup:restore ... --to-directory` of a single file restored nothing ("Restored 0 + files"); only a folder worked. A path to a file now restores that file. +- A restore that was stopped (a crash, a timeout, a restart) left its half-written temporary file + (`.ngb-restore-`) next to the file it was restoring, and a `files:scan` then showed it + in Files. A restore now removes such leftovers in each folder it writes to. ## [1.0.0] - 2026-10-29 diff --git a/PLAN.md b/PLAN.md index 8df1b62..89d2fd0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -392,10 +392,12 @@ in README "Known limitations": gids (SFTP, Storage Box) → rc4 uitbrengen en om tests vragen → zelf een volledige DR van een productie-instantie van ~250 GB via S3 en via SMB. Drie volledige DR-restores vóór 1.0.0. Problemen of meldingen van gebruikers: rc5. -- **1.0.1:** - 1. (Naar rc4: herstelsnelheid over SFTP.) S3 leest al 16 tegelijk bij index en tree, maar een +- **1.0.1:** alleen de kleine punten (besluit 2026-10-30): 4, 6–13. Punten 1, 2 en 5 gaan naar + 1.0.2 (README-roadmap aangepast); punt 3 is een test, ingepland voor de week van 2 november, + niet aangekondigd en los van de release. + 1. **Naar 1.0.2.** (Naar rc4: herstelsnelheid over SFTP.) S3 leest al 16 tegelijk bij index en tree, maar een restore leest de packs nog één voor één: ook vooruitlezen via de S3-client. - 2. Achtergrondjobs zonder systeem-cron (issue #13; besluit 2026-10-19). De PR die dit + 2. **Naar 1.0.2.** Achtergrondjobs zonder systeem-cron (issue #13; besluit 2026-10-19). De PR die dit oplost zegt "Fixes #13" (bevestigd: #13 was de cron; besluit 2026-10-21): a. **Waarschuwen** in de beheerpagina en `backup:status`: modus uit `core backgroundjobs_mode` (`ajax`/`webcron`/`cron`) en de laatste run uit `core lastcron`. Niet op `cron`, of de @@ -424,25 +426,29 @@ in README "Known limitations": 4. **Label bij "Back up now"** (besluit 2026-10-19): een optioneel tekstveld naast de knop; de tekst gaat als label van de snapshot mee (nu kan dat alleen met `occ backup:run -l`; de knop stuurt een leeg label, geplande runs krijgen "scheduled"). Raakt alleen de UI en een - bestaande parameter. + bestaande parameter. Ten hoogste 100 tekens — **klaar** (2026-10-30). 5. **`--salvage`** voor `backup:restore` en `backup:restore:full` (besluit 2026-10-22): terughalen wat kan, een bestand dat niet te lezen of te ontsleutelen is overslaan en aan het eind - opsommen, in plaats van stoppen. + opsommen, in plaats van stoppen. **Naar 1.0.2** (besluit 2026-10-30). 6. **Tijdelijke bestanden van een afgebroken restore opruimen** (gevonden in de SMB-DR van rc6, 2026-10-24): de rc5-poging die stopte liet `VID_0009.mp4.ngb-restore-` staan; de geslaagde restore daarna haalde het niet weg, en `files:scan` maakte het zichtbaar in Files. - Een restore ruimt `*.ngb-restore-*` in de mappen die hij terugzet op. + Een restore ruimt `*.ngb-restore-*` in de mappen die hij terugzet op (alleen bestanden met + precies dat achtervoegsel en 6 of 12 hextekens) — **klaar** (2026-10-30). 7. **Eén bestand terugzetten met `backup:restore`**: een pad naar een bestand zet 0 bestanden - terug (er komt een `/` achter); nu werkt alleen een map. + terug (er komt een `/` achter); nu werkt alleen een map. Gold alleen voor `--to-directory`; + naar de bestanden van een gebruiker werkte een los bestand al — **klaar** (2026-10-30). 8. **Uitleg (i) bij de prullenbak van herstelpunten** (besluit 2026-10-25): bij "removed by retention policy" een infoknop met wat het betekent: het bewaarbeleid houdt voor oudere dagen één herstelpunt per dag (het laatste), weken en maanden; de rest gaat naar de prullenbak en wordt pas na de ingestelde wachttijd (`delete_delay_days`, standaard 30) echt verwijderd, en is tot die datum terug te zetten. Aanleiding: de beheerder zag "19-10 13:12 – after rc4 upgrade - removed by retention policy" en dacht dat er iets was weggegooid. + removed by retention policy" en dacht dat er iets was weggegooid. `occ backup:trash` zegt + hetzelfde onder de lijst — **klaar** (2026-10-30). 9. **Tijdzone in `occ backup:status`**: de tabel toont de servertijd (UTC), de webinterface de tijd van de beheerder; dat leest als een verkeerde starttijd. Zet de zone in de kolomkop - ("started (UTC)") of toon de tijden in de zone van het schema. + ("started (UTC)") of toon de tijden in de zone van het schema. Gedaan: beide, en ook in + `backup:list`, `backup:trash` en `backup:browse` — **klaar** (2026-10-30). 10. **User-restore op gebruikersnaam** (issue #22, 2026-10-27): `backup:user:restore New_User` gaf alleen "is not a recorded user export"; het argument moet het manifestpad zijn, dat alleen `backup:user:backup` eenmalig toonde. Nu: `backup:user:list` toont de diff --git a/README.md b/README.md index 1f9a8dc..87f1bf5 100644 --- a/README.md +++ b/README.md @@ -261,15 +261,22 @@ What is planned, without dates. Ideas and votes are welcome in the a 👍 on its issue helps decide what comes first. **1.0.1** -- A warning on the admin page and in `occ backup:status` when background jobs do not run on a - system cron, with how to set one up. Without cron, a backup started with "Back up now" moves on - while the NG Backup page is open ([#13](https://github.com/dillardblom/ng_backup/issues/13)). - An optional label for a backup started with "Back up now". - An explanation in the trash of restore points of what "removed by retention policy" means and until when a restore point can be brought back. -- Faster full restores from S3 and SMB, reading ahead as restores over SFTP do since 1.0.0-rc.4. - A "Docs" tab on the admin page with links to the how-to, the disaster recovery guide and this roadmap. +- Times in `occ` in the administrator's time zone, as on the admin page; restoring a single file + with `occ backup:restore --to-directory`. + +**1.0.2** +- A warning on the admin page and in `occ backup:status` when background jobs do not run on a + system cron, with how to set one up. Without cron, a backup started with "Back up now" moves on + while the NG Backup page is open, and the database phase of a backup shows its progress + ([#13](https://github.com/dillardblom/ng_backup/issues/13)). +- Faster full restores from S3 and SMB, reading ahead as restores over SFTP do since 1.0.0-rc.4. +- `--salvage` for a restore: restore what can be read, skip a file that cannot, and list those at + the end instead of stopping. **1.1.0** - Restore without the recovery kit: the backup key from the location itself, opened with one of diff --git a/lib/Command/Browse.php b/lib/Command/Browse.php index 9d2bf37..afb57b2 100644 --- a/lib/Command/Browse.php +++ b/lib/Command/Browse.php @@ -7,7 +7,9 @@ declare(strict_types=1); namespace OCA\NgBackup\Command; +use OCA\NgBackup\Format; use OCA\NgBackup\Service\RestoreService; +use OCA\NgBackup\Service\Schedule; use OCA\NgBackup\Service\TargetService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; @@ -19,6 +21,7 @@ final class Browse extends Command { public function __construct( private TargetService $targets, private RestoreService $restore, + private Schedule $schedule, ) { parent::__construct(); } @@ -43,9 +46,10 @@ final class Browse extends Command { $output->writeln('Nothing here.'); return 0; } + $zone = $this->schedule->get()['timezone']; $rows = array_map(fn ($i) => [$i['type'] === 'dir' ? $i['name'] . '/' : $i['name'], $this->size($i['size']), - $i['type'] === 'dir' ? $i['files'] . ' files' : date('Y-m-d H:i', (int)$i['mtime'])], $items); - (new Table($output))->setHeaders(['name', 'size', 'files / modified'])->setRows($rows)->render(); + $i['type'] === 'dir' ? $i['files'] . ' files' : Format::time((int)$i['mtime'], $zone)], $items); + (new Table($output))->setHeaders(['name', 'size', "files / modified ($zone)"])->setRows($rows)->render(); return 0; } diff --git a/lib/Command/ListSnapshots.php b/lib/Command/ListSnapshots.php index ef2ced9..8ad4b96 100644 --- a/lib/Command/ListSnapshots.php +++ b/lib/Command/ListSnapshots.php @@ -8,6 +8,8 @@ declare(strict_types=1); namespace OCA\NgBackup\Command; use OCA\NgBackup\Db\SnapshotMapper; +use OCA\NgBackup\Format; +use OCA\NgBackup\Service\Schedule; use OCA\NgBackup\Service\TargetService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; @@ -19,6 +21,7 @@ final class ListSnapshots extends Command { public function __construct( private TargetService $targets, private SnapshotMapper $snapshots, + private Schedule $schedule, ) { parent::__construct(); } @@ -32,10 +35,11 @@ final class ListSnapshots extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { $name = $input->getArgument('target'); $targets = $name === null ? $this->targets->list() : [$this->targets->get($name)]; + $zone = $this->schedule->get()['timezone']; $rows = []; foreach ($targets as $t) { foreach ($this->snapshots->findForTarget($t->getId()) as $s) { - $rows[] = [$t->getName(), $s->getSnapshotId(), date('Y-m-d H:i', $s->getCreatedAt()), $s->getKind(), + $rows[] = [$t->getName(), $s->getSnapshotId(), Format::time($s->getCreatedAt(), $zone), $s->getKind(), $s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? '']; } } @@ -43,7 +47,7 @@ final class ListSnapshots extends Command { $output->writeln('No snapshots yet (occ backup:run).'); return 0; } - (new Table($output))->setHeaders(['location', 'snapshot', 'date', 'kind', 'files', 'size', 'label'])->setRows($rows)->render(); + (new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'label'])->setRows($rows)->render(); return 0; } } diff --git a/lib/Command/Status.php b/lib/Command/Status.php index 90491a1..9d89678 100644 --- a/lib/Command/Status.php +++ b/lib/Command/Status.php @@ -8,7 +8,9 @@ declare(strict_types=1); namespace OCA\NgBackup\Command; use OCA\NgBackup\Db\RunMapper; +use OCA\NgBackup\Format; use OCA\NgBackup\Service\KeyService; +use OCA\NgBackup\Service\Schedule; use OCA\NgBackup\Service\TargetService; use OCA\NgBackup\Service\VerifyService; use Symfony\Component\Console\Command\Command; @@ -26,6 +28,7 @@ final class Status extends Command { private TargetService $targets, private RunMapper $runs, private VerifyService $verify, + private Schedule $schedule, ) { parent::__construct(); } @@ -36,12 +39,13 @@ final class Status extends Command { } protected function execute(InputInterface $input, OutputInterface $output): int { + $zone = $this->schedule->get()['timezone']; if (!$this->keys->isInitialized()) { $output->writeln('Key: not created (occ backup:key:init)'); } else { $c = $this->keys->recoveryKitConfirmation(); $output->writeln('Key: ' . $this->keys->fingerprint() . ', recovery kit ' - . ($c ? 'confirmed by ' . $c['uid'] . ' on ' . date('Y-m-d H:i', $c['time']) : 'not confirmed (occ backup:key:confirm)')); + . ($c ? 'confirmed by ' . $c['uid'] . ' on ' . Format::time($c['time'], $zone) . " ($zone)" : 'not confirmed (occ backup:key:confirm)')); } $names = []; foreach ($this->targets->list() as $t) { @@ -54,12 +58,12 @@ final class Status extends Command { foreach ($shown as $r) { $s = json_decode($r->getStats() ?? '{}', true)['files'] ?? []; $rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . self::percent($r) . ')' : ''), - date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? \OCA\NgBackup\Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '', + Format::time($r->getStartedAt(), $zone), $r->getFinishedAt() ? Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '', $s['files'] ?? '', isset($s['bytes']) ? self::size((int)$s['bytes']) : '', isset($s['uploaded']) ? self::size((int)$s['uploaded']) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')]; } if ($rows !== []) { - (new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'size', 'uploaded', 'error'])->setRows($rows)->render(); + (new Table($output))->setHeaders(['run', 'location', 'kind', 'status', "started ($zone)", 'took', 'files', 'size', 'uploaded', 'error'])->setRows($rows)->render(); } $older = $all ? 0 : $this->runs->countAll() - count($shown); if ($older > 0) { diff --git a/lib/Command/Trash.php b/lib/Command/Trash.php index d63a01d..61a7300 100644 --- a/lib/Command/Trash.php +++ b/lib/Command/Trash.php @@ -7,7 +7,9 @@ declare(strict_types=1); namespace OCA\NgBackup\Command; +use OCA\NgBackup\Format; use OCA\NgBackup\Service\KeyService; +use OCA\NgBackup\Service\Schedule; use OCA\NgBackup\Service\TargetService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; @@ -19,6 +21,7 @@ final class Trash extends Command { public function __construct( private TargetService $targets, private KeyService $keys, + private Schedule $schedule, ) { parent::__construct(); } @@ -32,11 +35,14 @@ final class Trash extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { $name = $input->getArgument('target'); $delay = $this->keys->deleteDelayDays() * 86400; + $zone = $this->schedule->get()['timezone']; $rows = []; foreach ($name === null ? $this->targets->list() : [$this->targets->get($name)] as $t) { foreach ($this->targets->repository($t)->trash() as $id => $info) { - $rows[] = [$t->getName(), $id, (string)($info['time'] ?? ''), $info['label'], date('Y-m-d H:i', $info['forgottenAt']), $info['by'], - date('Y-m-d H:i', $info['forgottenAt'] + $delay)]; + $time = $info['time'] ?? null; + $made = is_string($time) ? strtotime($time) : false; + $rows[] = [$t->getName(), $id, $made !== false ? Format::time($made, $zone) : '', $info['label'], Format::time($info['forgottenAt'], $zone), $info['by'], + Format::time($info['forgottenAt'] + $delay, $zone)]; } } if ($rows === []) { @@ -44,6 +50,7 @@ final class Trash extends Command { return 0; } (new Table($output))->setHeaders(['location', 'snapshot', 'made', 'label', 'removed', 'by', 'deleted after'])->setRows($rows)->render(); + $output->writeln("Times in $zone. The retention policy keeps the newest snapshots and the last of each recent day, week and month; it moves the others here, and they are deleted permanently after " . $this->keys->deleteDelayDays() . ' days.'); return 0; } } diff --git a/lib/Format.php b/lib/Format.php index 6662287..85901ee 100644 --- a/lib/Format.php +++ b/lib/Format.php @@ -15,4 +15,14 @@ final class Format { $time = gmdate('H:i:s', $seconds % 86400); return $days > 0 ? $days . 'd ' . $time : $time; } + + /** + * "2026-10-29 04:30" in $zone. Nextcloud runs PHP in UTC; occ shows times in the zone of the + * schedule, the administrator's, as the web interface does. + * + * @param non-empty-string $zone + */ + public static function time(int $timestamp, string $zone): string { + return (new \DateTimeImmutable('@' . $timestamp))->setTimezone(new \DateTimeZone($zone))->format('Y-m-d H:i'); + } } diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index 6d547ca..e87a173 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -214,7 +214,8 @@ final class Repository { } /** - * Restore a snapshot (or only paths starting with $prefix) into a local directory. + * Restore a snapshot (or only paths starting with $prefix) into a local directory. A $prefix + * "dir/file/" also restores the file "dir/file" itself. * * @param bool $stripPrefix write "$target/" instead of * "$target/" (disaster recovery: restoring e.g. "data/" or @@ -253,7 +254,7 @@ final class Repository { $plan = new ReadAheadPlan(rtrim($target, '/') . '/.ngb-restore-spool-' . bin2hex(random_bytes(4))); try { foreach ($this->streamEntries($snapshotId) as $entry) { - if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) { + if (!self::underPrefix($entry['p'], $prefix)) { continue; } foreach ($entry['b'] as $id) { @@ -286,11 +287,15 @@ final class Repository { private function restoreEntries(string $snapshotId, string $target, string $prefix, bool $stripPrefix, ?callable $heartbeat, ?ReadAheadPlan $plan): int { $count = 0; + $cleaned = []; foreach ($this->streamEntries($snapshotId) as $entry) { - if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) { + if (!self::underPrefix($entry['p'], $prefix)) { continue; } - $relPath = ($stripPrefix && $prefix !== '') ? substr($entry['p'], strlen(rtrim($prefix, '/')) + 1) : $entry['p']; + $relPath = $entry['p']; + if ($stripPrefix && $prefix !== '') { + $relPath = $entry['p'] === rtrim($prefix, '/') ? basename($entry['p']) : substr($entry['p'], strlen($prefix)); + } $dest = rtrim($target, '/') . '/' . $relPath; if (!is_dir(dirname($dest))) { mkdir(dirname($dest), 0700, true); @@ -301,6 +306,10 @@ final class Repository { if ($base === false || $dir === false || ($dir !== $base && !str_starts_with($dir, $base . '/')) || is_link($dest)) { throw new RepositoryException('Refusing to write outside the restore directory: ' . $entry['p']); } + if (!isset($cleaned[$dir])) { + self::removeStaleTemporaries($dir); + $cleaned[$dir] = true; + } // Write next to the destination and rename after all blobs verified: at most one file // is ever present twice. $tmp = $dest . '.ngb-restore-' . bin2hex(random_bytes(6)); @@ -331,6 +340,30 @@ final class Repository { return $count; } + /** $prefix is "" (everything) or ends with "/"; the path without that "/" is a file to restore too. */ + private static function underPrefix(string $path, string $prefix): bool { + return $prefix === '' || str_starts_with($path, $prefix) || $path === rtrim($prefix, '/'); + } + + /** + * Remove what a restore that was stopped left in a directory this restore writes to: + * ".ngb-restore-" from here, "..ngb-restore-" from a restore through + * Nextcloud. A files:scan would otherwise make them show up in Files. + */ + public static function removeStaleTemporaries(string $dir): void { + $names = scandir($dir); + foreach ($names === false ? [] : $names as $name) { + $path = $dir . '/' . $name; + if (self::isRestoreTemporary($name) && is_file($path) && !is_link($path)) { + @unlink($path); + } + } + } + + public static function isRestoreTemporary(string $name): bool { + return preg_match('/.\.ngb-restore-[0-9a-f]{6}(?:[0-9a-f]{6})?$/', $name) === 1; + } + /** * Start writing a generated stream (e.g. a table dump) as blobs. Call flushPacks() when the * run is done, so the last pack is uploaded before anything references it. diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index e6c39ea..3951f3d 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -34,6 +34,8 @@ use Psr\Log\LoggerInterface; */ final class BackupService { public const KIND_FULL = 'full'; + /** Characters of a snapshot label (the column holds 255) */ + public const LABEL_MAX = 100; /** @var \WeakMap the location settings each kept repository was opened with */ private \WeakMap $openedWith; @@ -57,6 +59,10 @@ final class BackupService { } public function start(Target $target, string $label = ''): Run { + $label = trim($label); + if (mb_strlen($label) > self::LABEL_MAX) { + throw new \InvalidArgumentException('A label has at most ' . self::LABEL_MAX . ' characters'); + } if (!$this->keys->everConfirmed()) { throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)'); } diff --git a/lib/Service/RestoreService.php b/lib/Service/RestoreService.php index 20bee68..fc3e276 100644 --- a/lib/Service/RestoreService.php +++ b/lib/Service/RestoreService.php @@ -33,6 +33,9 @@ final class RestoreService { public const MODE_MERGE = 'merge'; public const MODE_REPLACE = 'replace'; + /** @var array folders of the current restore already cleared of stale temporaries */ + private array $cleaned = []; + public function __construct( private TargetService $targets, private IRootFolder $root, @@ -133,6 +136,7 @@ final class RestoreService { $dest = $this->ensureFolder($userFolder, $parentRel); $names = $single ? [basename($rel) => $entries['']] : $entries; + $this->cleaned = []; $restored = 0; foreach ($names as $relPath => $entry) { $this->writeFile($repo, $dest, (string)$relPath, $entry); @@ -178,6 +182,10 @@ final class RestoreService { private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void { $dir = dirname($relPath); $folder = $dir === '.' ? $dest : $this->ensureFolder($dest, $dir); + if (!isset($this->cleaned[$folder->getPath()])) { + $this->removeStaleTemporaries($folder); + $this->cleaned[$folder->getPath()] = true; + } $name = basename($relPath); // Stage under a temporary name; only rename into place after every blob was verified. $tmpName = '.' . $name . '.ngb-restore-' . bin2hex(random_bytes(3)); @@ -223,6 +231,19 @@ final class RestoreService { } } + /** See Repository::removeStaleTemporaries(): what a restore that was stopped left in this folder. */ + private function removeStaleTemporaries(Folder $folder): void { + foreach ($folder->getDirectoryListing() as $node) { + if ($node instanceof File && Repository::isRestoreTemporary($node->getName())) { + try { + $node->delete(); + } catch (\Throwable) { + // in the way of nothing: the new temporary gets a name of its own + } + } + } + } + private function trashExtras(Folder $folder, string $prefix, array $keep): int { $count = 0; foreach ($folder->getDirectoryListing() as $node) { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index b25f599..c138868 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -110,11 +110,6 @@ - - - - - diff --git a/src/components/Backups.vue b/src/components/Backups.vue index e0d915d..d077d9c 100644 --- a/src/components/Backups.vue +++ b/src/components/Backups.vue @@ -10,6 +10,8 @@ {{ t('ng_backup', 'Save') }}
+ {{ isRunning(target.id) ? t('ng_backup', 'Backup to {name} running …', { name: target.name }) : t('ng_backup', 'Back up now to {name}', { name: target.name }) }} @@ -24,9 +26,23 @@
  • {{ item.time ? formatDateTime(item.time) : item.id }}{{ item.label ? ' – ' + item.label : '' }} {{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: formatDate(item.deleteAfter) }) }} + {{ t('ng_backup', 'Restore') }}
  • + + {{ n('ng_backup', + 'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this trash, where they stay for %n day before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.', + 'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this trash, where they stay for %n days before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.', + status.key.deleteDelayDays) }} +
    @@ -93,6 +109,10 @@ const schedule = ref(shown(props.status.schedule)) const error = ref('') watch(() => [props.status.schedule, props.status.scheduleTimezone], ([s]) => { schedule.value = shown(s) }) +const label = ref('') +// "by" of a restore point the weekly cleanup removed (PruneService) +const RETENTION = 'retention policy' +const retentionInfo = ref(false) const trash = reactive({}) async function loadTrash() { for (const target of props.status.targets) { @@ -125,7 +145,8 @@ const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ? async function start(target) { error.value = '' try { - await api.startBackup(target.id, '') + await api.startBackup(target.id, label.value.trim()) + label.value = '' emit('changed') } catch (e) { error.value = e.message @@ -146,6 +167,8 @@ async function saveSchedule() {