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==