diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c976d..8cd6191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ instead carried by the app description, the git tag, the release, and this file. ## [Unreleased] ### Added +- Progress of a running backup in the web interface and `occ backup:status`, e.g. + "running (files) 68%". The total is the size of the previous snapshot; for a first backup it + is estimated from Nextcloud's file cache (users' files, versions and trash) and shown as + "~68%". - Edit a backup location (web interface and `occ backup:target:edit`): change the password, host, folder, connection type or name without losing its history. Before this the only option was a new location, which starts an empty repository and, with a retention lock at the provider, diff --git a/lib/Command/Status.php b/lib/Command/Status.php index 509e03e..9e637ac 100644 --- a/lib/Command/Status.php +++ b/lib/Command/Status.php @@ -44,7 +44,7 @@ final class Status extends Command { $rows = []; foreach ($this->runs->findRecent(10) 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() . ')' : ''), + $rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . self::percent($r) . ')' : ''), date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '', $s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')]; } @@ -53,4 +53,10 @@ final class Status extends Command { } return 0; } + + /** " 68%", or " ~68%" when estimated from the file cache; empty without an estimate */ + 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)); + } } diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index 63b375f..26cd430 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -305,6 +305,6 @@ 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)]; + 'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r)]; } } diff --git a/lib/Job/BackupRun.php b/lib/Job/BackupRun.php index 7edb3f4..5b6a300 100644 --- a/lib/Job/BackupRun.php +++ b/lib/Job/BackupRun.php @@ -160,6 +160,38 @@ final class BackupRun { return $state; } + /** + * Progress of the files phase as a fraction (at most 0.99 until it is done), or null without an + * estimate. Bytes of a large file that is still being read count too. + * + * @return array{fraction: float, approximate: bool}|null + */ + public static function progress(array $state): ?array { + $estimate = $state['estimate'] ?? null; + if (($state['phase'] ?? null) !== 'files' || !is_array($estimate) || ($estimate['bytes'] ?? 0) <= 0) { + return null; + } + $done = (int)($state['stats']['bytes'] ?? 0) + (int)($state['cur']['offset'] ?? 0); + return ['fraction' => min(0.99, $done / (int)$estimate['bytes']), 'approximate' => (bool)($estimate['approximate'] ?? true)]; + } + + /** + * progress() of a run, while it is in its files phase. + * + * @return array{fraction: float, approximate: bool}|null + */ + public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array { + if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || $run->getPhase() !== 'files') { + return null; + } + try { + $state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + return is_array($state['files'] ?? null) ? self::progress($state['files']) : null; + } + /** * A file or directory that could not be read: it is missing from this snapshot. Remembered (first * 1000, deduplicated across steps) so the run can report it instead of silently succeeding. diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index 702cf78..df68e11 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -17,6 +17,7 @@ use OCA\NgBackup\Job\BackupRun; use OCA\NgBackup\Repository\RepositoryException; use OCP\App\IAppManager; use OCP\IConfig; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; @@ -68,6 +69,46 @@ final class BackupService { } } + /** + * The expected size of the files phase, for the progress shown while it runs: the size of the + * previous snapshot, or for a first backup the users' files, versions and trash as Nextcloud's + * file cache knows them (approximate: other data in the data directory is not counted). + * + * @return array{bytes: int, approximate: bool} + */ + private function estimateBytes(Run $run, array $files): array { + if (($files['parent'] ?? null) !== null) { + try { + return ['bytes' => $this->snapshots->findOne($run->getTargetId(), (string)$files['parent'])->getBytes() ?? 0, 'approximate' => false]; + } catch (\Throwable) { + } + } + try { + $qb = $this->db->getQueryBuilder(); + $qb->select('numeric_id')->from('storages') + ->where($qb->expr()->like('id', $qb->createNamedParameter('home::%'))) + ->orWhere($qb->expr()->like('id', $qb->createNamedParameter('object::user:%'))); + $result = $qb->executeQuery(); + $storages = []; + while (($id = $result->fetchOne()) !== false) { + $storages[] = (int)$id; + } + $result->closeCursor(); + $bytes = 0; + foreach (array_chunk($storages, 500) as $chunk) { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->sum('size'))->from('filecache') + ->where($qb->expr()->in('storage', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->in('path_hash', $qb->createNamedParameter(array_map('md5', ['files', 'files_versions', 'files_trashbin']), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($qb->expr()->gt('size', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + $bytes += (int)$qb->executeQuery()->fetchOne(); + } + return ['bytes' => $bytes, 'approximate' => true]; + } catch (\Throwable) { + return ['bytes' => 0, 'approximate' => true]; + } + } + private function createRun(Target $target, string $label): Run { $running = $this->runs->findRunning($target->getId(), self::KIND_FULL); if ($running !== []) { @@ -147,6 +188,7 @@ final class BackupService { $state['dump'] = $dump; } } else { + $state['files']['estimate'] ??= $this->estimateBytes($run, $state['files']); $state['files'] = BackupRun::step($repo, $state['files'], $deadline); if ($state['files']['phase'] === 'commit') { // Catalog write under the catalog lock, from a repository opened inside it: a per-user diff --git a/src/components/Backups.vue b/src/components/Backups.vue index 3a09a28..547c9dd 100644 --- a/src/components/Backups.vue +++ b/src/components/Backups.vue @@ -49,7 +49,7 @@