Skip to content

Commit cc4788e

Browse files
authored
Merge pull request #69 from biersoeckli/feat/add-status-badge-to-backup-overview
Feat/add status badge to backup overview
2 parents dd3ea97 + 870c756 commit cc4788e

10 files changed

Lines changed: 234 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"next": "14.2.35",
5858
"next-auth": "^4.24.8",
5959
"next-themes": "^0.3.0",
60+
"cron-parser": "^4.9.0",
6061
"node-schedule": "^2.1.1",
6162
"otpauth": "^9.3.4",
6263
"prisma": "7.1.0",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { CronCheckUtils } from '@/server/utils/cron-check.utils';
2+
3+
const now = new Date('2026-03-06T14:00:00.000Z');
4+
const toleranceMs = 60 * 60 * 1000; // 60 min
5+
6+
describe('CronCheckUtils.getLastScheduledTime', () => {
7+
8+
it('returns null for @reboot', () => {
9+
expect(CronCheckUtils.getLastScheduledTime('@reboot', now)).toBeNull();
10+
});
11+
12+
it('returns null for invalid cron expression', () => {
13+
expect(CronCheckUtils.getLastScheduledTime('not-a-cron', now)).toBeNull();
14+
});
15+
16+
it('returns valid date for standard 5-field cron (every hour at :00)', () => {
17+
const result = CronCheckUtils.getLastScheduledTime('0 * * * *', now);
18+
expect(result).not.toBeNull();
19+
expect(result!.getTime()).toBeLessThanOrEqual(now.getTime());
20+
});
21+
22+
it('returns valid date for @daily', () => {
23+
const result = CronCheckUtils.getLastScheduledTime('@daily', now);
24+
expect(result).not.toBeNull();
25+
expect(result!.getTime()).toBeLessThanOrEqual(now.getTime());
26+
});
27+
28+
it('returns valid date for @hourly', () => {
29+
const result = CronCheckUtils.getLastScheduledTime('@hourly', now);
30+
expect(result).not.toBeNull();
31+
expect(result!.getTime()).toBeLessThanOrEqual(now.getTime());
32+
});
33+
34+
it('returns (now - interval) for @every 1h', () => {
35+
const result = CronCheckUtils.getLastScheduledTime('@every 1h', now);
36+
expect(result).not.toBeNull();
37+
expect(result!.getTime()).toBe(now.getTime() - 3_600_000);
38+
});
39+
40+
it('returns (now - interval) for @every 30m', () => {
41+
const result = CronCheckUtils.getLastScheduledTime('@every 30m', now);
42+
expect(result).not.toBeNull();
43+
expect(result!.getTime()).toBe(now.getTime() - 30 * 60_000);
44+
});
45+
46+
it('returns null for malformed @every expression', () => {
47+
expect(CronCheckUtils.getLastScheduledTime('@every 5x', now)).toBeNull();
48+
});
49+
50+
it('returns (date - interval) for 0 3 * * *', () => {
51+
const result = CronCheckUtils.getLastScheduledTime('0 3 * * *', new Date('2026-03-06T04:00:00.000Z'));
52+
expect(result).not.toBeNull();
53+
expect(result!.getTime()).toBe(new Date('2026-03-06T03:00:00.000Z').getTime());
54+
});
55+
});
56+
57+
describe('CronCheckUtils.isBackupMissed', () => {
58+
59+
it('returns undefined for @reboot (unevaluable schedule)', () => {
60+
expect(CronCheckUtils.isBackupMissed('@reboot', new Date(), toleranceMs, now)).toBeUndefined();
61+
});
62+
63+
it('returns undefined for invalid cron expression', () => {
64+
expect(CronCheckUtils.isBackupMissed('not-a-cron', new Date(), toleranceMs, now)).toBeUndefined();
65+
});
66+
67+
it('returns true when no backup exists and schedule can be evaluated', () => {
68+
expect(CronCheckUtils.isBackupMissed('0 * * * *', undefined, toleranceMs, now)).toBe(true);
69+
});
70+
71+
it('returns false when latest backup is within tolerance of last scheduled time', () => {
72+
// Schedule: @every 1h → last scheduled = now - 1h = 13:00 UTC
73+
// Backup created at 13:30 → within 60 min tolerance → NOT missed
74+
const backupDate = new Date('2026-03-06T13:30:00.000Z');
75+
expect(CronCheckUtils.isBackupMissed('@every 1h', backupDate, toleranceMs, now)).toBe(false);
76+
});
77+
78+
it('returns true when latest backup is older than (scheduled time - tolerance)', () => {
79+
// Schedule: @every 1h → last scheduled = now - 1h = 13:00 UTC
80+
// Threshold = 13:00 - 60min tolerance = 12:00 UTC
81+
// Backup at 11:00 → older than 12:00 → MISSED
82+
const oldBackupDate = new Date('2026-03-06T11:00:00.000Z');
83+
expect(CronCheckUtils.isBackupMissed('@every 1h', oldBackupDate, toleranceMs, now)).toBe(true);
84+
});
85+
86+
it('returns false when backup was created just before threshold boundary', () => {
87+
// last scheduled = 13:00, threshold = 12:00, backup at 12:01 → NOT missed
88+
const backupDate = new Date(now.getTime() - 3_600_000 - 59 * 60_000); // 12:01 UTC
89+
expect(CronCheckUtils.isBackupMissed('@every 1h', backupDate, toleranceMs, now)).toBe(false);
90+
});
91+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client'
2+
3+
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
4+
import { TooltipTrigger } from "@radix-ui/react-tooltip";
5+
6+
interface BackupStatusBadgeProps {
7+
missedBackup: boolean | undefined;
8+
}
9+
10+
export default function BackupStatusBadge({ missedBackup }: BackupStatusBadgeProps) {
11+
if (missedBackup === undefined) {
12+
return null;
13+
}
14+
15+
if (missedBackup) {
16+
return (
17+
<Tooltip>
18+
<TooltipTrigger>
19+
<span className="px-2 py-1 rounded-lg text-sm font-semibold bg-orange-100 text-orange-800">
20+
Warning
21+
</span>
22+
</TooltipTrigger>
23+
<TooltipContent>
24+
<p className="max-w-60">The backup schedule is configured, but it seems that a backup has not been created recently. This could indicate a problem with the backup process. Please check the backup configuration and logs to ensure that backups are running correctly.</p>
25+
</TooltipContent>
26+
</Tooltip>
27+
);
28+
}
29+
30+
return (
31+
<span className="px-2 py-1 rounded-lg text-sm font-semibold bg-green-100 text-green-800">
32+
OK
33+
</span>
34+
);
35+
}

src/app/backups/backups-table.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { formatDateTime } from "@/frontend/utils/format.utils";
66
import { List } from "lucide-react";
77
import { BackupInfoModel } from "@/shared/model/backup-info.model";
88
import { BackupDetailDialog } from "./backup-detail-overlay";
9+
import BackupStatusBadge from "./backup-status-badge";
910

1011
export default function BackupsTable({ data }: { data: BackupInfoModel[] }) {
1112

1213
return <>
1314
<SimpleDataTable columns={[
1415
['projectId', 'Project ID', false],
16+
['missedBackup', 'Status', true, (item) => <BackupStatusBadge missedBackup={item.missedBackup} />],
1517
['projectName', 'Project', true],
1618
['appName', 'App', true],
1719
['appId', 'App ID', false],

src/app/backups/page.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getAuthUserSession, isAuthorizedForBackups } from "@/server/utils/actio
44
import PageTitle from "@/components/custom/page-title";
55
import backupService from "@/server/services/standalone-services/backup.service";
66
import BackupsTable from "./backups-table";
7-
import { AlertCircle } from "lucide-react"
7+
import { AlertCircle, AlertTriangleIcon } from "lucide-react"
88
import {
99
Alert,
1010
AlertDescription,
@@ -20,13 +20,15 @@ export default async function BackupsPage() {
2020
backupsVolumesWithoutActualBackups
2121
} = await backupService.getBackupsForAllS3Targets();
2222

23+
const hasMissedBackups = backupInfoModels.some(x => x.missedBackup === true);
24+
2325
return (
2426
<div className="flex-1 space-y-4 pt-6">
2527
<PageTitle
2628
title={'Backups'}
2729
subtitle={`View all backups wich are stored in all S3 Target destinations. If a backup exists from an app wich doesnt exist anymore, it will be shown as orphaned.`}>
2830
</PageTitle>
29-
<div className="space-y-6">
31+
<div className="space-y-4">
3032
{backupsVolumesWithoutActualBackups.length > 0 && <Alert variant="destructive">
3133
<AlertCircle className="h-4 w-4" />
3234
<AlertTitle>Apps without Backup</AlertTitle>
@@ -35,6 +37,13 @@ export default async function BackupsPage() {
3537
{backupsVolumesWithoutActualBackups.map((item) => `${item.volume.app.name} (mount: ${item.volume.containerMountPath})`).join(', ')}
3638
</AlertDescription>
3739
</Alert>}
40+
{hasMissedBackups && <Alert variant="destructive" className="border-orange-400 text-orange-400">
41+
<AlertTriangleIcon className="h-4 w-4 text-orange-400" />
42+
<AlertTitle>Missed Backups</AlertTitle>
43+
<AlertDescription>
44+
Some backups may not have been created for their last scheduled interval. Check the Status column below for details.
45+
</AlertDescription>
46+
</Alert>}
3847
{backupsVolumesWithoutActualBackups.length === 0 && backupInfoModels.length === 0 && <Alert>
3948
<AlertCircle className="h-4 w-4" />
4049
<AlertTitle>No Backups configured</AlertTitle>

src/server/services/standalone-services/backup.service.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import standalonePodService from "./standalone-pod.service";
88
import { ListUtils } from "../../../shared/utils/list.utils";
99
import { S3Target } from "@prisma/client";
1010
import { BackupEntry, BackupInfoModel } from "../../../shared/model/backup-info.model";
11+
import { CronCheckUtils } from "../../utils/cron-check.utils";
1112
import databaseBackupService from "./database-backup.service";
1213
import sharedBackupService, { s3BucketPrefix } from "./database-backup-services/shared-backup.service";
1314
import systemBackupService from "./system-backup.service";
@@ -163,11 +164,17 @@ class BackupService {
163164
volumeId: volumeBackup?.id ?? defaultInfoIfAppWasDeleted,
164165
mountPath: volumeBackup?.volume.containerMountPath ?? defaultInfoIfAppWasDeleted,
165166
backups: backupEntries,
166-
s3TargetId: s3Target.id
167+
s3TargetId: s3Target.id,
168+
cron: volumeBackup?.cron,
169+
missedBackup: volumeBackup?.cron
170+
? CronCheckUtils.isBackupMissed(volumeBackup.cron, backupEntries[0]?.backupDate)
171+
: undefined,
167172
});
168173
}
169174

170-
const backupsVolumesWithoutActualBackups = volumeBackups.filter(vb => !backupInfoModels.find(x => x.backupVolumeId === vb.id));
175+
const backupsVolumesWithoutActualBackups = volumeBackups
176+
.filter(vb => vb.targetId === s3Target.id)
177+
.filter(vb => !backupInfoModels.find(x => x.backupVolumeId === vb.id));
171178

172179
backupInfoModels.sort((a, b) => {
173180
if (a.projectName === b.projectName) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Constants } from '@/shared/utils/constants';
2+
import { parseExpression } from 'cron-parser';
3+
4+
export class CronCheckUtils {
5+
6+
/**
7+
* Parses a `@every <N><unit>` interval string (node-schedule syntax) into milliseconds.
8+
* Supported units: ns, us, µs, ms, s, m, h
9+
* Returns null if the format doesn't match or the unit is unknown.
10+
*/
11+
private static parseEveryIntervalMs(cron: string): number | null {
12+
const match = cron.match(/^@every\s+(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)$/);
13+
if (!match) return null;
14+
const value = parseFloat(match[1]);
15+
const unit = match[2];
16+
const unitToMs: Record<string, number> = {
17+
ns: 1 / 1_000_000,
18+
us: 1 / 1_000,
19+
µs: 1 / 1_000,
20+
ms: 1,
21+
s: 1_000,
22+
m: 60_000,
23+
h: 3_600_000,
24+
};
25+
return value * unitToMs[unit];
26+
}
27+
28+
/**
29+
* Returns the last scheduled time for a given cron expression, relative to `now`.
30+
* Returns null if the cron can't be evaluated (e.g. @reboot or parse error).
31+
*/
32+
static getLastScheduledTime(cron: string, now: Date = new Date()): Date | null {
33+
// Handle @reboot – has no deterministic last run time
34+
if (cron.trim() === '@reboot') {
35+
return null;
36+
}
37+
38+
// Handle @every <interval> (node-schedule-specific, not standard cron)
39+
if (cron.trim().startsWith('@every')) {
40+
const intervalMs = CronCheckUtils.parseEveryIntervalMs(cron.trim());
41+
if (intervalMs === null || intervalMs <= 0) return null;
42+
return new Date(now.getTime() - intervalMs);
43+
}
44+
45+
try {
46+
const interval = parseExpression(cron, { currentDate: now, iterator: false });
47+
const prev = interval.prev();
48+
return prev.toDate();
49+
} catch {
50+
return null;
51+
}
52+
}
53+
54+
/**
55+
* Returns true when the backup is considered "missed":
56+
* - The last scheduled run time can be determined from the cron expression
57+
* - AND the latest backup was created before (lastScheduledTime - tolerance)
58+
*
59+
* Returns false when backups are up to date.
60+
* Returns undefined when the schedule cannot be evaluated.
61+
*/
62+
static isBackupMissed(
63+
cron: string,
64+
latestBackupDate: Date | undefined,
65+
toleranceMs: number = Constants.TOLERATION_FOR_EXECUTED_CRON_BACKUPS_MS,
66+
now: Date = new Date(),
67+
): boolean | undefined {
68+
const lastScheduledTime = CronCheckUtils.getLastScheduledTime(cron, now);
69+
if (lastScheduledTime === null) {
70+
return undefined;
71+
}
72+
73+
if (!latestBackupDate) {
74+
// No backup ever created
75+
return true;
76+
}
77+
78+
return latestBackupDate.getTime() < lastScheduledTime.getTime() - toleranceMs;
79+
}
80+
}

src/shared/model/backup-info.model.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export interface BackupInfoModel {
88
volumeId: string;
99
mountPath: string;
1010
backupRetention: number;
11-
backups: BackupEntry[]
11+
backups: BackupEntry[];
12+
cron?: string;
13+
missedBackup?: boolean;
1214
}
1315

1416
export interface BackupEntry {

src/shared/utils/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ export class Constants {
2020
static readonly DEFAULT_HEALTH_CHECK_PERIOD_SECONDS = 15;
2121
static readonly DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS = 10;
2222
static readonly DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD = 3;
23+
static readonly TOLERATION_FOR_EXECUTED_CRON_BACKUPS_MS = 60 * 60 * 1000; // 60 minutes;
2324
}

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4109,7 +4109,7 @@ create-require@^1.1.0:
41094109
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
41104110
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
41114111

4112-
cron-parser@^4.2.0:
4112+
cron-parser@^4.2.0, cron-parser@^4.9.0:
41134113
version "4.9.0"
41144114
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5"
41154115
integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==

0 commit comments

Comments
 (0)