diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90963640..de29e554 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ needs.determine-release-type.outputs.tagname }} publish-release: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 404599f3..ae9ea489 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,8 @@ CMD ["bun", "run", "dev"] # ------------------------------ FROM oven/bun:${BUN_VERSION} AS builder +ARG APP_VERSION=dev + WORKDIR /app COPY ./package.json ./bun.lock ./ @@ -70,6 +72,9 @@ RUN bun install --frozen-lockfile COPY . . +RUN touch .env +RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env + RUN bun run build FROM base AS production diff --git a/README.md b/README.md index e6d7e9e9..2ea629ac 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed ```yaml services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: @@ -46,9 +46,13 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount ``` +> [!WARNING] +> Do not try to change the location of the bind mount `/var/lib/ironmount` on your host or store it on a network share. You will likely face permission issues and strong performance degradation. + Then, run the following command to start Ironmount: ```bash @@ -68,7 +72,7 @@ If you want to track a local directory on the same server where Ironmount is run ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: @@ -78,6 +82,7 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount + - /path/to/your/directory:/mydata ``` @@ -133,7 +138,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: @@ -143,6 +148,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount + - ~/.config/rclone:/root/.config/rclone ``` @@ -189,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped ports: @@ -197,6 +203,7 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - - /var/lib/ironmount:/var/lib/ironmount + - /var/lib/ironmount:/var/lib/ironmount:rshared ``` @@ -217,7 +224,7 @@ In order to enable this feature, you need to run Ironmount with several items sh ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: @@ -227,6 +234,7 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - - /var/lib/ironmount:/var/lib/ironmount + - /var/lib/ironmount:/var/lib/ironmount:rshared + - /run/docker/plugins:/run/docker/plugins diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 910e0fd3..7cdc0d7e 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options) => que queryKey: listSnapshotsQueryKey(options) }); +/** + * Delete a specific snapshot from a repository + */ +export const deleteSnapshotMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSnapshot({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getSnapshotDetailsQueryKey = (options: Options) => createQueryKey("getSnapshotDetails", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 3e20b8c9..c71979b4 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetErrors, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -286,6 +286,16 @@ export const listSnapshots = (options: Opt }); }; +/** + * Delete a specific snapshot from a repository + */ +export const deleteSnapshot = (options: Options) => { + return (options.client ?? client).delete({ + url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', + ...options + }); +}; + /** * Get details of a specific snapshot */ @@ -422,7 +432,7 @@ export const stopBackup = (options: Option * Manually apply retention policy to clean up old snapshots */ export const runForget = (options: Options) => { - return (options.client ?? client).post({ + return (options.client ?? client).post({ url: '/api/v1/backups/{scheduleId}/forget', ...options }); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index db35bf89..5a3de70b 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -711,30 +711,42 @@ export type ListRepositoriesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -757,30 +769,42 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; name: string; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; @@ -865,30 +889,42 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -929,6 +965,27 @@ export type ListSnapshotsResponses = { export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses]; +export type DeleteSnapshotData = { + body?: never; + path: { + name: string; + snapshotId: string; + }; + query?: never; + url: '/api/v1/repositories/{name}/snapshots/{snapshotId}'; +}; + +export type DeleteSnapshotResponses = { + /** + * Snapshot deleted successfully + */ + 200: { + message: string; + }; +}; + +export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapshotResponses]; + export type GetSnapshotDetailsData = { body?: never; path: { @@ -1079,30 +1136,42 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1280,30 +1349,42 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1462,30 +1543,42 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1611,13 +1704,6 @@ export type RunForgetData = { url: '/api/v1/backups/{scheduleId}/forget'; }; -export type RunForgetErrors = { - /** - * No retention policy configured for this schedule - */ - 400: unknown; -}; - export type RunForgetResponses = { /** * Retention policy applied successfully diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 9995ee1b..19ef8549 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -3,6 +3,7 @@ import { Link, NavLink } from "react-router"; import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, @@ -13,6 +14,7 @@ import { } from "~/client/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip"; import { cn } from "~/client/lib/utils"; +import { APP_VERSION } from "~/client/lib/version"; const items = [ { @@ -85,6 +87,15 @@ export function AppSidebar() { + +
+ {APP_VERSION} +
+
); } diff --git a/app/client/components/create-repository-form.tsx b/app/client/components/create-repository-form.tsx index f9e2ab6a..724eda8f 100644 --- a/app/client/components/create-repository-form.tsx +++ b/app/client/components/create-repository-form.tsx @@ -41,6 +41,7 @@ const defaultValuesForType = { gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, azure: { backend: "azure" as const, compressionMode: "auto" as const }, rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, + rest: { backend: "rest" as const, compressionMode: "auto" as const }, }; export const CreateRepositoryForm = ({ @@ -126,6 +127,7 @@ export const CreateRepositoryForm = ({ Cloudflare R2 Google Cloud Storage Azure Blob Storage + REST Server @@ -546,6 +548,67 @@ export const CreateRepositoryForm = ({ ))} + {watchedBackend === "rest" && ( + <> + ( + + REST Server URL + + + + URL of the REST server. + + + )} + /> + ( + + Repository Path (Optional) + + + + Path to the repository on the REST server (leave empty for root). + + + )} + /> + ( + + Username (Optional) + + + + Username for REST server authentication. + + + )} + /> + ( + + Password (Optional) + + + + Password for REST server authentication. + + + )} + /> + + )} + {mode === "update" && ( + + + ))} + + + + + + + + Delete snapshot? + + This action cannot be undone. This will permanently delete the snapshot and all its data from the + repository. + + + + Cancel + + Delete snapshot + + + + + ); }; diff --git a/app/client/lib/version.ts b/app/client/lib/version.ts new file mode 100644 index 00000000..6bc6a20c --- /dev/null +++ b/app/client/lib/version.ts @@ -0,0 +1 @@ +export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev"; diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 488de694..7f8fee50 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware]; export function meta(_: Route.MetaArgs) { return [ - { title: "Download Recovery Key" }, + { title: "Ironmount - Download Recovery Key" }, { name: "description", content: "Download your backup recovery key to ensure you can restore your data.", diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index 29d73084..11400234 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware]; export function meta(_: Route.MetaArgs) { return [ - { title: "Login" }, + { title: "Ironmount - Login" }, { name: "description", content: "Sign in to your Ironmount account.", diff --git a/app/client/modules/auth/routes/onboarding.tsx b/app/client/modules/auth/routes/onboarding.tsx index e14296c8..bb72df15 100644 --- a/app/client/modules/auth/routes/onboarding.tsx +++ b/app/client/modules/auth/routes/onboarding.tsx @@ -24,7 +24,7 @@ export const clientMiddleware = [authMiddleware]; export function meta(_: Route.MetaArgs) { return [ - { title: "Onboarding" }, + { title: "Ironmount - Onboarding" }, { name: "description", content: "Welcome to Ironmount. Create your admin account to get started.", diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index d47d0326..ee4d1d87 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -254,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Backup paths - Select which folders or files to include in the backup. If no paths are selected, the entire volume will be - backed up. + Select which folders or files to include in the backup. If no paths are selected, the entire volume will + be backed up. diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 3ccca736..6c40f3fa 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -26,10 +26,12 @@ interface Props { snapshot: Snapshot; repositoryName: string; volume?: Volume; + onDeleteSnapshot?: (snapshotId: string) => void; + isDeletingSnapshot?: boolean; } export const SnapshotFileBrowser = (props: Props) => { - const { snapshot, repositoryName, volume } = props; + const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; @@ -136,30 +138,43 @@ export const SnapshotFileBrowser = (props: Props) => { File Browser {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} - {selectedPaths.size > 0 && ( - - - - - - - {isReadOnly && ( - -

Volume is mounted as read-only.

-

Please remount with read-only disabled to restore files.

-
- )} -
- )} +
+ {selectedPaths.size > 0 && ( + + + + + + + {isReadOnly && ( + +

Volume is mounted as read-only.

+

Please remount with read-only disabled to restore files.

+
+ )} +
+ )} + {onDeleteSnapshot && ( + + )} +
diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index a6311a3f..2a0b4bad 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { redirect, useNavigate } from "react-router"; import { toast } from "sonner"; import { Button } from "~/client/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/client/components/ui/alert-dialog"; import { getBackupScheduleOptions, runBackupNowMutation, @@ -10,6 +20,7 @@ import { listSnapshotsOptions, updateBackupScheduleMutation, stopBackupMutation, + deleteSnapshotMutation, } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; import { getCronExpression } from "~/utils/utils"; @@ -29,7 +40,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Backup Job Details" }, + { title: "Ironmount - Backup Job Details" }, { name: "description", content: "View and manage backup job configuration, schedule, and snapshots.", @@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const [isEditMode, setIsEditMode] = useState(false); const formId = useId(); const [selectedSnapshotId, setSelectedSnapshotId] = useState(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [snapshotToDelete, setSnapshotToDelete] = useState(null); const { data: schedule } = useQuery({ ...getBackupScheduleOptions({ path: { scheduleId: params.id } }), @@ -110,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon }, }); + const deleteSnapshot = useMutation({ + ...deleteSnapshotMutation(), + onSuccess: () => { + setShowDeleteConfirm(false); + setSnapshotToDelete(null); + if (selectedSnapshotId === snapshotToDelete) { + setSelectedSnapshotId(undefined); + } + }, + }); + const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!schedule) return; @@ -150,6 +174,26 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon }); }; + const handleDeleteSnapshot = (snapshotId: string) => { + setSnapshotToDelete(snapshotId); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + if (snapshotToDelete) { + toast.promise( + deleteSnapshot.mutateAsync({ + path: { name: schedule.repository.name, snapshotId: snapshotToDelete }, + }), + { + loading: "Deleting snapshot...", + success: "Snapshot deleted successfully", + error: (error) => parseError(error)?.message || "Failed to delete snapshot", + }, + ); + } + }; + if (isEditMode) { return (
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon snapshot={selectedSnapshot} repositoryName={schedule.repository.name} volume={schedule.volume} + onDeleteSnapshot={handleDeleteSnapshot} + isDeletingSnapshot={deleteSnapshot.isPending} /> )} + + + + + Delete snapshot? + + This action cannot be undone. This will permanently delete the snapshot and all its data from the + repository. + + + + Cancel + + Delete snapshot + + + +
); } diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 3908fb3e..3b4b21df 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -15,7 +15,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Backup Jobs" }, + { title: "Ironmount - Backup Jobs" }, { name: "description", content: "Automate volume backups with scheduled jobs and retention policies.", diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index fee33cee..909bd866 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -24,7 +24,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Create Backup Job" }, + { title: "Ironmount - Create Backup Job" }, { name: "description", content: "Create a new automated backup job for your volumes.", diff --git a/app/client/modules/repositories/routes/create-repository.tsx b/app/client/modules/repositories/routes/create-repository.tsx index 5c30eda6..a84c54be 100644 --- a/app/client/modules/repositories/routes/create-repository.tsx +++ b/app/client/modules/repositories/routes/create-repository.tsx @@ -17,7 +17,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Create Repository" }, + { title: "Ironmount - Create Repository" }, { name: "description", content: "Create a new backup repository with encryption and compression.", diff --git a/app/client/modules/repositories/routes/repositories.tsx b/app/client/modules/repositories/routes/repositories.tsx index ecb0114e..27bfa6ca 100644 --- a/app/client/modules/repositories/routes/repositories.tsx +++ b/app/client/modules/repositories/routes/repositories.tsx @@ -20,7 +20,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Repositories" }, + { title: "Ironmount - Repositories" }, { name: "description", content: "Manage your backup repositories with encryption and compression.", diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index c18d517d..8a091dcf 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -36,7 +36,7 @@ export const handle = { export function meta({ params }: Route.MetaArgs) { return [ - { title: params.name }, + { title: `Ironmount - ${params.name}` }, { name: "description", content: "View repository configuration, status, and snapshots.", diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index 50e49b70..ccfd34ff 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -17,7 +17,7 @@ export const handle = { export function meta({ params }: Route.MetaArgs) { return [ - { title: `Snapshot ${params.snapshotId}` }, + { title: `Ironmount - Snapshot ${params.snapshotId}` }, { name: "description", content: "Browse and restore files from a backup snapshot.", diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 6b6e2b77..e5728cf1 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -30,7 +30,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Settings" }, + { title: "Ironmount - Settings" }, { name: "description", content: "Manage your account settings and preferences.", diff --git a/app/client/modules/volumes/routes/create-volume.tsx b/app/client/modules/volumes/routes/create-volume.tsx index 9930807f..cf9ad1f4 100644 --- a/app/client/modules/volumes/routes/create-volume.tsx +++ b/app/client/modules/volumes/routes/create-volume.tsx @@ -17,7 +17,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Create Volume" }, + { title: "Ironmount - Create Volume" }, { name: "description", content: "Create a new storage volume with automatic mounting and health checks.", diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx index 9f74a431..89d86b41 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -37,7 +37,7 @@ export const handle = { export function meta({ params }: Route.MetaArgs) { return [ - { title: params.name }, + { title: `Ironmount - ${params.name}` }, { name: "description", content: "View and manage volume details, configuration, and files.", diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index 131c7a52..f1c0d957 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -20,7 +20,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Volumes" }, + { title: "Ironmount - Volumes" }, { name: "description", content: "Create, manage, monitor, and automate your Docker volumes with ease.", diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts index b15815e2..945f418c 100644 --- a/app/schemas/restic.ts +++ b/app/schemas/restic.ts @@ -7,6 +7,7 @@ export const REPOSITORY_BACKENDS = { gcs: "gcs", azure: "azure", rclone: "rclone", + rest: "rest", } as const; export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS; @@ -59,12 +60,21 @@ export const rcloneRepositoryConfigSchema = type({ path: "string", }).and(baseRepositoryConfigSchema); +export const restRepositoryConfigSchema = type({ + backend: "'rest'", + url: "string", + username: "string?", + password: "string?", + path: "string?", +}).and(baseRepositoryConfigSchema); + export const repositoryConfigSchema = s3RepositoryConfigSchema .or(r2RepositoryConfigSchema) .or(localRepositoryConfigSchema) .or(gcsRepositoryConfigSchema) .or(azureRepositoryConfigSchema) - .or(rcloneRepositoryConfigSchema); + .or(rcloneRepositoryConfigSchema) + .or(restRepositoryConfigSchema); export type RepositoryConfig = typeof repositoryConfigSchema.infer; diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 1515d349..45089b20 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -19,7 +19,7 @@ const calculateNextRun = (cronExpression: string): number => { try { const interval = CronExpressionParser.parse(cronExpression, { currentDate: new Date(), - tz: "UTC", + tz: Intl.DateTimeFormat().resolvedOptions().timeZone, }); return interval.next().getTime(); diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index b6a67a4e..2cda9983 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -4,6 +4,7 @@ import { createRepositoryBody, createRepositoryDto, deleteRepositoryDto, + deleteSnapshotDto, doctorRepositoryDto, getRepositoryDto, getSnapshotDetailsDto, @@ -16,6 +17,7 @@ import { restoreSnapshotBody, restoreSnapshotDto, type DeleteRepositoryDto, + type DeleteSnapshotDto, type DoctorRepositoryDto, type GetRepositoryDto, type GetSnapshotDetailsDto, @@ -142,4 +144,11 @@ export const repositoriesController = new Hono() const result = await repositoriesService.doctorRepository(name); return c.json(result, 200); + }) + .delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => { + const { name, snapshotId } = c.req.param(); + + await repositoriesService.deleteSnapshot(name, snapshotId); + + return c.json({ message: "Snapshot deleted" }, 200); }); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index da4d35ff..88f52629 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -326,3 +326,28 @@ export const listRcloneRemotesDto = describeRoute({ }, }, }); + +/** + * Delete a snapshot + */ +export const deleteSnapshotResponse = type({ + message: "string", +}); + +export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer; + +export const deleteSnapshotDto = describeRoute({ + description: "Delete a specific snapshot from a repository", + tags: ["Repositories"], + operationId: "deleteSnapshot", + responses: { + 200: { + description: "Snapshot deleted successfully", + content: { + "application/json": { + schema: resolver(deleteSnapshotResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 80bb8e87..41a90107 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -33,6 +33,14 @@ const encryptConfig = async (config: RepositoryConfig): Promise { }; }; +const deleteSnapshot = async (name: string, snapshotId: string) => { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + await restic.deleteSnapshot(repository.config, snapshotId); +}; + export const repositoriesService = { listRepositories, createRepository, @@ -338,4 +358,5 @@ export const repositoriesService = { getSnapshotDetails, checkHealth, doctorRepository, + deleteSnapshot, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index a85757a1..33d5d349 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -84,6 +84,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => { return `azure:${config.container}:/`; case "rclone": return `rclone:${config.remote}:${config.path}`; + case "rest": { + const path = config.path ? `/${config.path}` : ""; + return `rest:${config.url}${path}`; + } default: { throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`); } @@ -133,6 +137,15 @@ const buildEnv = async (config: RepositoryConfig) => { } break; } + case "rest": { + if (config.username) { + env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username); + } + if (config.password) { + env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password); + } + break; + } } return env; @@ -441,6 +454,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: return { success: true }; }; +const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config); + + const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"]; + + const res = await $`restic ${args}`.env(env).nothrow(); + + if (res.exitCode !== 0) { + logger.error(`Restic snapshot deletion failed: ${res.stderr}`); + throw new Error(`Failed to delete snapshot: ${res.stderr}`); + } + + return { success: true }; +}; + const lsNodeSchema = type({ name: "string", type: "string", @@ -601,6 +630,7 @@ export const restic = { restore, snapshots, forget, + deleteSnapshot, unlock, ls, check, diff --git a/bun.lock b/bun.lock index 1f24ce94..2bddbf87 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,6 @@ "": { "name": "@ironmount/client", "dependencies": { - "@hono/node-server": "^1.19.6", "@hono/standard-validator": "^0.1.5", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -23,8 +22,6 @@ "@react-router/serve": "^7.9.3", "@scalar/hono-api-reference": "^0.9.24", "@tanstack/react-query": "^5.90.2", - "@tanstack/react-query-devtools": "^5.90.2", - "@tanstack/react-table": "^8.21.3", "arktype": "^2.1.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -60,6 +57,7 @@ "@hey-api/openapi-ts": "^0.87.4", "@react-router/dev": "^7.9.3", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query-devtools": "^5.90.2", "@types/bun": "^1.3.2", "@types/dockerode": "^3.3.45", "@types/node": "^24.6.2", @@ -482,10 +480,6 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="], - "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], - - "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], - "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], diff --git a/docker-compose.yml b/docker-compose.yml index 051ac1f4..20371803 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: ports: - "4096:4096" volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount - ./app:/app/app @@ -37,6 +38,7 @@ services: ports: - "4096:4096" volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount:rshared - /run/docker/plugins:/run/docker/plugins - /var/run/docker.sock:/var/run/docker.sock diff --git a/package.json b/package.json index 79eec326..bb2af0d6 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "studio": "drizzle-kit studio" }, "dependencies": { - "@hono/node-server": "^1.19.6", "@hono/standard-validator": "^0.1.5", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -36,8 +35,6 @@ "@react-router/serve": "^7.9.3", "@scalar/hono-api-reference": "^0.9.24", "@tanstack/react-query": "^5.90.2", - "@tanstack/react-query-devtools": "^5.90.2", - "@tanstack/react-table": "^8.21.3", "arktype": "^2.1.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -73,6 +70,7 @@ "@hey-api/openapi-ts": "^0.87.4", "@react-router/dev": "^7.9.3", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query-devtools": "^5.90.2", "@types/bun": "^1.3.2", "@types/dockerode": "^3.3.45", "@types/node": "^24.6.2",