Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions src/__tests__/server/utils/cron-check.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
35 changes: 35 additions & 0 deletions src/app/backups/backup-status-badge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip>
<TooltipTrigger>
<span className="px-2 py-1 rounded-lg text-sm font-semibold bg-orange-100 text-orange-800">
Warning
</span>
</TooltipTrigger>
<TooltipContent>
<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>
</TooltipContent>
</Tooltip>
);
}

return (
<span className="px-2 py-1 rounded-lg text-sm font-semibold bg-green-100 text-green-800">
OK
</span>
);
}
2 changes: 2 additions & 0 deletions src/app/backups/backups-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <>
<SimpleDataTable columns={[
['projectId', 'Project ID', false],
['missedBackup', 'Status', true, (item) => <BackupStatusBadge missedBackup={item.missedBackup} />],
['projectName', 'Project', true],
['appName', 'App', true],
['appId', 'App ID', false],
Expand Down
13 changes: 11 additions & 2 deletions src/app/backups/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,13 +20,15 @@ export default async function BackupsPage() {
backupsVolumesWithoutActualBackups
} = await backupService.getBackupsForAllS3Targets();

const hasMissedBackups = backupInfoModels.some(x => x.missedBackup === true);

return (
<div className="flex-1 space-y-4 pt-6">
<PageTitle
title={'Backups'}
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.`}>
</PageTitle>
<div className="space-y-6">
<div className="space-y-4">
{backupsVolumesWithoutActualBackups.length > 0 && <Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Apps without Backup</AlertTitle>
Expand All @@ -35,6 +37,13 @@ export default async function BackupsPage() {
{backupsVolumesWithoutActualBackups.map((item) => `${item.volume.app.name} (mount: ${item.volume.containerMountPath})`).join(', ')}
</AlertDescription>
</Alert>}
{hasMissedBackups && <Alert variant="destructive" className="border-orange-400 text-orange-400">
<AlertTriangleIcon className="h-4 w-4 text-orange-400" />
<AlertTitle>Missed Backups</AlertTitle>
<AlertDescription>
Some backups may not have been created for their last scheduled interval. Check the Status column below for details.
</AlertDescription>
</Alert>}
{backupsVolumesWithoutActualBackups.length === 0 && backupInfoModels.length === 0 && <Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>No Backups configured</AlertTitle>
Expand Down
11 changes: 9 additions & 2 deletions src/server/services/standalone-services/backup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
80 changes: 80 additions & 0 deletions src/server/utils/cron-check.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Constants } from '@/shared/utils/constants';
import { parseExpression } from 'cron-parser';

export class CronCheckUtils {

/**
* Parses a `@every <N><unit>` 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<string, number> = {
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 <interval> (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;
}
}
4 changes: 3 additions & 1 deletion src/shared/model/backup-info.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export interface BackupInfoModel {
volumeId: string;
mountPath: string;
backupRetention: number;
backups: BackupEntry[]
backups: BackupEntry[];
cron?: string;
missedBackup?: boolean;
}

export interface BackupEntry {
Expand Down
1 change: 1 addition & 0 deletions src/shared/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand Down