Without system cron: warning and a backup that moves on while the page is open (#13); progress of the database phase; Bin tab
- The admin page and backup:status warn when background jobs do not run on a
system cron, or the cron stopped, with a link to how to set one up.
- Without a working system cron, the open admin page moves a running backup
forward through /api/tick, a short step per poll.
- The database phase shows the tables dumped ("db: 47/183 tables"); a backup
that has not moved for 15 minutes says it is waiting for background jobs.
- The trash of restore points is on a tab of its own, Bin.
- New screenshots; changelog and plan for 1.0.2.
This commit is contained in:
+24
-2
@@ -9,6 +9,7 @@ namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Format;
|
||||
use OCA\NgBackup\Service\CronHealth;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\Schedule;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
@@ -29,6 +30,7 @@ final class Status extends Command {
|
||||
private RunMapper $runs,
|
||||
private VerifyService $verify,
|
||||
private Schedule $schedule,
|
||||
private CronHealth $cron,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -40,6 +42,10 @@ final class Status extends Command {
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$zone = $this->schedule->get()['timezone'];
|
||||
$cronProblem = CronHealth::describe($this->cron->check());
|
||||
if ($cronProblem !== null) {
|
||||
$output->writeln('<error>' . $cronProblem . '</error>');
|
||||
}
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('Key: <error>not created</error> (occ backup:key:init)');
|
||||
} else {
|
||||
@@ -57,7 +63,7 @@ final class Status extends Command {
|
||||
$shown = $this->runs->findShown($all ? null : time() - self::RECENT_DAYS * 86400);
|
||||
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) . ')' : ''),
|
||||
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() === 'running' ? self::runningStatus($r) : $r->getStatus(),
|
||||
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, '…')];
|
||||
@@ -88,8 +94,24 @@ final class Status extends Command {
|
||||
return sprintf('%.1f TiB', $b);
|
||||
}
|
||||
|
||||
/** " 47/183 tables" in the database phase, " 68%" or " ~68%" in the files phase; empty without a figure */
|
||||
private static function percent(\OCA\NgBackup\Db\Run $r): string {
|
||||
$p = \OCA\NgBackup\Job\BackupRun::progressOf($r);
|
||||
return $p === null ? '' : sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
|
||||
if ($p === null) {
|
||||
return '';
|
||||
}
|
||||
if (isset($p['tables'], $p['totalTables'])) {
|
||||
return sprintf(': %d/%d tables', $p['tables'], $p['totalTables']);
|
||||
}
|
||||
return sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
|
||||
}
|
||||
|
||||
/** "running (db: 47/183 tables)", or "waiting for background jobs (last step 12 min ago)" */
|
||||
private static function runningStatus(\OCA\NgBackup\Db\Run $r): string {
|
||||
$waiting = $r->getKind() === \OCA\NgBackup\Service\BackupService::KIND_FULL ? CronHealth::waitingFor($r) : null;
|
||||
if ($waiting !== null) {
|
||||
return 'waiting for background jobs (' . $r->getPhase() . self::percent($r) . ', last step ' . CronHealth::ago($waiting) . ')';
|
||||
}
|
||||
return 'running (' . $r->getPhase() . self::percent($r) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ final class AdminApiController extends Controller {
|
||||
private \OCA\NgBackup\Service\KeySlotService $slots,
|
||||
private \OCA\NgBackup\Service\PruneService $prune,
|
||||
private \OCA\NgBackup\Service\Schedule $schedule,
|
||||
private \OCA\NgBackup\Service\CronHealth $cron,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
@@ -75,9 +76,36 @@ final class AdminApiController extends Controller {
|
||||
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
|
||||
'schedule' => $this->schedule->get()['time'],
|
||||
'scheduleTimezone' => $this->schedule->get()['timezone'],
|
||||
'cron' => $this->cron->check(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Seconds of backup work one tick() does: well within a normal web request. */
|
||||
public const TICK_SECONDS = 10.0;
|
||||
|
||||
/**
|
||||
* Move running backups forward from the open admin page, when background jobs do not run on a
|
||||
* working system cron (#13). The page calls this while a backup runs, so "Back up now" works
|
||||
* without cron as long as the page stays open; scheduled backups still need cron. With a
|
||||
* working system cron this does nothing: the background job does the work.
|
||||
*/
|
||||
public function tick(): JSONResponse {
|
||||
if ($this->cron->check()['ok']) {
|
||||
return new JSONResponse(['stepped' => 0]);
|
||||
}
|
||||
$deadline = microtime(true) + self::TICK_SECONDS;
|
||||
$stepped = 0;
|
||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
// step() returns at once when the background job (or another tab) holds the run.
|
||||
$this->backups->step($run, $deadline);
|
||||
$stepped++;
|
||||
}
|
||||
return new JSONResponse(['stepped' => $stepped]);
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
|
||||
try {
|
||||
@@ -313,6 +341,7 @@ final class AdminApiController extends Controller {
|
||||
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
||||
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
|
||||
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
|
||||
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r)];
|
||||
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r),
|
||||
'waitingFor' => $r->getKind() === BackupService::KIND_FULL ? \OCA\NgBackup\Service\CronHealth::waitingFor($r) : null];
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -176,12 +176,12 @@ final class BackupRun {
|
||||
}
|
||||
|
||||
/**
|
||||
* progress() of a run, while it is in its files phase.
|
||||
* progress() of a run in its files phase; in its database phase, the tables dumped so far.
|
||||
*
|
||||
* @return array{fraction: float, approximate: bool}|null
|
||||
* @return array{fraction: float, approximate: bool, tables?: int, totalTables?: int}|null
|
||||
*/
|
||||
public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array {
|
||||
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || $run->getPhase() !== 'files') {
|
||||
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || !in_array($run->getPhase(), ['db', 'files'], true)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
@@ -189,6 +189,14 @@ final class BackupRun {
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
if ($run->getPhase() === 'db') {
|
||||
// Tables dumped so far, as of the last step (none known before the first step ends)
|
||||
$tables = count($state['dump']['tables'] ?? []);
|
||||
return $tables > 0
|
||||
? ['fraction' => min(1.0, ($state['dump']['i'] ?? 0) / $tables), 'approximate' => false,
|
||||
'tables' => (int)($state['dump']['i'] ?? 0), 'totalTables' => $tables]
|
||||
: null;
|
||||
}
|
||||
return is_array($state['files'] ?? null) ? self::progress($state['files']) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCP\Exceptions\AppConfigTypeConflictException;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IURLGenerator;
|
||||
|
||||
/**
|
||||
* Whether Nextcloud's background jobs run on a system cron, which backups need: a backup moves
|
||||
* forward only when a background job run takes its next step (#13). Without system cron, AJAX
|
||||
* cron runs a job only now and then while someone has Nextcloud open, and webcron only as often as
|
||||
* the outside service calls it.
|
||||
*/
|
||||
final class CronHealth {
|
||||
/** A system cron that has not run for this long has stopped (it runs every 5 minutes). */
|
||||
public const STOPPED_AFTER = 3600;
|
||||
|
||||
/**
|
||||
* A running backup that took no step for this long is waiting for background jobs. A step with
|
||||
* system cron takes at most a few minutes (BackupJob) and the job runs every minute.
|
||||
*/
|
||||
public const WAITING_AFTER = 900;
|
||||
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
private IURLGenerator $urls,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* mode: ajax, webcron or cron; lastRun: the last background job run (0: never); ok: a system
|
||||
* cron that ran recently; stopped: system cron is set but has not run for STOPPED_AFTER.
|
||||
*
|
||||
* @return array{mode: string, lastRun: int, ok: bool, stopped: bool, docs: string}
|
||||
*/
|
||||
public function check(?int $now = null): array {
|
||||
$now ??= time();
|
||||
$mode = $this->coreString('backgroundjobs_mode', 'ajax');
|
||||
$lastRun = (int)$this->coreString('lastcron', '0');
|
||||
$stopped = $mode === 'cron' && $now - $lastRun > self::STOPPED_AFTER;
|
||||
return [
|
||||
'mode' => $mode,
|
||||
'lastRun' => $lastRun,
|
||||
'ok' => $mode === 'cron' && !$stopped,
|
||||
'stopped' => $stopped,
|
||||
'docs' => $this->urls->linkToDocs('admin-background-jobs'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds since a running backup took its last step, when that is long enough to say it waits
|
||||
* for background jobs; null while it moves (or when it is not running).
|
||||
*/
|
||||
public static function waitingFor(Run $run, ?int $now = null): ?int {
|
||||
$idle = ($now ?? time()) - $run->getUpdatedAt();
|
||||
return $run->getStatus() === Run::RUNNING && $idle >= self::WAITING_AFTER ? $idle : null;
|
||||
}
|
||||
|
||||
/** One line for occ and the log; null when system cron runs. */
|
||||
public static function describe(array $health, ?int $now = null): ?string {
|
||||
if ($health['ok']) {
|
||||
return null;
|
||||
}
|
||||
$last = $health['lastRun'] > 0 ? 'last ran ' . self::ago(($now ?? time()) - $health['lastRun']) : 'never ran';
|
||||
$what = $health['stopped']
|
||||
? 'Background jobs are set to system cron, but cron has not run for over an hour (' . $last . ').'
|
||||
: 'Background jobs do not run on a system cron: they are set to "' . ($health['mode'] === 'webcron' ? 'Webcron' : 'AJAX') . '" and ' . $last . '.';
|
||||
return $what . ' Backups, also the scheduled ones, need a system cron that runs every 5 minutes: ' . $health['docs'];
|
||||
}
|
||||
|
||||
/** "12 min ago" style, for durations from a minute to days */
|
||||
public static function ago(int $seconds): string {
|
||||
if ($seconds < 3600) {
|
||||
return max(1, intdiv($seconds, 60)) . ' min ago';
|
||||
}
|
||||
if ($seconds < 172800) {
|
||||
return intdiv($seconds, 3600) . ' h ago';
|
||||
}
|
||||
return intdiv($seconds, 86400) . ' days ago';
|
||||
}
|
||||
|
||||
/** Core stores these with different value types across versions: read whatever is there. */
|
||||
private function coreString(string $key, string $default): string {
|
||||
try {
|
||||
return $this->appConfig->getValueString('core', $key, $default);
|
||||
} catch (AppConfigTypeConflictException) {
|
||||
return (string)$this->appConfig->getValueInt('core', $key, (int)$default);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user