diff --git a/internal/api/summarydashboard.go b/internal/api/summarydashboard.go index 3dfcbe7d..4cb48278 100644 --- a/internal/api/summarydashboard.go +++ b/internal/api/summarydashboard.go @@ -282,10 +282,12 @@ func (a *summaryAcc) finalize(id string, now time.Time, allowedStaleness time.Du } // A day is overdue when, at its close (clamped to now for today), the newest - // OK backup is older than the allowed staleness. Days before the first OK - // backup ever are exempt: the scheduler anchors to the first real run. A day - // where a late backup finally lands renders as backed-up, so checking only the - // day's close loses nothing visible. + // OK backup is older than the allowed staleness AND the day had no OK backup + // of its own. Days before the first OK backup ever are exempt: the scheduler + // anchors to the first real run. Requiring the day itself to be empty keeps + // sub-daily schedules honest: an hourly plan whose last run was at noon still + // backed the day up even though midnight is well past the allowed gap, so the + // day must render as backed-up, not overdue. if allowedStaleness > 0 { checkpoint := day.AddDate(0, 0, 1) if checkpoint.After(now) { @@ -295,7 +297,11 @@ func (a *summaryAcc) finalize(id string, now time.Time, allowedStaleness time.Du lastOkBackup = okBackupDates[nextOkBackup] nextOkBackup++ } - bucket.Overdue = !lastOkBackup.IsZero() && checkpoint.Sub(lastOkBackup) > allowedStaleness + // lastOkBackup <= checkpoint <= day+1, so lastOkBackup.Before(day) is + // exactly "the newest OK backup landed on an earlier day" — this day had none. + bucket.Overdue = !lastOkBackup.IsZero() && + lastOkBackup.Before(day) && + checkpoint.Sub(lastOkBackup) > allowedStaleness } history = append(history, bucket) diff --git a/internal/api/summarydashboard_test.go b/internal/api/summarydashboard_test.go index 39802b13..36edc89a 100644 --- a/internal/api/summarydashboard_test.go +++ b/internal/api/summarydashboard_test.go @@ -69,6 +69,22 @@ func TestSummaryOverdueFlags(t *testing.T) { // day closes more than 48h past it. expected: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, }, + { + name: "sub-daily schedule with a backup each day is never overdue", + okBackupDates: []time.Time{at(0, 2), at(1, 2), at(2, 2), at(3, 2), at(4, 2), at(5, 2), at(6, 2), at(7, 2), at(8, 2), at(9, 2)}, + allowedGap: 75 * time.Minute, // hourly cadence * 1.25 grace + // Every day has an OK backup at 02:00. Its close (midnight) is ~22h past + // that, far beyond the gap, but a day that backed up is never overdue. + expected: nil, + }, + { + name: "sub-daily schedule flags a fully missed day", + okBackupDates: []time.Time{at(0, 2), at(1, 2), at(3, 2), at(4, 2), at(5, 2), at(6, 2), at(7, 2), at(8, 2), at(9, 2)}, // day2 missing + allowedGap: 75 * time.Minute, + // Day 2 has no OK backup of its own and closes far past the allowed gap; + // day 3 recovers with its own backup and stays backed-up. + expected: []int{2}, + }, { name: "gap exactly at allowed staleness is not overdue", okBackupDates: []time.Time{at(0, 0), at(2, 0), at(4, 0), at(6, 0), at(8, 0)}, diff --git a/webui/src/features/operations/OperationRow.tsx b/webui/src/features/operations/OperationRow.tsx index 37bd3128..a0b8d7ce 100644 --- a/webui/src/features/operations/OperationRow.tsx +++ b/webui/src/features/operations/OperationRow.tsx @@ -32,6 +32,7 @@ import { toaster } from "../../components/ui/toaster"; import { BackupProgressEntry, + BackupProgressEntrySchema, ResticSnapshot, SnapshotSummary, } from "../../../gen/ts/v1/restic_pb"; @@ -58,7 +59,7 @@ import { import { OperationIcon } from "./OperationIcon"; import { LogView } from "../../components/common/LogView"; -import { create } from "@bufbuild/protobuf"; +import { create, toJsonString } from "@bufbuild/protobuf"; import { useReposByGuid } from "../../app/provider"; import { OperationListView } from "./OperationListView"; import * as m from "../../paraglide/messages"; @@ -737,7 +738,14 @@ const BackupOperationStatus = ({ ); } else { console.error("GOT UNEXPECTED STATUS: ", status); - return <>{m.op_row_unexpected_status() + JSON.stringify(status)}; + // Serialize via the proto helper: BackupProgressEntry carries int64 fields that + // become BigInt, which raw JSON.stringify cannot serialize (it throws). + return ( + <> + {m.op_row_unexpected_status() + + toJsonString(BackupProgressEntrySchema, status)} + + ); } };