diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 360b7d1..053da07 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -23,6 +23,12 @@ import { deleteRepository, getRepository, listSnapshots, + listBackupSchedules, + createBackupSchedule, + deleteBackupSchedule, + getBackupSchedule, + updateBackupSchedule, + runBackupNow, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import type { @@ -59,6 +65,16 @@ import type { DeleteRepositoryResponse, GetRepositoryData, ListSnapshotsData, + ListBackupSchedulesData, + CreateBackupScheduleData, + CreateBackupScheduleResponse, + DeleteBackupScheduleData, + DeleteBackupScheduleResponse, + GetBackupScheduleData, + UpdateBackupScheduleData, + UpdateBackupScheduleResponse, + RunBackupNowData, + RunBackupNowResponse, } from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; @@ -693,3 +709,174 @@ export const listSnapshotsOptions = (options: Options) => { queryKey: listSnapshotsQueryKey(options), }); }; + +export const listBackupSchedulesQueryKey = (options?: Options) => + createQueryKey("listBackupSchedules", options); + +/** + * List all backup schedules + */ +export const listBackupSchedulesOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await listBackupSchedules({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: listBackupSchedulesQueryKey(options), + }); +}; + +export const createBackupScheduleQueryKey = (options?: Options) => + createQueryKey("createBackupSchedule", options); + +/** + * Create a new backup schedule for a volume + */ +export const createBackupScheduleOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createBackupSchedule({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createBackupScheduleQueryKey(options), + }); +}; + +/** + * Create a new backup schedule for a volume + */ +export const createBackupScheduleMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions< + CreateBackupScheduleResponse, + DefaultError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await createBackupSchedule({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +/** + * Delete a backup schedule + */ +export const deleteBackupScheduleMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions< + DeleteBackupScheduleResponse, + DefaultError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await deleteBackupSchedule({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getBackupScheduleQueryKey = (options: Options) => + createQueryKey("getBackupSchedule", options); + +/** + * Get a backup schedule by ID + */ +export const getBackupScheduleOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getBackupSchedule({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getBackupScheduleQueryKey(options), + }); +}; + +/** + * Update a backup schedule + */ +export const updateBackupScheduleMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions< + UpdateBackupScheduleResponse, + DefaultError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await updateBackupSchedule({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const runBackupNowQueryKey = (options: Options) => createQueryKey("runBackupNow", options); + +/** + * Trigger a backup immediately for a schedule + */ +export const runBackupNowOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await runBackupNow({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: runBackupNowQueryKey(options), + }); +}; + +/** + * Trigger a backup immediately for a schedule + */ +export const runBackupNowMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await runBackupNow({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 80c88ec..d85f41f 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -54,6 +54,18 @@ import type { GetRepositoryResponses, ListSnapshotsData, ListSnapshotsResponses, + ListBackupSchedulesData, + ListBackupSchedulesResponses, + CreateBackupScheduleData, + CreateBackupScheduleResponses, + DeleteBackupScheduleData, + DeleteBackupScheduleResponses, + GetBackupScheduleData, + GetBackupScheduleResponses, + UpdateBackupScheduleData, + UpdateBackupScheduleResponses, + RunBackupNowData, + RunBackupNowResponses, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -335,3 +347,83 @@ export const listSnapshots = ( ...options, }); }; + +/** + * List all backup schedules + */ +export const listBackupSchedules = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get({ + url: "/api/v1/backups", + ...options, + }); +}; + +/** + * Create a new backup schedule for a volume + */ +export const createBackupSchedule = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).post({ + url: "/api/v1/backups", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); +}; + +/** + * Delete a backup schedule + */ +export const deleteBackupSchedule = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).delete({ + url: "/api/v1/backups/{scheduleId}", + ...options, + }); +}; + +/** + * Get a backup schedule by ID + */ +export const getBackupSchedule = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get({ + url: "/api/v1/backups/{scheduleId}", + ...options, + }); +}; + +/** + * Update a backup schedule + */ +export const updateBackupSchedule = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).patch({ + url: "/api/v1/backups/{scheduleId}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; + +/** + * Trigger a backup immediately for a schedule + */ +export const runBackupNow = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).post({ + url: "/api/v1/backups/{scheduleId}/run", + ...options, + }); +}; diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 8efa5e6..c39342b 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -804,6 +804,258 @@ export type ListSnapshotsResponses = { export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses]; +export type ListBackupSchedulesData = { + body?: never; + path?: never; + query?: never; + url: "/api/v1/backups"; +}; + +export type ListBackupSchedulesResponses = { + /** + * List of backup schedules + */ + 200: { + schedules: Array<{ + createdAt: number; + cronExpression: string; + enabled: boolean; + excludePatterns: Array; + id: number; + includePatterns: Array; + lastBackupAt: number | null; + lastBackupError: string | null; + lastBackupStatus: "error" | "success" | null; + nextBackupAt: number | null; + repositoryId: string; + repositoryName: string; + retentionPolicy: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + } | null; + updatedAt: number; + volumeId: number; + volumeName: string; + }>; + }; +}; + +export type ListBackupSchedulesResponse = ListBackupSchedulesResponses[keyof ListBackupSchedulesResponses]; + +export type CreateBackupScheduleData = { + body?: { + cronExpression: string; + enabled: boolean; + repositoryId: string; + volumeId: number; + excludePatterns?: Array; + includePatterns?: Array; + retentionPolicy?: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + }; + tags?: Array; + }; + path?: never; + query?: never; + url: "/api/v1/backups"; +}; + +export type CreateBackupScheduleResponses = { + /** + * Backup schedule created successfully + */ + 201: { + message: string; + schedule: { + createdAt: number; + cronExpression: string; + enabled: boolean; + excludePatterns: Array; + id: number; + includePatterns: Array; + lastBackupAt: number | null; + lastBackupError: string | null; + lastBackupStatus: "error" | "success" | null; + nextBackupAt: number | null; + repositoryId: string; + repositoryName: string; + retentionPolicy: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + } | null; + updatedAt: number; + volumeId: number; + volumeName: string; + }; + }; +}; + +export type CreateBackupScheduleResponse = CreateBackupScheduleResponses[keyof CreateBackupScheduleResponses]; + +export type DeleteBackupScheduleData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: "/api/v1/backups/{scheduleId}"; +}; + +export type DeleteBackupScheduleResponses = { + /** + * Backup schedule deleted successfully + */ + 200: { + message: string; + }; +}; + +export type DeleteBackupScheduleResponse = DeleteBackupScheduleResponses[keyof DeleteBackupScheduleResponses]; + +export type GetBackupScheduleData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: "/api/v1/backups/{scheduleId}"; +}; + +export type GetBackupScheduleResponses = { + /** + * Backup schedule details + */ + 200: { + schedule: { + createdAt: number; + cronExpression: string; + enabled: boolean; + excludePatterns: Array; + id: number; + includePatterns: Array; + lastBackupAt: number | null; + lastBackupError: string | null; + lastBackupStatus: "error" | "success" | null; + nextBackupAt: number | null; + repositoryId: string; + repositoryName: string; + retentionPolicy: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + } | null; + updatedAt: number; + volumeId: number; + volumeName: string; + }; + }; +}; + +export type GetBackupScheduleResponse = GetBackupScheduleResponses[keyof GetBackupScheduleResponses]; + +export type UpdateBackupScheduleData = { + body?: { + cronExpression?: string; + enabled?: boolean; + excludePatterns?: Array; + includePatterns?: Array; + repositoryId?: string; + retentionPolicy?: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + }; + tags?: Array; + }; + path: { + scheduleId: string; + }; + query?: never; + url: "/api/v1/backups/{scheduleId}"; +}; + +export type UpdateBackupScheduleResponses = { + /** + * Backup schedule updated successfully + */ + 200: { + message: string; + schedule: { + createdAt: number; + cronExpression: string; + enabled: boolean; + excludePatterns: Array; + id: number; + includePatterns: Array; + lastBackupAt: number | null; + lastBackupError: string | null; + lastBackupStatus: "error" | "success" | null; + nextBackupAt: number | null; + repositoryId: string; + repositoryName: string; + retentionPolicy: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + } | null; + updatedAt: number; + volumeId: number; + volumeName: string; + }; + }; +}; + +export type UpdateBackupScheduleResponse = UpdateBackupScheduleResponses[keyof UpdateBackupScheduleResponses]; + +export type RunBackupNowData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: "/api/v1/backups/{scheduleId}/run"; +}; + +export type RunBackupNowResponses = { + /** + * Backup started successfully + */ + 200: { + backupStarted: boolean; + message: string; + }; +}; + +export type RunBackupNowResponse = RunBackupNowResponses[keyof RunBackupNowResponses]; + export type ClientOptions = { baseUrl: "http://192.168.2.42:4096" | (string & {}); }; diff --git a/apps/server/drizzle/0008_silent_lady_bullseye.sql b/apps/server/drizzle/0008_silent_lady_bullseye.sql new file mode 100644 index 0000000..d416c97 --- /dev/null +++ b/apps/server/drizzle/0008_silent_lady_bullseye.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS `backup_schedules_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `volume_id` integer NOT NULL, + `repository_id` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `cron_expression` text NOT NULL, + `retention_policy` text, + `exclude_patterns` text DEFAULT '[]', + `include_patterns` text DEFAULT '[]', + `last_backup_at` integer, + `last_backup_status` text, + `last_backup_error` text, + `next_backup_at` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `backup_schedules_table_volume_id_unique` ON `backup_schedules_table` (`volume_id`); diff --git a/apps/server/drizzle/meta/0008_snapshot.json b/apps/server/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..2692619 --- /dev/null +++ b/apps/server/drizzle/meta/0008_snapshot.json @@ -0,0 +1,459 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6e35f329-5431-47fd-8862-8fb06b0afe4b", + "prevId": "866b1d3b-454b-4cf7-9835-a0f60d048b6e", + "tables": { + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "backup_schedules_table_volume_id_unique": { + "name": "backup_schedules_table_volume_id_unique", + "columns": [ + "volume_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": [ + "volume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 02f6133..f8840a7 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1761224911352, "tag": "0007_watery_sersi", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1761414054481, + "tag": "0008_silent_lady_bullseye", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 3fcf665..7c5361e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,7 @@ "@ironmount/schemas": "workspace:*", "@scalar/hono-api-reference": "^0.9.13", "arktype": "^2.1.23", + "cron-parser": "^5.4.0", "dockerode": "^4.0.8", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.6", diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index efa7e8a..f299533 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -58,3 +58,35 @@ export const repositoriesTable = sqliteTable("repositories_table", { }); export type Repository = typeof repositoriesTable.$inferSelect; + +export const backupSchedulesTable = sqliteTable("backup_schedules_table", { + id: int().primaryKey({ autoIncrement: true }), + volumeId: int("volume_id") + .notNull() + .unique() + .references(() => volumesTable.id, { onDelete: "cascade" }), + repositoryId: text("repository_id") + .notNull() + .references(() => repositoriesTable.id, { onDelete: "cascade" }), + enabled: int("enabled", { mode: "boolean" }).notNull().default(true), + cronExpression: text("cron_expression").notNull(), + retentionPolicy: text("retention_policy", { mode: "json" }).$type<{ + keepLast?: number; + keepHourly?: number; + keepDaily?: number; + keepWeekly?: number; + keepMonthly?: number; + keepYearly?: number; + keepWithinDuration?: string; + }>(), + excludePatterns: text("exclude_patterns", { mode: "json" }).$type().default([]), + includePatterns: text("include_patterns", { mode: "json" }).$type().default([]), + lastBackupAt: int("last_backup_at", { mode: "timestamp" }), + lastBackupStatus: text("last_backup_status").$type<"success" | "error">(), + lastBackupError: text("last_backup_error"), + nextBackupAt: int("next_backup_at", { mode: "timestamp" }), + createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), + updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), +}); + +export type BackupSchedule = typeof backupSchedulesTable.$inferSelect; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2543f30..41d622f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -11,6 +11,7 @@ import { driverController } from "./modules/driver/driver.controller"; import { startup } from "./modules/lifecycle/startup"; import { repositoriesController } from "./modules/repositories/repositories.controller"; import { volumeController } from "./modules/volumes/volume.controller"; +import { backupScheduleController } from "./modules/backups/backups.controller"; import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; @@ -39,6 +40,7 @@ const app = new Hono() .route("/api/v1/auth", authController.basePath("/api/v1")) .route("/api/v1/volumes", volumeController.use(requireAuth)) .route("/api/v1/repositories", repositoriesController.use(requireAuth)) + .route("/api/v1/backups", backupScheduleController.use(requireAuth)) .get("/assets/*", serveStatic({ root: "./assets/frontend" })) .get("*", serveStatic({ path: "./assets/frontend/index.html" })); diff --git a/apps/server/src/jobs/backup-execution.ts b/apps/server/src/jobs/backup-execution.ts new file mode 100644 index 0000000..15802b0 --- /dev/null +++ b/apps/server/src/jobs/backup-execution.ts @@ -0,0 +1,29 @@ +import { Job } from "../core/scheduler"; +import { backupsService } from "../modules/backups/backups.service"; +import { toMessage } from "../utils/errors"; +import { logger } from "../utils/logger"; + +export class BackupExecutionJob extends Job { + async run() { + logger.debug("Checking for backup schedules to execute..."); + + const scheduleIds = await backupsService.getSchedulesToExecute(); + + if (scheduleIds.length === 0) { + logger.debug("No backup schedules to execute"); + return { done: true, timestamp: new Date(), executed: 0 }; + } + + logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`); + + for (const scheduleId of scheduleIds) { + try { + await backupsService.executeBackup(scheduleId); + } catch (error) { + logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`); + } + } + + return { done: true, timestamp: new Date(), executed: scheduleIds.length }; + } +} diff --git a/apps/server/src/modules/backups/backups.controller.ts b/apps/server/src/modules/backups/backups.controller.ts new file mode 100644 index 0000000..f22380c --- /dev/null +++ b/apps/server/src/modules/backups/backups.controller.ts @@ -0,0 +1,64 @@ +import { Hono } from "hono"; +import { validator } from "hono-openapi"; +import { + createBackupScheduleBody, + createBackupScheduleDto, + deleteBackupScheduleDto, + getBackupScheduleDto, + listBackupSchedulesDto, + runBackupNowDto, + updateBackupScheduleBody, + updateBackupScheduleDto, +} from "./backups.dto"; +import { backupsService } from "./backups.service"; + +export const backupScheduleController = new Hono() + .get("/", listBackupSchedulesDto, async (c) => { + const schedules = await backupsService.listSchedules(); + + return c.json({ schedules }, 200); + }) + .get("/:scheduleId", getBackupScheduleDto, async (c) => { + const scheduleId = c.req.param("scheduleId"); + + const schedule = await backupsService.getSchedule(Number(scheduleId)); + + return c.json({ schedule }, 200); + }) + .post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => { + const body = c.req.valid("json"); + + const schedule = await backupsService.createSchedule(body); + + return c.json({ message: "Backup schedule created successfully", schedule }, 201); + }) + .patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => { + const scheduleId = c.req.param("scheduleId"); + const body = c.req.valid("json"); + + const schedule = await backupsService.updateSchedule(Number(scheduleId), body); + + return c.json({ message: "Backup schedule updated successfully", schedule }, 200); + }) + .delete("/:scheduleId", deleteBackupScheduleDto, async (c) => { + const scheduleId = c.req.param("scheduleId"); + + await backupsService.deleteSchedule(Number(scheduleId)); + + return c.json({ message: "Backup schedule deleted successfully" }, 200); + }) + .post("/:scheduleId/run", runBackupNowDto, async (c) => { + const scheduleId = c.req.param("scheduleId"); + + backupsService.executeBackup(Number(scheduleId)).catch((error) => { + console.error("Backup execution failed:", error); + }); + + return c.json( + { + message: "Backup started", + backupStarted: true, + }, + 200, + ); + }); diff --git a/apps/server/src/modules/backups/backups.dto.ts b/apps/server/src/modules/backups/backups.dto.ts new file mode 100644 index 0000000..a3de602 --- /dev/null +++ b/apps/server/src/modules/backups/backups.dto.ts @@ -0,0 +1,205 @@ +import { type } from "arktype"; +import { describeRoute, resolver } from "hono-openapi"; + +const retentionPolicySchema = type({ + keepLast: "number?", + keepHourly: "number?", + keepDaily: "number?", + keepWeekly: "number?", + keepMonthly: "number?", + keepYearly: "number?", + keepWithinDuration: "string?", +}); + +export type RetentionPolicy = typeof retentionPolicySchema.infer; + +const backupScheduleSchema = type({ + id: "number", + volumeId: "number", + volumeName: "string", + repositoryId: "string", + repositoryName: "string", + enabled: "boolean", + cronExpression: "string", + retentionPolicy: retentionPolicySchema.or("null"), + excludePatterns: "string[]", + includePatterns: "string[]", + lastBackupAt: "number | null", + lastBackupStatus: "'success' | 'error' | null", + lastBackupError: "string | null", + nextBackupAt: "number | null", + createdAt: "number", + updatedAt: "number", +}); + +export type BackupScheduleDto = typeof backupScheduleSchema.infer; + +/** + * List all backup schedules + */ +export const listBackupSchedulesResponse = type({ + schedules: backupScheduleSchema.array(), +}); + +export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer; + +export const listBackupSchedulesDto = describeRoute({ + description: "List all backup schedules", + tags: ["Backups"], + operationId: "listBackupSchedules", + responses: { + 200: { + description: "List of backup schedules", + content: { + "application/json": { + schema: resolver(listBackupSchedulesResponse), + }, + }, + }, + }, +}); + +/** + * Get a single backup schedule + */ +export const getBackupScheduleResponse = type({ + schedule: backupScheduleSchema, +}); + +export type GetBackupScheduleResponseDto = typeof getBackupScheduleResponse.infer; + +export const getBackupScheduleDto = describeRoute({ + description: "Get a backup schedule by ID", + tags: ["Backups"], + operationId: "getBackupSchedule", + responses: { + 200: { + description: "Backup schedule details", + content: { + "application/json": { + schema: resolver(getBackupScheduleResponse), + }, + }, + }, + }, +}); + +/** + * Create a new backup schedule + */ +export const createBackupScheduleBody = type({ + volumeId: "number", + repositoryId: "string", + enabled: "boolean", + cronExpression: "string", + retentionPolicy: retentionPolicySchema.optional(), + excludePatterns: "string[]?", + includePatterns: "string[]?", + tags: "string[]?", +}); + +export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer; + +export const createBackupScheduleResponse = type({ + message: "string", + schedule: backupScheduleSchema, +}); + +export const createBackupScheduleDto = describeRoute({ + description: "Create a new backup schedule for a volume", + operationId: "createBackupSchedule", + tags: ["Backups"], + responses: { + 201: { + description: "Backup schedule created successfully", + content: { + "application/json": { + schema: resolver(createBackupScheduleResponse), + }, + }, + }, + }, +}); + +/** + * Update a backup schedule + */ +export const updateBackupScheduleBody = type({ + repositoryId: "string?", + enabled: "boolean?", + cronExpression: "string?", + retentionPolicy: retentionPolicySchema.optional(), + excludePatterns: "string[]?", + includePatterns: "string[]?", + tags: "string[]?", +}); + +export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer; + +export const updateBackupScheduleResponse = type({ + message: "string", + schedule: backupScheduleSchema, +}); + +export const updateBackupScheduleDto = describeRoute({ + description: "Update a backup schedule", + operationId: "updateBackupSchedule", + tags: ["Backups"], + responses: { + 200: { + description: "Backup schedule updated successfully", + content: { + "application/json": { + schema: resolver(updateBackupScheduleResponse), + }, + }, + }, + }, +}); + +/** + * Delete a backup schedule + */ +export const deleteBackupScheduleResponse = type({ + message: "string", +}); + +export const deleteBackupScheduleDto = describeRoute({ + description: "Delete a backup schedule", + operationId: "deleteBackupSchedule", + tags: ["Backups"], + responses: { + 200: { + description: "Backup schedule deleted successfully", + content: { + "application/json": { + schema: resolver(deleteBackupScheduleResponse), + }, + }, + }, + }, +}); + +/** + * Run a backup immediately + */ +export const runBackupNowResponse = type({ + message: "string", + backupStarted: "boolean", +}); + +export const runBackupNowDto = describeRoute({ + description: "Trigger a backup immediately for a schedule", + operationId: "runBackupNow", + tags: ["Backups"], + responses: { + 200: { + description: "Backup started successfully", + content: { + "application/json": { + schema: resolver(runBackupNowResponse), + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts new file mode 100644 index 0000000..79ab30b --- /dev/null +++ b/apps/server/src/modules/backups/backups.service.ts @@ -0,0 +1,258 @@ +import { eq } from "drizzle-orm"; +import cron from "node-cron"; +import { CronExpressionParser } from "cron-parser"; +import { NotFoundError, BadRequestError } from "http-errors-enhanced"; +import { db } from "../../db/db"; +import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema"; +import { restic } from "../../utils/restic"; +import { logger } from "../../utils/logger"; +import { getVolumePath } from "../volumes/helpers"; +import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto"; +import { toMessage } from "../../utils/errors"; + +const calculateNextRun = (cronExpression: string): Date => { + try { + const interval = CronExpressionParser.parse(cronExpression, { + currentDate: new Date(), + tz: "UTC", + }); + + return interval.next().toDate(); + } catch (error) { + logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`); + const fallback = new Date(); + fallback.setMinutes(fallback.getMinutes() + 1); + return fallback; + } +}; + +const listSchedules = async () => { + const schedules = await db.query.backupSchedulesTable.findMany({}); + return schedules; +}; + +const getSchedule = async (scheduleId: number) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(volumesTable.id, scheduleId), + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + return schedule; +}; + +const createSchedule = async (data: CreateBackupScheduleBody) => { + if (!cron.validate(data.cronExpression)) { + throw new BadRequestError("Invalid cron expression"); + } + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.id, data.volumeId), + }); + + if (!volume) { + throw new NotFoundError("Volume not found"); + } + + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.id, data.repositoryId), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + const existingSchedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.volumeId, data.volumeId), + }); + + if (existingSchedule) { + throw new BadRequestError("Volume already has a backup schedule"); + } + + const nextBackupAt = calculateNextRun(data.cronExpression); + + const [newSchedule] = await db + .insert(backupSchedulesTable) + .values({ + volumeId: data.volumeId, + repositoryId: data.repositoryId, + enabled: data.enabled, + cronExpression: data.cronExpression, + retentionPolicy: data.retentionPolicy ?? null, + excludePatterns: data.excludePatterns ?? [], + includePatterns: data.includePatterns ?? [], + nextBackupAt: nextBackupAt, + }) + .returning(); + + if (!newSchedule) { + throw new Error("Failed to create backup schedule"); + } + + return newSchedule; +}; + +const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.id, scheduleId), + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + if (data.cronExpression && !cron.validate(data.cronExpression)) { + throw new BadRequestError("Invalid cron expression"); + } + + if (data.repositoryId) { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.id, data.repositoryId), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + } + + const cronExpression = data.cronExpression ?? schedule.cronExpression; + const nextBackupAt = data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt; + + const [updated] = await db + .update(backupSchedulesTable) + .set({ ...data, nextBackupAt, updatedAt: new Date() }) + .where(eq(backupSchedulesTable.id, scheduleId)) + .returning(); + + if (!updated) { + throw new Error("Failed to update backup schedule"); + } + + return updated; +}; + +const deleteSchedule = async (scheduleId: number) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.id, scheduleId), + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + await db.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId)); +}; + +const executeBackup = async (scheduleId: number) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.id, scheduleId), + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.id, schedule.volumeId), + }); + + if (!volume) { + throw new NotFoundError("Volume not found"); + } + + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.id, schedule.repositoryId), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + if (volume.status !== "mounted") { + throw new BadRequestError("Volume is not mounted"); + } + + logger.info(`Starting backup for volume ${volume.name} to repository ${repository.name}`); + + try { + const volumePath = getVolumePath(volume.name); + + const backupOptions: { + exclude?: string[]; + include?: string[]; + tags?: string[]; + } = {}; + + if (schedule.excludePatterns && schedule.excludePatterns.length > 0) { + backupOptions.exclude = schedule.excludePatterns; + } + + if (schedule.includePatterns && schedule.includePatterns.length > 0) { + backupOptions.include = schedule.includePatterns; + } + + await restic.backup(repository.config, volumePath, backupOptions); + + if (schedule.retentionPolicy) { + await restic.forget(repository.config, schedule.retentionPolicy); + } + + const nextBackupAt = calculateNextRun(schedule.cronExpression); + await db + .update(backupSchedulesTable) + .set({ + lastBackupAt: new Date(), + lastBackupStatus: "success", + lastBackupError: null, + nextBackupAt: nextBackupAt, + updatedAt: new Date(), + }) + .where(eq(backupSchedulesTable.id, scheduleId)); + + logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`); + } catch (error) { + logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`); + + await db + .update(backupSchedulesTable) + .set({ + lastBackupAt: new Date(), + lastBackupStatus: "error", + lastBackupError: toMessage(error), + updatedAt: new Date(), + }) + .where(eq(backupSchedulesTable.id, scheduleId)); + + throw error; + } +}; + +const getSchedulesToExecute = async () => { + const now = new Date(); + const schedules = await db.query.backupSchedulesTable.findMany({ + where: eq(backupSchedulesTable.enabled, true), + }); + + const schedulesToRun: number[] = []; + + for (const schedule of schedules) { + if (!schedule.nextBackupAt || schedule.nextBackupAt <= now) { + schedulesToRun.push(schedule.id); + } + } + + return schedulesToRun; +}; + +export const backupsService = { + listSchedules, + getSchedule, + createSchedule, + updateSchedule, + deleteSchedule, + executeBackup, + getSchedulesToExecute, +}; diff --git a/apps/server/src/modules/lifecycle/startup.ts b/apps/server/src/modules/lifecycle/startup.ts index 227fa4d..a903fcb 100644 --- a/apps/server/src/modules/lifecycle/startup.ts +++ b/apps/server/src/modules/lifecycle/startup.ts @@ -7,6 +7,7 @@ import { restic } from "../../utils/restic"; import { volumeService } from "../volumes/volume.service"; import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling"; import { VolumeHealthCheckJob } from "../../jobs/healthchecks"; +import { BackupExecutionJob } from "../../jobs/backup-execution"; export const startup = async () => { await Scheduler.start(); @@ -30,4 +31,5 @@ export const startup = async () => { Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *"); Scheduler.build(VolumeHealthCheckJob).schedule("* * * * *"); + Scheduler.build(BackupExecutionJob).schedule("* * * * *"); }; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 52c7bff..1c29a36 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -7,6 +7,7 @@ import { $ } from "bun"; import { RESTIC_PASS_FILE } from "../core/constants"; import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; +import type { RetentionPolicy } from "../modules/backups/backups.dto"; const backupOutputSchema = type({ message_type: "'summary'", @@ -110,18 +111,44 @@ const init = async (config: RepositoryConfig) => { return { success: true, error: null }; }; -const backup = async (config: RepositoryConfig, source: string) => { +const backup = async ( + config: RepositoryConfig, + source: string, + options?: { exclude?: string[]; include?: string[] }, +) => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); - const res = await $`restic --repo ${repoUrl} backup ${source} --json`.env(env).nothrow(); + const args: string[] = ["--repo", repoUrl, "backup", source]; + + if (options?.exclude && options.exclude.length > 0) { + for (const pattern of options.exclude) { + args.push("--exclude", pattern); + } + } + + if (options?.include && options.include.length > 0) { + for (const pattern of options.include) { + args.push("--include", pattern); + } + } + + args.push("--json"); + + const res = await $`restic ${args}`.env(env).nothrow(); if (res.exitCode !== 0) { logger.error(`Restic backup failed: ${res.stderr}`); throw new Error(`Restic backup failed: ${res.stderr}`); } - const result = backupOutputSchema(res.json()); + // res is a succession of JSON objects, we need to parse the last one which contains the summary + const stdout = res.text(); + const outputLines = stdout.trim().split("\n"); + const lastLine = outputLines[outputLines.length - 1]; + const resSummary = JSON.parse(lastLine ?? "{}"); + + const result = backupOutputSchema(resSummary); if (result instanceof type.errors) { logger.error(`Restic backup output validation failed: ${result}`); @@ -166,10 +193,53 @@ const snapshots = async (config: RepositoryConfig) => { return result; }; +const forget = async (config: RepositoryConfig, options: RetentionPolicy) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config); + + const args: string[] = ["--repo", repoUrl, "forget"]; + + if (options.keepLast) { + args.push("--keep-last", String(options.keepLast)); + } + if (options.keepHourly) { + args.push("--keep-hourly", String(options.keepHourly)); + } + if (options.keepDaily) { + args.push("--keep-daily", String(options.keepDaily)); + } + if (options.keepWeekly) { + args.push("--keep-weekly", String(options.keepWeekly)); + } + if (options.keepMonthly) { + args.push("--keep-monthly", String(options.keepMonthly)); + } + if (options.keepYearly) { + args.push("--keep-yearly", String(options.keepYearly)); + } + if (options.keepWithinDuration) { + args.push("--keep-within-duration", options.keepWithinDuration); + } + + args.push("--prune"); + args.push("--json"); + + const res = await $`restic ${args}`.env(env).nothrow(); + + if (res.exitCode !== 0) { + logger.error(`Restic forget failed: ${res.stderr}`); + throw new Error(`Restic forget failed: ${res.stderr}`); + } + + logger.info("Restic forget completed successfully"); + return { success: true }; +}; + export const restic = { ensurePassfile, init, backup, restore, snapshots, + forget, }; diff --git a/bun.lock b/bun.lock index b472a67..e28d3fe 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "@ironmount/schemas": "workspace:*", "@scalar/hono-api-reference": "^0.9.13", "arktype": "^2.1.23", + "cron-parser": "^5.4.0", "dockerode": "^4.0.8", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.6", @@ -699,6 +700,8 @@ "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + "cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -1015,6 +1018,8 @@ "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], "make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="],