From 193f65940b7356f96d16ef922d16f1102e7fe4e0 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Fri, 6 Nov 2026 23:49:50 +0100 Subject: [PATCH] Pin a restore point and keep a note on it Both live in the catalog on the location, so a new server sees them; the database holds a local copy for the lists. The retention policy leaves a pinned restore point alone and does not count it. Available on the restore page and as occ backup:pin and backup:note. --- CHANGELOG.md | 7 ++ README.md | 1 + appinfo/info.xml | 2 + appinfo/routes.php | 2 + docs/HOWTO.md | 5 ++ lib/Command/ListSnapshots.php | 4 +- lib/Command/Note.php | 44 +++++++++ lib/Command/Pin.php | 45 ++++++++++ lib/Controller/AdminApiController.php | 22 ++++- lib/Db/Snapshot.php | 7 ++ .../Version000007Date20261107000000.php | 33 +++++++ lib/Repository/Catalog.php | 11 ++- lib/Repository/Repository.php | 43 +++++++++ lib/Service/PruneService.php | 52 ++++++++++- lib/Service/RetentionPolicy.php | 13 +++ lib/Service/TargetService.php | 20 +++++ psalm-baseline.xml | 8 ++ src/api.js | 2 + src/components/Backups.vue | 2 +- src/components/RestoreBrowser.vue | 39 +++++++- tests/Unit/Repository/SnapshotMarksTest.php | 89 +++++++++++++++++++ tests/Unit/Service/RetentionPolicyTest.php | 11 +++ tests/integration/roundtrip.sh | 11 +++ 23 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 lib/Command/Note.php create mode 100644 lib/Command/Pin.php create mode 100644 lib/Migration/Version000007Date20261107000000.php create mode 100644 tests/Unit/Repository/SnapshotMarksTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fdd9ad..e72c7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. ## [Unreleased] +### Added +- Pin a restore point, on the restore page or with `occ backup:pin`: the retention policy leaves it + alone, for example the one from before an upgrade. Unpinning notifies all administrators. +- A note on a restore point that you can change afterwards, on the restore page or with + `occ backup:note`. Pins and notes are stored encrypted on the location, so a new server sees them. + An older version of NG Backup can still read the location, but drops them when it writes to it. + ### Changed - A file or folder that is left out because of its name now gets a notification of its own, in the log and in `occ backup:run`. It says the item is missing from the backup, gives example paths and diff --git a/README.md b/README.md index 3211c1d..402984e 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,7 @@ On the backlog; know them before you rely on NG Backup: | `backup:cancel ` | stop a running backup | | `backup:browse`, `backup:restore` | browse and restore | | `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash | +| `backup:pin`, `backup:note` | keep a restore point, a note you can change later | | `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) | | `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) | | `backup:key:import` | import a recovery kit on a fresh installation | diff --git a/appinfo/info.xml b/appinfo/info.xml index 6ee5d53..1f15e3e 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -83,6 +83,8 @@ you need help with restoring a backup after a crash.]]> OCA\NgBackup\Command\ListSnapshots OCA\NgBackup\Command\Browse OCA\NgBackup\Command\Restore + OCA\NgBackup\Command\Pin + OCA\NgBackup\Command\Note OCA\NgBackup\Command\Prune OCA\NgBackup\Command\Retention OCA\NgBackup\Command\TargetAppendOnly diff --git a/appinfo/routes.php b/appinfo/routes.php index b058d68..22d510c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -27,6 +27,8 @@ return [ ['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'], ['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'], ['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'], + ['name' => 'AdminApi#setSnapshotNote', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/note', 'verb' => 'PUT'], + ['name' => 'AdminApi#setSnapshotPinned', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/pin', 'verb' => 'PUT'], ['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'], ['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'], ], diff --git a/docs/HOWTO.md b/docs/HOWTO.md index 3ff6136..552e724 100644 --- a/docs/HOWTO.md +++ b/docs/HOWTO.md @@ -173,8 +173,13 @@ occ backup:prune offsite --dry-run # show first occ backup:prune offsite # apply the policy; forgotten snapshots go to the trash first occ backup:trash # list the trash occ backup:trash:restore offsite +occ backup:pin offsite # keep this restore point; --unpin to undo +occ backup:note offsite "text" # a note you can change later; without text it is removed ``` +The retention policy leaves a pinned restore point alone, and a pinned one does not take the place of +a daily, weekly or monthly one. Pin and note are stored on the location, so a new server sees them too. + Snapshots are kept according to the policy, and their data is deleted only after the deletion delay (set with `backup:key:init --delete-delay-days`). `occ backup:prune offsite --dry-run` shows what would be removed without changing anything. diff --git a/lib/Command/ListSnapshots.php b/lib/Command/ListSnapshots.php index 8ad4b96..5cc2892 100644 --- a/lib/Command/ListSnapshots.php +++ b/lib/Command/ListSnapshots.php @@ -40,14 +40,14 @@ final class ListSnapshots extends Command { foreach ($targets as $t) { foreach ($this->snapshots->findForTarget($t->getId()) as $s) { $rows[] = [$t->getName(), $s->getSnapshotId(), Format::time($s->getCreatedAt(), $zone), $s->getKind(), - $s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? '']; + $s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getPinned() ? 'yes' : '', $s->getLabel() ?? '', $s->getNote() ?? '']; } } if ($rows === []) { $output->writeln('No snapshots yet (occ backup:run).'); return 0; } - (new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'label'])->setRows($rows)->render(); + (new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'pinned', 'label', 'note'])->setRows($rows)->render(); return 0; } } diff --git a/lib/Command/Note.php b/lib/Command/Note.php new file mode 100644 index 0000000..bdfcf4c --- /dev/null +++ b/lib/Command/Note.php @@ -0,0 +1,44 @@ +setName('backup:note') + ->setDescription('Set or remove the note on a snapshot') + ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') + ->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)') + ->addArgument('note', InputArgument::OPTIONAL, 'The note, at most ' . PruneService::NOTE_MAX . ' characters; without it the note is removed', ''); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $note = (string)$input->getArgument('note'); + try { + $this->prune->setNote($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), $note); + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln(trim($note) === '' ? 'Note removed.' : 'Note saved.'); + return 0; + } +} diff --git a/lib/Command/Pin.php b/lib/Command/Pin.php new file mode 100644 index 0000000..eedd132 --- /dev/null +++ b/lib/Command/Pin.php @@ -0,0 +1,45 @@ +setName('backup:pin') + ->setDescription('Keep a snapshot: the retention policy does not remove a pinned one') + ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') + ->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)') + ->addOption('unpin', null, InputOption::VALUE_NONE, 'Leave the snapshot to the retention policy again'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $unpin = (bool)$input->getOption('unpin'); + try { + $this->prune->setPinned($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), !$unpin); + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln($unpin ? 'Snapshot unpinned; the retention policy applies to it again.' : 'Snapshot pinned; the retention policy leaves it alone.'); + return 0; + } +} diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index 37995ec..a27e594 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -292,11 +292,29 @@ final class AdminApiController extends Controller { $out = []; foreach ($this->snapshots->findForTarget($targetId) as $s) { $out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(), - 'files' => $s->getFiles(), 'bytes' => $s->getBytes()]; + 'files' => $s->getFiles(), 'bytes' => $s->getBytes(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()]; } return new JSONResponse($out); } + public function setSnapshotNote(int $targetId, string $snapshotId, string $note = ''): JSONResponse { + try { + $this->prune->setNote($this->targets->get((string)$targetId), $snapshotId, $note); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + return new JSONResponse(['ok' => true]); + } + + public function setSnapshotPinned(int $targetId, string $snapshotId, bool $pinned = true): JSONResponse { + try { + $this->prune->setPinned($this->targets->get((string)$targetId), $snapshotId, $pinned); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + return new JSONResponse(['ok' => true]); + } + public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse { try { return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path)); @@ -341,7 +359,7 @@ final class AdminApiController extends Controller { if ($r->getSnapshotId() !== null) { try { $s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId()); - $snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()]; + $snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()]; } catch (\Throwable) { // Snapshot already pruned or trashed: the run keeps its id, just not the date. } diff --git a/lib/Db/Snapshot.php b/lib/Db/Snapshot.php index 55eb769..47d8014 100644 --- a/lib/Db/Snapshot.php +++ b/lib/Db/Snapshot.php @@ -24,6 +24,10 @@ use OCP\AppFramework\Db\Entity; * @method void setFiles(?int $v) * @method int|null getBytes() * @method void setBytes(?int $v) + * @method string|null getNote() + * @method void setNote(?string $v) + * @method bool|null getPinned() + * @method void setPinned(?bool $v) */ final class Snapshot extends Entity { protected int $targetId = 0; @@ -33,8 +37,11 @@ final class Snapshot extends Entity { protected int $createdAt = 0; protected ?int $files = null; protected ?int $bytes = null; + protected ?string $note = null; + protected ?bool $pinned = null; public function __construct() { + $this->addType('pinned', 'boolean'); $this->addType('targetId', 'integer'); $this->addType('createdAt', 'integer'); $this->addType('files', 'integer'); diff --git a/lib/Migration/Version000007Date20261107000000.php b/lib/Migration/Version000007Date20261107000000.php new file mode 100644 index 0000000..5c8dca0 --- /dev/null +++ b/lib/Migration/Version000007Date20261107000000.php @@ -0,0 +1,33 @@ +getTable('ngb_snapshots'); + $changed = false; + if (!$t->hasColumn('note')) { + $t->addColumn('note', Types::STRING, ['notnull' => false, 'length' => 1000]); + $changed = true; + } + if (!$t->hasColumn('pinned')) { + $t->addColumn('pinned', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $changed = true; + } + return $changed ? $schema : null; + } +} diff --git a/lib/Repository/Catalog.php b/lib/Repository/Catalog.php index 7dae152..328cb68 100644 --- a/lib/Repository/Catalog.php +++ b/lib/Repository/Catalog.php @@ -30,6 +30,10 @@ final class Catalog { public array $trash = []; /** @var array manifest path => true, for a per-user export (users//) */ public array $userExports = []; + /** @var array snapshots the retention policy leaves alone */ + public array $pinned = []; + /** @var array snapshot id => note, also while the snapshot is in the trash */ + public array $notes = []; public string $hash = ''; public function __construct( @@ -82,6 +86,8 @@ final class Catalog { // Catalogs written before user exports existed have no such key: default to empty rather // than adopting the location's current users/ listing (that would skip verification). $this->userExports = array_fill_keys($data['userExports'] ?? [], true); + $this->pinned = array_fill_keys($data['pinned'] ?? [], true); + $this->notes = $data['notes'] ?? []; $this->anchor?->set($this->repositoryId, $this->gen, $this->hash); } @@ -93,9 +99,12 @@ final class Catalog { if ($this->backend->exists($this->path($next))) { throw new RepositoryException("Catalog generation $next was written by another process at the same time; try again"); } + // A pin ends when its snapshot leaves the list, a note when the snapshot is gone for good. + $this->pinned = array_intersect_key($this->pinned, $this->snapshots); + $this->notes = array_intersect_key($this->notes, $this->snapshots + $this->trash); $plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash, 'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash), - 'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR); + 'userExports' => array_keys($this->userExports), 'pinned' => array_keys($this->pinned), 'notes' => $this->notes], JSON_THROW_ON_ERROR); $encrypted = $this->cipher->encryptString($plain, $this->ad($next)); $stream = fopen('php://memory', 'w+b'); fwrite($stream, $encrypted); diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index a631000..32a51be 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -134,6 +134,49 @@ final class Repository { return $this->catalog->gen; } + /** @return list snapshots the retention policy leaves alone */ + public function pinnedSnapshots(): array { + return array_map('strval', array_keys($this->catalog->pinned)); + } + + /** @return array snapshot id => note */ + public function snapshotNotes(): array { + return $this->catalog->notes; + } + + public function setPinned(string $snapshotId, bool $pinned): void { + $this->requireSnapshot($snapshotId); + if (isset($this->catalog->pinned[$snapshotId]) === $pinned) { + return; + } + if ($pinned) { + $this->catalog->pinned[$snapshotId] = true; + } else { + unset($this->catalog->pinned[$snapshotId]); + } + $this->catalog->write(); + } + + /** A note kept in the catalog: unlike the label inside the snapshot, it can change afterwards. */ + public function setSnapshotNote(string $snapshotId, string $note): void { + $this->requireSnapshot($snapshotId); + if (($this->catalog->notes[$snapshotId] ?? '') === $note) { + return; + } + if ($note === '') { + unset($this->catalog->notes[$snapshotId]); + } else { + $this->catalog->notes[$snapshotId] = $note; + } + $this->catalog->write(); + } + + private function requireSnapshot(string $snapshotId): void { + if (!isset($this->catalog->snapshots[$snapshotId])) { + throw new \InvalidArgumentException('There is no snapshot ' . $snapshotId . ' on this location'); + } + } + /** Index files downloaded when this repository was opened (0 when the local cache was complete). */ public function indexDownloads(): int { return $this->index->downloaded; diff --git a/lib/Service/PruneService.php b/lib/Service/PruneService.php index 68852b9..b0f5277 100644 --- a/lib/Service/PruneService.php +++ b/lib/Service/PruneService.php @@ -11,6 +11,7 @@ use OCA\NgBackup\AppInfo\Application; use OCA\NgBackup\Db\RunMapper; use OCA\NgBackup\Db\SnapshotMapper; use OCA\NgBackup\Db\Target; +use OCA\NgBackup\Repository\Repository; use OCP\IAppConfig; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; @@ -33,6 +34,54 @@ final class PruneService { ) { } + /** Characters of a note on a snapshot (the column holds 1000) */ + public const NOTE_MAX = 500; + + /** Pin or unpin a snapshot: the retention policy leaves a pinned one alone. */ + public function setPinned(Target $target, string $snapshotId, bool $pinned): void { + $changed = $this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $pinned): bool { + $was = in_array($snapshotId, $repo->pinnedSnapshots(), true); + $repo->setPinned($snapshotId, $pinned); + return $was !== $pinned; + }); + $this->targets->syncSnapshot($target, $snapshotId, pinned: $pinned); + if ($changed) { + // Unpinning opens the way to removing it, so every administrator hears about that. + $this->alerts->securityEvent($pinned ? 'snapshot_pinned' : 'snapshot_unpinned', ['target' => $target->getName(), 'snapshot' => $snapshotId], !$pinned); + } + } + + public function setNote(Target $target, string $snapshotId, string $note): void { + $note = trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $note)); + if (!mb_check_encoding($note, 'UTF-8')) { + throw new \InvalidArgumentException('A note must be valid UTF-8'); + } + if (mb_strlen($note) > self::NOTE_MAX) { + throw new \InvalidArgumentException('A note has at most ' . self::NOTE_MAX . ' characters'); + } + $this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $note): bool { + $repo->setSnapshotNote($snapshotId, $note); + return true; + }); + $this->targets->syncSnapshot($target, $snapshotId, note: $note); + } + + /** + * Write to the catalog next to a running backup or restore, which only takes it for a moment to + * commit. Not during a cleanup, which holds a repository opened earlier and writes from that. + * + * @param callable(Repository): bool $change + */ + private function changeCatalog(Target $target, callable $change): bool { + try { + return $this->leases->with(self::lockKey($target), LeaseService::SHARED, 300, + fn (): bool => $this->leases->with(self::catalogLockKey($target), LeaseService::EXCLUSIVE, 300, + fn (): bool => $change($this->targets->repository($target)))); + } catch (LockedException) { + throw new \RuntimeException('A cleanup of ' . $target->getName() . ' or another change to its restore points is in progress; try again later'); + } + } + public function untrash(Target $target, string $snapshotId): void { $lease = $this->lockExclusive($target, 300); try { @@ -87,6 +136,7 @@ final class PruneService { $s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now'))); $s->setFiles((int)($meta['stats']['files'] ?? 0)); $s->setBytes((int)($meta['stats']['bytes'] ?? 0)); + $s->setNote($repo->snapshotNotes()[$snapshotId] ?? null); try { $this->snapshots->insert($s); } catch (\Throwable) { @@ -208,7 +258,7 @@ final class PruneService { $meta = $repo->snapshotMeta($id); $times[$id] = (int)strtotime((string)($meta['time'] ?? '@0')); } - $keep = $this->policy()->keep($times); + $keep = $this->policy()->keepWithPinned($times, $repo->pinnedSnapshots()); $forget = array_values(array_diff(array_keys($times), array_keys($keep))); $actor = $scheduled ? 'retention policy' : $this->alerts->actor(); $purged = []; diff --git a/lib/Service/RetentionPolicy.php b/lib/Service/RetentionPolicy.php index fc4b1a7..cd8be0b 100644 --- a/lib/Service/RetentionPolicy.php +++ b/lib/Service/RetentionPolicy.php @@ -28,6 +28,19 @@ final class RetentionPolicy { return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly]; } + /** + * keep() for the snapshots that are not pinned, plus the pinned ones. A pinned snapshot does not + * take the place of a daily, weekly or monthly one. + * + * @param array $snapshots id => unix time + * @param list $pinned + * @return array> + */ + public function keepWithPinned(array $snapshots, array $pinned): array { + $pins = array_intersect_key($snapshots, array_flip($pinned)); + return $this->keep(array_diff_key($snapshots, $pins)) + array_fill_keys(array_keys($pins), ['pinned']); + } + /** * @param array $snapshots id => unix time * @return array> id => reasons it is kept (ids not in the result are removed) diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index 4fd4922..b30308a 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -208,7 +208,25 @@ final class TargetService { return $result; } + /** Update the local copy of a snapshot's note or pin, if the snapshot is listed here at all. */ + public function syncSnapshot(Target $target, string $snapshotId, ?string $note = null, ?bool $pinned = null): void { + try { + $s = $this->snapshots->findOne($target->getId(), $snapshotId); + } catch (DoesNotExistException) { + return; + } + if ($note !== null) { + $s->setNote($note !== '' ? $note : null); + } + if ($pinned !== null) { + $s->setPinned($pinned); + } + $this->snapshots->update($s); + } + private function adoptSnapshots(Target $target, Repository $repo): void { + $notes = $repo->snapshotNotes(); + $pinned = array_flip($repo->pinnedSnapshots()); foreach ($repo->listSnapshots() as $snapshotId) { try { $this->snapshots->findOne($target->getId(), $snapshotId); @@ -227,6 +245,8 @@ final class TargetService { $s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now'))); $s->setFiles((int)($meta['stats']['files'] ?? 0)); $s->setBytes((int)($meta['stats']['bytes'] ?? 0)); + $s->setNote($notes[$snapshotId] ?? null); + $s->setPinned(isset($pinned[$snapshotId])); $this->snapshots->insert($s); } catch (\Throwable $e) { Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]); diff --git a/psalm-baseline.xml b/psalm-baseline.xml index d6ad913..c8bf83b 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -300,6 +300,14 @@ + + + + + + + + diff --git a/src/api.js b/src/api.js index 85b391b..63dabbd 100644 --- a/src/api.js +++ b/src/api.js @@ -36,6 +36,8 @@ export default { tick: () => call('post', '/tick'), setSchedule: (time, timezone) => call('put', '/schedule', { time, timezone }), snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`), + setSnapshotNote: (targetId, snapshotId, note) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/note`, { note }), + setSnapshotPinned: (targetId, snapshotId, pinned) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/pin`, { pinned }), browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }), startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }), } diff --git a/src/components/Backups.vue b/src/components/Backups.vue index 5866402..8a2feea 100644 --- a/src/components/Backups.vue +++ b/src/components/Backups.vue @@ -37,7 +37,7 @@ {{ formatDateTime(run.startedAt) }} {{ targetName(run.targetId) }} {{ run.kind }} - {{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }} + {{ run.snapshot ? (run.snapshot.pinned ? '📌 ' : '') + formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') + (run.snapshot.note ? ' – ' + run.snapshot.note : '') : '' }} {{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }} {{ run.stats?.files?.files ?? run.stats?.restored ?? '' }} {{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }} diff --git a/src/components/RestoreBrowser.vue b/src/components/RestoreBrowser.vue index 6a2d612..da8688a 100644 --- a/src/components/RestoreBrowser.vue +++ b/src/components/RestoreBrowser.vue @@ -11,6 +11,13 @@