diff --git a/appinfo/info.xml b/appinfo/info.xml index 9b0bf02..e6e2981 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ NG Backup Encrypted, incremental backups of your Nextcloud with restore from the web interface - 0.0.17 + 0.0.18 agpl Dillard Blom NgBackup diff --git a/appinfo/routes.php b/appinfo/routes.php index de72e81..018dc23 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -20,6 +20,7 @@ return [ ['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'], ['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'], ['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'], + ['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'], ['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'], ['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'], ['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'], diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index fca4a45..c509762 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -55,7 +55,8 @@ class AdminApiController extends Controller { $targets = []; foreach ($this->targets->list() as $t) { $targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(), - 'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt()]; + 'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(), + 'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)]; } $runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15)); return new JSONResponse([ @@ -167,15 +168,25 @@ class AdminApiController extends Controller { return new JSONResponse($this->targets->availableBackends()); } - public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse { + public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse { try { $r = $this->targets->add($name, $backend, $auth, $options, $path); + if ($maxGb !== null && $maxGb > 0) { + $this->targets->setMaxBytes($r['target'], (int)round($maxGb * 1073741824)); + } } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]); } + /** Size limit in GiB; null or 0 = unlimited. */ + #[PasswordConfirmationRequired] + public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse { + $t = $this->targets->setMaxBytes($this->targets->get((string)$id), ($maxGb !== null && $maxGb > 0) ? (int)round($maxGb * 1073741824) : null); + return new JSONResponse(['maxBytes' => $t->getMaxBytes()]); + } + #[PasswordConfirmationRequired] public function removeTarget(int $id): JSONResponse { $target = $this->targets->get((string)$id); diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 2a47c45..ff34354 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -42,6 +42,10 @@ class Notifier implements INotifier { : $l->t('Backup to %s failed', [$p['target']])); $notification->setParsedMessage((string)($p['error'] ?? '')); break; + case 'quota_warning': + $notification->setParsedSubject($l->t('Backup location %1$s is at %2$s%% of its limit (%3$s of %4$s)', [$p['target'], $p['percent'], $p['used'], $p['limit']])); + $notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.')); + break; case 'backup_stale': $notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']])); $notification->setParsedMessage($l->t('Check the backup settings and the server log.')); diff --git a/lib/Service/AlertService.php b/lib/Service/AlertService.php index 3166f86..2c6a3fd 100644 --- a/lib/Service/AlertService.php +++ b/lib/Service/AlertService.php @@ -54,6 +54,36 @@ class AlertService { ['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]); } + /** + * Remember how much is stored on a location (for the settings page) and warn administrators + * once a day when it is at 80% of its limit or more. + */ + public function storedBytes(Target $target, int $bytes): void { + $this->appConfig->setValueString(Application::APP_ID, 'stored_' . $target->getId(), (string)$bytes); + $limit = $target->getMaxBytes(); + if ($limit === null || $limit <= 0 || $bytes < 0.8 * $limit) { + return; + } + $key = 'quota_notified_' . $target->getId(); + if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) { + return; + } + $this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d')); + $this->notifyAdmins('quota_warning', 'quota-' . $target->getId() . '-' . date('Y-m-d'), [ + 'target' => $target->getName(), 'percent' => (string)round($bytes / $limit * 100), + 'used' => self::gib($bytes), 'limit' => self::gib($limit)]); + } + + private static function gib(int $bytes): string { + $g = $bytes / 1073741824; + return sprintf($g < 10 ? '%.2f GiB' : '%.1f GiB', $g); + } + + public function lastStoredBytes(Target $target): ?int { + $v = $this->appConfig->getValueString(Application::APP_ID, 'stored_' . $target->getId(), ''); + return $v === '' ? null : (int)$v; + } + /** At most one notification per location per day. */ public function checkStale(Target $target): void { $last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL); diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index d195801..d6507d7 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -149,6 +149,7 @@ class BackupService { $state['files'] = BackupRun::step($repo, $state['files'], $deadline); if ($state['files']['phase'] === 'done') { $this->recordSnapshot($run, $state['files']); + $this->alerts->storedBytes($target, $repo->storedBytes()); $run->setStatus(Run::DONE); $run->setPhase('done'); $run->setFinishedAt(time()); diff --git a/src/api.js b/src/api.js index 12c4873..0c53981 100644 --- a/src/api.js +++ b/src/api.js @@ -29,6 +29,7 @@ export default { addTarget: (target) => call('post', '/targets', target), removeTarget: (id) => call('delete', `/targets/${id}`), testTarget: (id) => call('post', `/targets/${id}/test`), + setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }), startBackup: (targetId, label) => call('post', '/backups', { targetId, label }), setSchedule: (time) => call('put', '/schedule', { time }), snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`), diff --git a/src/components/Locations.vue b/src/components/Locations.vue index 8553960..1b23cf3 100644 --- a/src/components/Locations.vue +++ b/src/components/Locations.vue @@ -8,7 +8,8 @@