Tell administrators about a location without a schedule

A notification when a schedule is turned off, then a reminder: once a week while another
location is backed up, every day when none is. The round trip now also checks the text of
these notifications and of the one about names, and that one location never gets two runs.
This commit is contained in:
2026-11-07 00:23:28 +01:00
parent e58b86fc48
commit 36f2427e9a
8 changed files with 97 additions and 12 deletions
+4 -1
View File
@@ -59,8 +59,11 @@ final class BackupJob extends TimedJob {
}
$this->failStaleRestores();
$this->weeklyPrune();
foreach ($this->targets->list() as $t) {
$targets = $this->targets->list();
foreach ($targets as $t) {
$this->alerts->checkStale($t);
$othersActive = array_filter($targets, static fn ($o) => $o->getId() !== $t->getId() && (string)$o->getSchedule() !== '') !== [];
$this->alerts->checkOff($t, $othersActive);
}
}
+8 -1
View File
@@ -61,13 +61,20 @@ final class Notifier implements INotifier {
: $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.'));
break;
case 'schedule_off':
$notification->setParsedSubject($l->t('Backup location %s has no schedule', [$p['target']]));
$notification->setParsedMessage(($p['others'] ?? '') === '1'
? $l->t('No backups run to this location until it gets a schedule or someone starts one. If that is what you want, you can ignore this reminder.')
: $l->t('No location has a schedule, so no backups run at all unless someone starts one. Set a schedule under Backups.'));
break;
case 'security_event':
$who = (string)($p['actor'] ?? '');
$notification->setParsedSubject(match ($p['event'] ?? '') {
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']),
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
'schedule_off' => $l->t('%1$s turned off the schedule of backup location %2$s', [$who, $p['target'] ?? '']),
'append_only_off' =>$l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']),
'snapshot_untrashed' => $l->t('%1$s restored snapshot %2$s of %3$s from the trash', [$who, $p['snapshot'] ?? '', $p['target'] ?? '']),
+24
View File
@@ -101,6 +101,30 @@ final class AlertService {
return $v === '' ? null : (int)$v;
}
/**
* Whether it is time to remind administrators of a location without a schedule: once a week
* while another location is still backed up, every day when none is. An hour of margin, so
* that the moment of the cron run does not push a reminder to the next day.
*/
public static function offReminderDue(int $last, int $now, bool $othersActive): bool {
return $now - $last >= ($othersActive ? 7 * 86400 : 86400) - 3600;
}
public function checkOff(Target $target, bool $othersActive): void {
if ((string)$target->getSchedule() !== '') {
return;
}
$key = 'off_notified_' . $target->getId();
$now = time();
$last = max((int)$target->getScheduleSince(), $this->appConfig->getValueInt(Application::APP_ID, $key, 0));
if (!self::offReminderDue($last, $now, $othersActive)) {
return;
}
$this->appConfig->setValueInt(Application::APP_ID, $key, $now);
$this->notifyAdmins('schedule_off', 'off-' . $target->getId() . '-' . date('Y-m-d', $now),
['target' => $target->getName(), 'others' => $othersActive ? '1' : '0']);
}
/** At most one notification per location per day. */
public function checkStale(Target $target): void {
if ((string)$target->getSchedule() === '') {
+9 -2
View File
@@ -35,6 +35,7 @@ final class TargetService {
private \OCA\NgBackup\Db\DbIndexCache $indexCache,
private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor,
private SnapshotMapper $snapshots,
private AlertService $alerts,
) {
}
@@ -281,10 +282,16 @@ final class TargetService {
if (!Schedule::validZone($zone)) {
throw new \InvalidArgumentException('Unknown time zone: ' . $zone);
}
$target->setSchedule(LocationSchedule::normalize($schedule));
$new = LocationSchedule::normalize($schedule);
$turnedOff = $new === '' && (string)$target->getSchedule() !== '';
$target->setSchedule($new);
$target->setScheduleZone($zone);
$target->setScheduleSince(time());
return $this->mapper->update($target);
$target = $this->mapper->update($target);
if ($turnedOff) {
$this->alerts->securityEvent('schedule_off', ['target' => $target->getName()]);
}
return $target;
}
public function setMaxBytes(Target $target, ?int $maxBytes): Target {