Skip to content

Commit a9ee5ba

Browse files
committed
Show real progress for running restores
Restores showed an indeterminate "Preparing..." bar forever in the queue and dashboard lists: both compute percent from files_total, which backups pre-count but restores never had — even though the agent reports byte-accurate progress from borg extract every 3 seconds and counts extracted files from --list. Two-part fix. The restore job now gets files_total at queue time — the archive's stored file count for full restores, or a ClickHouse catalog count of the selected paths (normalized to the catalog's leading-slash form, with selections nested inside other selections deduped). And the queue rows, queue JS poll, and dashboard active-jobs percent all fall back to bytes_processed/bytes_total when no file total exists (catalog down, legacy jobs). Percent is clamped at 100 since extract's item counter includes directories while catalog counts are file-only.
1 parent abc1005 commit a9ee5ba

3 files changed

Lines changed: 85 additions & 7 deletions

File tree

src/Controllers/ClientController.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,56 @@ public function restoreSubmit(int $id): void
901901
}
902902

903903

904+
// Resolve the expected file count up front so the queue's existing
905+
// files-based progress bar works for restores — the agent counts
906+
// each extracted file from `borg extract --list` but can't know the
907+
// total itself. Full archive: the stored count. Selected paths:
908+
// count matching catalog rows. Left NULL when the catalog can't
909+
// answer (progress falls back to byte totals).
910+
$filesTotal = null;
911+
if (empty($selectedFiles)) {
912+
$filesTotal = (int) ($archive['file_count'] ?? 0) ?: null;
913+
} else {
914+
try {
915+
$ch = \BBS\Core\ClickHouse::getInstance();
916+
if ($ch->isAvailable()) {
917+
// Catalog paths always carry a leading slash (the indexer
918+
// prepends one); selections from the UI may or may not —
919+
// normalize so the prefix match works either way. Then
920+
// drop selections nested inside another selection so
921+
// overlapping prefixes aren't counted twice.
922+
$paths = array_values(array_filter(array_map(
923+
fn($p) => '/' . trim((string) $p, '/'),
924+
$selectedFiles
925+
), fn($p) => $p !== '/'));
926+
$roots = array_filter($paths, function ($p) use ($paths) {
927+
foreach ($paths as $other) {
928+
if ($other !== $p && str_starts_with($p, $other . '/')) return false;
929+
}
930+
return true;
931+
});
932+
933+
$conds = [];
934+
$params = [$id, $archive_id];
935+
foreach ($roots as $p) {
936+
$conds[] = '(path = ? OR startsWith(path, ?))';
937+
$params[] = $p;
938+
$params[] = $p . '/';
939+
}
940+
if ($conds) {
941+
$row = $ch->fetchOne(
942+
"SELECT count() AS cnt FROM file_catalog
943+
WHERE agent_id = ? AND archive_id = ? AND (" . implode(' OR ', $conds) . ")",
944+
$params
945+
);
946+
$filesTotal = (int) ($row['cnt'] ?? 0) ?: null;
947+
}
948+
}
949+
} catch (\Exception $e) {
950+
// Catalog unavailable — byte-based progress still applies
951+
}
952+
}
953+
904954
// Create restore job
905955
$jobId = $this->db->insert('backup_jobs', [
906956
'agent_id' => $id,
@@ -912,6 +962,7 @@ public function restoreSubmit(int $id): void
912962
'restore_archive_id' => $archive_id,
913963
'restore_paths' => json_encode($selectedFiles),
914964
'restore_destination' => $destination ?: null,
965+
'files_total' => $filesTotal,
915966
]);
916967

917968
$this->db->insert('server_log', [

src/Controllers/DashboardController.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ public function apiActiveJson(): void
320320

321321
$activeJobs = $this->db->fetchAll("
322322
SELECT bj.id, bj.task_type, bj.status, bj.files_total, bj.files_processed,
323+
bj.bytes_total, bj.bytes_processed,
323324
a.name as agent_name, r.name as repo_name
324325
FROM backup_jobs bj
325326
JOIN agents a ON a.id = bj.agent_id
@@ -350,9 +351,15 @@ public function apiActiveJson(): void
350351
'queuedJobs' => $queuedJobs,
351352
'errorCount' => $errorCount,
352353
'activeJobs' => array_map(static function ($j) {
353-
$pct = ((int) ($j['files_total'] ?? 0)) > 0
354-
? (int) round(((int) $j['files_processed'] / (int) $j['files_total']) * 100)
355-
: null;
354+
// File counts when the total is known (backups pre-count;
355+
// restores get it from the catalog at queue time), else byte
356+
// totals from borg extract's progress stream. Clamped at 100.
357+
$pct = null;
358+
if (((int) ($j['files_total'] ?? 0)) > 0) {
359+
$pct = min(100, (int) round(((int) $j['files_processed'] / (int) $j['files_total']) * 100));
360+
} elseif (((int) ($j['bytes_total'] ?? 0)) > 0 && ((int) ($j['bytes_processed'] ?? 0)) > 0) {
361+
$pct = min(100, (int) round(((int) $j['bytes_processed'] / (int) $j['bytes_total']) * 100));
362+
}
356363
return [
357364
'id' => (int) $j['id'],
358365
'agent_name' => $j['agent_name'],

src/Views/queue/index.php

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,23 @@ function jobTypeIcon(string $type): string {
269269
<td class="text-nowrap"><?= jobTypeIcon($job['task_type']) ?><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $job['task_type']))) ?></td>
270270
<td class="d-none d-md-table-cell"><?= number_format($job['files_total'] ?? 0) ?></td>
271271
<td>
272+
<?php
273+
// Percent from file counts when the total is known
274+
// (backups pre-count; restores get it from the
275+
// catalog at queue time), else from byte totals
276+
// (borg extract reports byte offsets). Clamped —
277+
// extract's item counter includes directories, so
278+
// it can slightly overshoot a file-only total.
279+
$pct = null;
280+
if (($job['files_total'] ?? 0) > 0) {
281+
$pct = min(100, round(($job['files_processed'] / $job['files_total']) * 100));
282+
} elseif (($job['bytes_total'] ?? 0) > 0 && ($job['bytes_processed'] ?? 0) > 0) {
283+
$pct = min(100, round(($job['bytes_processed'] / $job['bytes_total']) * 100));
284+
}
285+
?>
272286
<?php if ($job['status'] === 'queued'): ?>
273287
<span class="text-muted">Waiting</span>
274-
<?php elseif (($job['files_total'] ?? 0) > 0): ?>
275-
<?php $pct = round(($job['files_processed'] / $job['files_total']) * 100); ?>
288+
<?php elseif ($pct !== null): ?>
276289
<div class="progress queue-progress" style="height: 18px; min-width: 80px;">
277290
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width: <?= $pct ?>%">
278291
<?= $pct ?>%
@@ -455,10 +468,17 @@ function jobTypeIcon(type) {
455468

456469
function buildInProgressRow(job) {
457470
let progress;
471+
// Same fallback chain as the PHP render: file counts, then byte
472+
// totals (restores without a catalog count), clamped at 100.
473+
let pct = null;
474+
if ((job.files_total || 0) > 0) {
475+
pct = Math.min(100, Math.round((job.files_processed / job.files_total) * 100));
476+
} else if ((job.bytes_total || 0) > 0 && (job.bytes_processed || 0) > 0) {
477+
pct = Math.min(100, Math.round((job.bytes_processed / job.bytes_total) * 100));
478+
}
458479
if (job.status === 'queued') {
459480
progress = '<span class="text-muted">Waiting</span>';
460-
} else if ((job.files_total || 0) > 0) {
461-
const pct = Math.round((job.files_processed / job.files_total) * 100);
481+
} else if (pct !== null) {
462482
progress = '<div class="progress queue-progress" style="height:18px;min-width:80px;">' +
463483
'<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width:' + pct + '%">' + pct + '%</div></div>';
464484
} else if (job.status_message) {

0 commit comments

Comments
 (0)