diff --git a/package.json b/package.json index 3145128..71f10bf 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "next": "14.2.35", "next-auth": "^4.24.8", "next-themes": "^0.3.0", + "cron-parser": "^4.9.0", "node-schedule": "^2.1.1", "otpauth": "^9.3.4", "prisma": "7.1.0", diff --git a/src/__tests__/server/utils/cron-check.utils.test.ts b/src/__tests__/server/utils/cron-check.utils.test.ts new file mode 100644 index 0000000..7e8ff34 --- /dev/null +++ b/src/__tests__/server/utils/cron-check.utils.test.ts @@ -0,0 +1,91 @@ +import { CronCheckUtils } from '@/server/utils/cron-check.utils'; + +const now = new Date('2026-03-06T14:00:00.000Z'); +const toleranceMs = 60 * 60 * 1000; // 60 min + +describe('CronCheckUtils.getLastScheduledTime', () => { + + it('returns null for @reboot', () => { + expect(CronCheckUtils.getLastScheduledTime('@reboot', now)).toBeNull(); + }); + + it('returns null for invalid cron expression', () => { + expect(CronCheckUtils.getLastScheduledTime('not-a-cron', now)).toBeNull(); + }); + + it('returns valid date for standard 5-field cron (every hour at :00)', () => { + const result = CronCheckUtils.getLastScheduledTime('0 * * * *', now); + expect(result).not.toBeNull(); + expect(result!.getTime()).toBeLessThanOrEqual(now.getTime()); + }); + + it('returns valid date for @daily', () => { + const result = CronCheckUtils.getLastScheduledTime('@daily', now); + expect(result).not.toBeNull(); + expect(result!.getTime()).toBeLessThanOrEqual(now.getTime()); + }); + + it('returns valid date for @hourly', () => { + const result = CronCheckUtils.getLastScheduledTime('@hourly', now); + expect(result).not.toBeNull(); + expect(result!.getTime()).toBeLessThanOrEqual(now.getTime()); + }); + + it('returns (now - interval) for @every 1h', () => { + const result = CronCheckUtils.getLastScheduledTime('@every 1h', now); + expect(result).not.toBeNull(); + expect(result!.getTime()).toBe(now.getTime() - 3_600_000); + }); + + it('returns (now - interval) for @every 30m', () => { + const result = CronCheckUtils.getLastScheduledTime('@every 30m', now); + expect(result).not.toBeNull(); + expect(result!.getTime()).toBe(now.getTime() - 30 * 60_000); + }); + + it('returns null for malformed @every expression', () => { + expect(CronCheckUtils.getLastScheduledTime('@every 5x', now)).toBeNull(); + }); + + it('returns (date - interval) for 0 3 * * *', () => { + const result = CronCheckUtils.getLastScheduledTime('0 3 * * *', new Date('2026-03-06T04:00:00.000Z')); + expect(result).not.toBeNull(); + expect(result!.getTime()).toBe(new Date('2026-03-06T03:00:00.000Z').getTime()); + }); +}); + +describe('CronCheckUtils.isBackupMissed', () => { + + it('returns undefined for @reboot (unevaluable schedule)', () => { + expect(CronCheckUtils.isBackupMissed('@reboot', new Date(), toleranceMs, now)).toBeUndefined(); + }); + + it('returns undefined for invalid cron expression', () => { + expect(CronCheckUtils.isBackupMissed('not-a-cron', new Date(), toleranceMs, now)).toBeUndefined(); + }); + + it('returns true when no backup exists and schedule can be evaluated', () => { + expect(CronCheckUtils.isBackupMissed('0 * * * *', undefined, toleranceMs, now)).toBe(true); + }); + + it('returns false when latest backup is within tolerance of last scheduled time', () => { + // Schedule: @every 1h → last scheduled = now - 1h = 13:00 UTC + // Backup created at 13:30 → within 60 min tolerance → NOT missed + const backupDate = new Date('2026-03-06T13:30:00.000Z'); + expect(CronCheckUtils.isBackupMissed('@every 1h', backupDate, toleranceMs, now)).toBe(false); + }); + + it('returns true when latest backup is older than (scheduled time - tolerance)', () => { + // Schedule: @every 1h → last scheduled = now - 1h = 13:00 UTC + // Threshold = 13:00 - 60min tolerance = 12:00 UTC + // Backup at 11:00 → older than 12:00 → MISSED + const oldBackupDate = new Date('2026-03-06T11:00:00.000Z'); + expect(CronCheckUtils.isBackupMissed('@every 1h', oldBackupDate, toleranceMs, now)).toBe(true); + }); + + it('returns false when backup was created just before threshold boundary', () => { + // last scheduled = 13:00, threshold = 12:00, backup at 12:01 → NOT missed + const backupDate = new Date(now.getTime() - 3_600_000 - 59 * 60_000); // 12:01 UTC + expect(CronCheckUtils.isBackupMissed('@every 1h', backupDate, toleranceMs, now)).toBe(false); + }); +}); diff --git a/src/app/backups/backup-status-badge.tsx b/src/app/backups/backup-status-badge.tsx new file mode 100644 index 0000000..0e7bb28 --- /dev/null +++ b/src/app/backups/backup-status-badge.tsx @@ -0,0 +1,35 @@ +'use client' + +import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; +import { TooltipTrigger } from "@radix-ui/react-tooltip"; + +interface BackupStatusBadgeProps { + missedBackup: boolean | undefined; +} + +export default function BackupStatusBadge({ missedBackup }: BackupStatusBadgeProps) { + if (missedBackup === undefined) { + return null; + } + + if (missedBackup) { + return ( + + + + Warning + + + +

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.

+
+
+ ); + } + + return ( + + OK + + ); +} diff --git a/src/app/backups/backups-table.tsx b/src/app/backups/backups-table.tsx index 6e80a40..b0029c8 100644 --- a/src/app/backups/backups-table.tsx +++ b/src/app/backups/backups-table.tsx @@ -6,12 +6,14 @@ import { formatDateTime } from "@/frontend/utils/format.utils"; import { List } from "lucide-react"; import { BackupInfoModel } from "@/shared/model/backup-info.model"; import { BackupDetailDialog } from "./backup-detail-overlay"; +import BackupStatusBadge from "./backup-status-badge"; export default function BackupsTable({ data }: { data: BackupInfoModel[] }) { return <> ], ['projectName', 'Project', true], ['appName', 'App', true], ['appId', 'App ID', false], diff --git a/src/app/backups/page.tsx b/src/app/backups/page.tsx index 0253494..27d8459 100644 --- a/src/app/backups/page.tsx +++ b/src/app/backups/page.tsx @@ -4,7 +4,7 @@ import { getAuthUserSession, isAuthorizedForBackups } from "@/server/utils/actio import PageTitle from "@/components/custom/page-title"; import backupService from "@/server/services/standalone-services/backup.service"; import BackupsTable from "./backups-table"; -import { AlertCircle } from "lucide-react" +import { AlertCircle, AlertTriangleIcon } from "lucide-react" import { Alert, AlertDescription, @@ -20,13 +20,15 @@ export default async function BackupsPage() { backupsVolumesWithoutActualBackups } = await backupService.getBackupsForAllS3Targets(); + const hasMissedBackups = backupInfoModels.some(x => x.missedBackup === true); + return (
-
+
{backupsVolumesWithoutActualBackups.length > 0 && Apps without Backup @@ -35,6 +37,13 @@ export default async function BackupsPage() { {backupsVolumesWithoutActualBackups.map((item) => `${item.volume.app.name} (mount: ${item.volume.containerMountPath})`).join(', ')} } + {hasMissedBackups && + + Missed Backups + + Some backups may not have been created for their last scheduled interval. Check the Status column below for details. + + } {backupsVolumesWithoutActualBackups.length === 0 && backupInfoModels.length === 0 && No Backups configured diff --git a/src/server/services/standalone-services/backup.service.ts b/src/server/services/standalone-services/backup.service.ts index 115e033..0d8bc5d 100644 --- a/src/server/services/standalone-services/backup.service.ts +++ b/src/server/services/standalone-services/backup.service.ts @@ -8,6 +8,7 @@ import standalonePodService from "./standalone-pod.service"; import { ListUtils } from "../../../shared/utils/list.utils"; import { S3Target } from "@prisma/client"; import { BackupEntry, BackupInfoModel } from "../../../shared/model/backup-info.model"; +import { CronCheckUtils } from "../../utils/cron-check.utils"; import databaseBackupService from "./database-backup.service"; import sharedBackupService, { s3BucketPrefix } from "./database-backup-services/shared-backup.service"; import systemBackupService from "./system-backup.service"; @@ -163,11 +164,17 @@ class BackupService { volumeId: volumeBackup?.id ?? defaultInfoIfAppWasDeleted, mountPath: volumeBackup?.volume.containerMountPath ?? defaultInfoIfAppWasDeleted, backups: backupEntries, - s3TargetId: s3Target.id + s3TargetId: s3Target.id, + cron: volumeBackup?.cron, + missedBackup: volumeBackup?.cron + ? CronCheckUtils.isBackupMissed(volumeBackup.cron, backupEntries[0]?.backupDate) + : undefined, }); } - const backupsVolumesWithoutActualBackups = volumeBackups.filter(vb => !backupInfoModels.find(x => x.backupVolumeId === vb.id)); + const backupsVolumesWithoutActualBackups = volumeBackups + .filter(vb => vb.targetId === s3Target.id) + .filter(vb => !backupInfoModels.find(x => x.backupVolumeId === vb.id)); backupInfoModels.sort((a, b) => { if (a.projectName === b.projectName) { diff --git a/src/server/utils/cron-check.utils.ts b/src/server/utils/cron-check.utils.ts new file mode 100644 index 0000000..1dc4495 --- /dev/null +++ b/src/server/utils/cron-check.utils.ts @@ -0,0 +1,80 @@ +import { Constants } from '@/shared/utils/constants'; +import { parseExpression } from 'cron-parser'; + +export class CronCheckUtils { + + /** + * Parses a `@every ` interval string (node-schedule syntax) into milliseconds. + * Supported units: ns, us, µs, ms, s, m, h + * Returns null if the format doesn't match or the unit is unknown. + */ + private static parseEveryIntervalMs(cron: string): number | null { + const match = cron.match(/^@every\s+(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)$/); + if (!match) return null; + const value = parseFloat(match[1]); + const unit = match[2]; + const unitToMs: Record = { + ns: 1 / 1_000_000, + us: 1 / 1_000, + µs: 1 / 1_000, + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, + }; + return value * unitToMs[unit]; + } + + /** + * Returns the last scheduled time for a given cron expression, relative to `now`. + * Returns null if the cron can't be evaluated (e.g. @reboot or parse error). + */ + static getLastScheduledTime(cron: string, now: Date = new Date()): Date | null { + // Handle @reboot – has no deterministic last run time + if (cron.trim() === '@reboot') { + return null; + } + + // Handle @every (node-schedule-specific, not standard cron) + if (cron.trim().startsWith('@every')) { + const intervalMs = CronCheckUtils.parseEveryIntervalMs(cron.trim()); + if (intervalMs === null || intervalMs <= 0) return null; + return new Date(now.getTime() - intervalMs); + } + + try { + const interval = parseExpression(cron, { currentDate: now, iterator: false }); + const prev = interval.prev(); + return prev.toDate(); + } catch { + return null; + } + } + + /** + * Returns true when the backup is considered "missed": + * - The last scheduled run time can be determined from the cron expression + * - AND the latest backup was created before (lastScheduledTime - tolerance) + * + * Returns false when backups are up to date. + * Returns undefined when the schedule cannot be evaluated. + */ + static isBackupMissed( + cron: string, + latestBackupDate: Date | undefined, + toleranceMs: number = Constants.TOLERATION_FOR_EXECUTED_CRON_BACKUPS_MS, + now: Date = new Date(), + ): boolean | undefined { + const lastScheduledTime = CronCheckUtils.getLastScheduledTime(cron, now); + if (lastScheduledTime === null) { + return undefined; + } + + if (!latestBackupDate) { + // No backup ever created + return true; + } + + return latestBackupDate.getTime() < lastScheduledTime.getTime() - toleranceMs; + } +} diff --git a/src/shared/model/backup-info.model.ts b/src/shared/model/backup-info.model.ts index e1b1547..ca15704 100644 --- a/src/shared/model/backup-info.model.ts +++ b/src/shared/model/backup-info.model.ts @@ -8,7 +8,9 @@ export interface BackupInfoModel { volumeId: string; mountPath: string; backupRetention: number; - backups: BackupEntry[] + backups: BackupEntry[]; + cron?: string; + missedBackup?: boolean; } export interface BackupEntry { diff --git a/src/shared/utils/constants.ts b/src/shared/utils/constants.ts index a4cf4ad..a9c9f18 100644 --- a/src/shared/utils/constants.ts +++ b/src/shared/utils/constants.ts @@ -20,4 +20,5 @@ export class Constants { static readonly DEFAULT_HEALTH_CHECK_PERIOD_SECONDS = 15; static readonly DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS = 10; static readonly DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD = 3; + static readonly TOLERATION_FOR_EXECUTED_CRON_BACKUPS_MS = 60 * 60 * 1000; // 60 minutes; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 2d0613d..d47fb81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4109,7 +4109,7 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cron-parser@^4.2.0: +cron-parser@^4.2.0, cron-parser@^4.9.0: version "4.9.0" resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==