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 @@
-
{{ target.name }}
- {{ target.backend }} · {{ target.path }}
+ {{ target.backend }} · {{ target.path }}{{ target.appendOnly ? ' · ' + t('ng_backup', 'append-only') : '' }}
+ {{ usageText(target) }}
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
@@ -28,6 +29,8 @@
+
{{ t('ng_backup', 'Connect and add') }}
{{ t('ng_backup', 'Cancel') }}
@@ -61,7 +64,18 @@ const backends = ref([])
const backend = ref(null)
const auth = ref(null)
const tests = reactive({})
-const form = reactive({ name: '', path: 'ng_backup', options: {} })
+const form = reactive({ name: '', path: 'ng_backup', options: {}, maxGb: '' })
+
+const gib = (b) => (b / 1073741824).toFixed(1) + ' GiB'
+function usageText(target) {
+ if (target.storedBytes === null || target.storedBytes === undefined) {
+ return target.maxBytes ? t('ng_backup', 'limit {limit}', { limit: gib(target.maxBytes) }) : t('ng_backup', 'no limit')
+ }
+ return target.maxBytes
+ ? t('ng_backup', '{used} of {limit}', { used: gib(target.storedBytes), limit: gib(target.maxBytes) })
+ : t('ng_backup', '{used} stored, no limit', { used: gib(target.storedBytes) })
+}
+const usageClass = (target) => (target.maxBytes && target.storedBytes >= 0.8 * target.maxBytes) ? 'ngb-err' : 'ngb-muted'
// files_external DefinitionParameter: type 0 text, 1 boolean, 2 password; flag 1 = optional, 4 = hidden
const toFields = (params) => Object.entries(params || {})
@@ -90,9 +104,10 @@ async function add() {
busy.value = true
error.value = ''
try {
- await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path })
+ await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
+ maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
adding.value = false
- Object.assign(form, { name: '', path: 'ng_backup', options: {} })
+ Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
emit('changed')
} catch (e) {
error.value = e.message