mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
10 Commits
v0.11.1-be
...
f1096220dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1096220dd | ||
|
|
2418870284 | ||
|
|
43dfe6b190 | ||
|
|
8c4939af4e | ||
|
|
a622b5e689 | ||
|
|
6c30e7e357 | ||
|
|
043f73ea87 | ||
|
|
518700eef6 | ||
|
|
a250c442f8 | ||
|
|
6981600ad7 |
14
Dockerfile
14
Dockerfile
@@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.1"
|
|||||||
|
|
||||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache davfs2=1.6.1-r2
|
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
@@ -14,24 +14,27 @@ WORKDIR /deps
|
|||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG RESTIC_VERSION="0.18.1"
|
ARG RESTIC_VERSION="0.18.1"
|
||||||
|
ARG SHOUTRRR_VERSION="0.12.0"
|
||||||
ENV TARGETARCH=${TARGETARCH}
|
ENV TARGETARCH=${TARGETARCH}
|
||||||
|
|
||||||
RUN apk add --no-cache curl bzip2
|
RUN apk add --no-cache curl bzip2 unzip tar
|
||||||
|
|
||||||
RUN echo "Building for ${TARGETARCH}"
|
RUN echo "Building for ${TARGETARCH}"
|
||||||
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
|
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
|
||||||
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
|
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
|
||||||
curl -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
|
curl -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
|
||||||
unzip rclone-current-linux-arm64.zip; \
|
unzip rclone-current-linux-arm64.zip; \
|
||||||
|
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
|
||||||
elif [ "${TARGETARCH}" = "amd64" ]; then \
|
elif [ "${TARGETARCH}" = "amd64" ]; then \
|
||||||
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
|
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
|
||||||
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
|
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
|
||||||
unzip rclone-current-linux-amd64.zip; \
|
unzip rclone-current-linux-amd64.zip; \
|
||||||
|
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUN bzip2 -d restic.bz2 && chmod +x restic
|
RUN bzip2 -d restic.bz2 && chmod +x restic
|
||||||
RUN mv rclone-*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
RUN mv rclone-*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
||||||
|
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# DEVELOPMENT
|
# DEVELOPMENT
|
||||||
@@ -44,6 +47,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||||
|
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||||
|
|
||||||
COPY ./package.json ./bun.lock ./
|
COPY ./package.json ./bun.lock ./
|
||||||
|
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
@@ -80,10 +85,11 @@ ENV NODE_ENV="production"
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
RUN bun install --production --frozen-lockfile
|
RUN bun install --production --frozen-lockfile --verbose
|
||||||
|
|
||||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||||
|
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||||
COPY --from=builder /app/dist/client ./dist/client
|
COPY --from=builder /app/dist/client ./dist/client
|
||||||
COPY --from=builder /app/dist/server ./dist/server
|
COPY --from=builder /app/dist/server ./dist/server
|
||||||
COPY --from=builder /app/app/drizzle ./assets/migrations
|
COPY --from=builder /app/app/drizzle ./assets/migrations
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -6,7 +6,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<figure>
|
<figure>
|
||||||
<img src="https://github.com/nicotsx/zerobyte/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" />
|
<img src="https://github.com/nicotsx/zerobyte/blob/main/screenshots/backup-details.webp?raw=true" alt="Demo" />
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Backup management with scheduling and monitoring
|
Backup management with scheduling and monitoring
|
||||||
@@ -36,7 +36,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
image: ghcr.io/nicotsx/zerobyte:v0.13
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -51,7 +51,7 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Do not try to change the location of the bind mount `/var/lib/zerobyte` on your host or store it on a network share. You will likely face permission issues and strong performance degradation.
|
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
|
||||||
|
|
||||||
Then, run the following command to start Zerobyte:
|
Then, run the following command to start Zerobyte:
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
image: ghcr.io/nicotsx/zerobyte:v0.13
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -138,7 +138,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
image: ghcr.io/nicotsx/zerobyte:v0.13
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -195,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
image: ghcr.io/nicotsx/zerobyte:v0.13
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -224,7 +224,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
image: ghcr.io/nicotsx/zerobyte:v0.13
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
import { client } from '../client.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 { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateScheduleNotifications, 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';
|
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, 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, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user
|
* Register a new user
|
||||||
@@ -703,6 +703,145 @@ export const runForgetMutation = (options?: Partial<Options<RunForgetData>>): Us
|
|||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getScheduleNotificationsQueryKey = (options: Options<GetScheduleNotificationsData>) => createQueryKey("getScheduleNotifications", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const getScheduleNotificationsOptions = (options: Options<GetScheduleNotificationsData>) => queryOptions<GetScheduleNotificationsResponse, DefaultError, GetScheduleNotificationsResponse, ReturnType<typeof getScheduleNotificationsQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getScheduleNotifications({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getScheduleNotificationsQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const updateScheduleNotificationsMutation = (options?: Partial<Options<UpdateScheduleNotificationsData>>): UseMutationOptions<UpdateScheduleNotificationsResponse, DefaultError, Options<UpdateScheduleNotificationsData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<UpdateScheduleNotificationsResponse, DefaultError, Options<UpdateScheduleNotificationsData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await updateScheduleNotifications({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all notification destinations
|
||||||
|
*/
|
||||||
|
export const listNotificationDestinationsOptions = (options?: Options<ListNotificationDestinationsData>) => queryOptions<ListNotificationDestinationsResponse, DefaultError, ListNotificationDestinationsResponse, ReturnType<typeof listNotificationDestinationsQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await listNotificationDestinations({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: listNotificationDestinationsQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification destination
|
||||||
|
*/
|
||||||
|
export const createNotificationDestinationMutation = (options?: Partial<Options<CreateNotificationDestinationData>>): UseMutationOptions<CreateNotificationDestinationResponse, DefaultError, Options<CreateNotificationDestinationData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<CreateNotificationDestinationResponse, DefaultError, Options<CreateNotificationDestinationData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await createNotificationDestination({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a notification destination
|
||||||
|
*/
|
||||||
|
export const deleteNotificationDestinationMutation = (options?: Partial<Options<DeleteNotificationDestinationData>>): UseMutationOptions<DeleteNotificationDestinationResponse, DefaultError, Options<DeleteNotificationDestinationData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<DeleteNotificationDestinationResponse, DefaultError, Options<DeleteNotificationDestinationData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await deleteNotificationDestination({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNotificationDestinationQueryKey = (options: Options<GetNotificationDestinationData>) => createQueryKey("getNotificationDestination", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a notification destination by ID
|
||||||
|
*/
|
||||||
|
export const getNotificationDestinationOptions = (options: Options<GetNotificationDestinationData>) => queryOptions<GetNotificationDestinationResponse, DefaultError, GetNotificationDestinationResponse, ReturnType<typeof getNotificationDestinationQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getNotificationDestination({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getNotificationDestinationQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a notification destination
|
||||||
|
*/
|
||||||
|
export const updateNotificationDestinationMutation = (options?: Partial<Options<UpdateNotificationDestinationData>>): UseMutationOptions<UpdateNotificationDestinationResponse, DefaultError, Options<UpdateNotificationDestinationData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<UpdateNotificationDestinationResponse, DefaultError, Options<UpdateNotificationDestinationData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await updateNotificationDestination({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a notification destination by sending a test message
|
||||||
|
*/
|
||||||
|
export const testNotificationDestinationMutation = (options?: Partial<Options<TestNotificationDestinationData>>): UseMutationOptions<TestNotificationDestinationResponse, DefaultError, Options<TestNotificationDestinationData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<TestNotificationDestinationResponse, DefaultError, Options<TestNotificationDestinationData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await testNotificationDestination({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
|
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.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';
|
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, 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, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
@@ -438,6 +438,98 @@ export const runForget = <ThrowOnError extends boolean = false>(options: Options
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/backups/{scheduleId}/notifications',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/backups/{scheduleId}/notifications',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all notification destinations
|
||||||
|
*/
|
||||||
|
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/notifications/destinations',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification destination
|
||||||
|
*/
|
||||||
|
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/notifications/destinations',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a notification destination
|
||||||
|
*/
|
||||||
|
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({
|
||||||
|
url: '/api/v1/notifications/destinations/{id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a notification destination by ID
|
||||||
|
*/
|
||||||
|
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({
|
||||||
|
url: '/api/v1/notifications/destinations/{id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a notification destination
|
||||||
|
*/
|
||||||
|
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
|
||||||
|
url: '/api/v1/notifications/destinations/{id}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a notification destination by sending a test message
|
||||||
|
*/
|
||||||
|
export const testNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({
|
||||||
|
url: '/api/v1/notifications/destinations/{id}/test',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get system information including available capabilities
|
* Get system information including available capabilities
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -756,6 +756,15 @@ export type ListRepositoriesResponses = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -763,7 +772,7 @@ export type ListRepositoriesResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -823,6 +832,15 @@ export type CreateRepositoryData = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
};
|
};
|
||||||
name: string;
|
name: string;
|
||||||
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
||||||
@@ -952,6 +970,15 @@ export type GetRepositoryResponses = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -959,7 +986,7 @@ export type GetRepositoryResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1084,6 +1111,7 @@ export type RestoreSnapshotData = {
|
|||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
delete?: boolean;
|
delete?: boolean;
|
||||||
exclude?: Array<string>;
|
exclude?: Array<string>;
|
||||||
|
excludeXattr?: Array<string>;
|
||||||
include?: Array<string>;
|
include?: Array<string>;
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
@@ -1208,6 +1236,15 @@ export type ListBackupSchedulesResponses = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1215,7 +1252,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
@@ -1430,6 +1467,15 @@ export type GetBackupScheduleResponses = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1437,7 +1483,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
@@ -1633,6 +1679,15 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1640,7 +1695,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
@@ -1769,6 +1824,584 @@ export type RunForgetResponses = {
|
|||||||
|
|
||||||
export type RunForgetResponse = RunForgetResponses[keyof RunForgetResponses];
|
export type RunForgetResponse = RunForgetResponses[keyof RunForgetResponses];
|
||||||
|
|
||||||
|
export type GetScheduleNotificationsData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
scheduleId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/backups/{scheduleId}/notifications';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetScheduleNotificationsResponses = {
|
||||||
|
/**
|
||||||
|
* List of notification assignments for the schedule
|
||||||
|
*/
|
||||||
|
200: Array<{
|
||||||
|
createdAt: number;
|
||||||
|
destination: {
|
||||||
|
config: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
destinationId: number;
|
||||||
|
notifyOnFailure: boolean;
|
||||||
|
notifyOnStart: boolean;
|
||||||
|
notifyOnSuccess: boolean;
|
||||||
|
scheduleId: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetScheduleNotificationsResponse = GetScheduleNotificationsResponses[keyof GetScheduleNotificationsResponses];
|
||||||
|
|
||||||
|
export type UpdateScheduleNotificationsData = {
|
||||||
|
body?: {
|
||||||
|
assignments: Array<{
|
||||||
|
destinationId: number;
|
||||||
|
notifyOnFailure: boolean;
|
||||||
|
notifyOnStart: boolean;
|
||||||
|
notifyOnSuccess: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
scheduleId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/backups/{scheduleId}/notifications';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateScheduleNotificationsResponses = {
|
||||||
|
/**
|
||||||
|
* Notification assignments updated successfully
|
||||||
|
*/
|
||||||
|
200: Array<{
|
||||||
|
createdAt: number;
|
||||||
|
destination: {
|
||||||
|
config: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
destinationId: number;
|
||||||
|
notifyOnFailure: boolean;
|
||||||
|
notifyOnStart: boolean;
|
||||||
|
notifyOnSuccess: boolean;
|
||||||
|
scheduleId: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses];
|
||||||
|
|
||||||
|
export type ListNotificationDestinationsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/notifications/destinations';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListNotificationDestinationsResponses = {
|
||||||
|
/**
|
||||||
|
* A list of notification destinations
|
||||||
|
*/
|
||||||
|
200: Array<{
|
||||||
|
config: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
||||||
|
updatedAt: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListNotificationDestinationsResponse = ListNotificationDestinationsResponses[keyof ListNotificationDestinationsResponses];
|
||||||
|
|
||||||
|
export type CreateNotificationDestinationData = {
|
||||||
|
body?: {
|
||||||
|
config: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/notifications/destinations';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateNotificationDestinationResponses = {
|
||||||
|
/**
|
||||||
|
* Notification destination created successfully
|
||||||
|
*/
|
||||||
|
201: {
|
||||||
|
config: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateNotificationDestinationResponse = CreateNotificationDestinationResponses[keyof CreateNotificationDestinationResponses];
|
||||||
|
|
||||||
|
export type DeleteNotificationDestinationData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/notifications/destinations/{id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteNotificationDestinationErrors = {
|
||||||
|
/**
|
||||||
|
* Notification destination not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteNotificationDestinationResponses = {
|
||||||
|
/**
|
||||||
|
* Notification destination deleted successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteNotificationDestinationResponse = DeleteNotificationDestinationResponses[keyof DeleteNotificationDestinationResponses];
|
||||||
|
|
||||||
|
export type GetNotificationDestinationData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/notifications/destinations/{id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetNotificationDestinationErrors = {
|
||||||
|
/**
|
||||||
|
* Notification destination not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetNotificationDestinationResponses = {
|
||||||
|
/**
|
||||||
|
* Notification destination details
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
config: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetNotificationDestinationResponse = GetNotificationDestinationResponses[keyof GetNotificationDestinationResponses];
|
||||||
|
|
||||||
|
export type UpdateNotificationDestinationData = {
|
||||||
|
body?: {
|
||||||
|
config?: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
enabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/notifications/destinations/{id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateNotificationDestinationErrors = {
|
||||||
|
/**
|
||||||
|
* Notification destination not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateNotificationDestinationResponses = {
|
||||||
|
/**
|
||||||
|
* Notification destination updated successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
config: {
|
||||||
|
apiToken: string;
|
||||||
|
priority: -1 | 0 | 1;
|
||||||
|
type: 'pushover';
|
||||||
|
userKey: string;
|
||||||
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
from: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
to: Array<string>;
|
||||||
|
type: 'email';
|
||||||
|
useTLS: boolean;
|
||||||
|
username: string;
|
||||||
|
} | {
|
||||||
|
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||||
|
topic: string;
|
||||||
|
type: 'ntfy';
|
||||||
|
serverUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
} | {
|
||||||
|
priority: number;
|
||||||
|
serverUrl: string;
|
||||||
|
token: string;
|
||||||
|
type: 'gotify';
|
||||||
|
} | {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
type: 'custom';
|
||||||
|
} | {
|
||||||
|
type: 'discord';
|
||||||
|
webhookUrl: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
type: 'slack';
|
||||||
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
|
iconEmoji?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateNotificationDestinationResponse = UpdateNotificationDestinationResponses[keyof UpdateNotificationDestinationResponses];
|
||||||
|
|
||||||
|
export type TestNotificationDestinationData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/notifications/destinations/{id}/test';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestNotificationDestinationErrors = {
|
||||||
|
/**
|
||||||
|
* Notification destination not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
/**
|
||||||
|
* Cannot test disabled destination
|
||||||
|
*/
|
||||||
|
409: unknown;
|
||||||
|
/**
|
||||||
|
* Failed to send test notification
|
||||||
|
*/
|
||||||
|
500: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestNotificationDestinationResponses = {
|
||||||
|
/**
|
||||||
|
* Test notification sent successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestNotificationDestinationResponse = TestNotificationDestinationResponses[keyof TestNotificationDestinationResponses];
|
||||||
|
|
||||||
export type GetSystemInfoData = {
|
export type GetSystemInfoData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
||||||
import { Link, NavLink } from "react-router";
|
import { Link, NavLink } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -32,6 +32,11 @@ const items = [
|
|||||||
url: "/backups",
|
url: "/backups",
|
||||||
icon: CalendarClock,
|
icon: CalendarClock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Notifications",
|
||||||
|
url: "/notifications",
|
||||||
|
icon: Bell,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: "/settings",
|
url: "/settings",
|
||||||
@@ -46,11 +51,7 @@ export function AppSidebar() {
|
|||||||
<Sidebar variant="inset" collapsible="icon" className="p-0">
|
<Sidebar variant="inset" collapsible="icon" className="p-0">
|
||||||
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-[65px] flex-row items-center p-4">
|
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-[65px] flex-row items-center p-4">
|
||||||
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
|
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
|
||||||
<img
|
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
|
||||||
src="/images/zerobyte.png"
|
|
||||||
alt="Zerobyte Logo"
|
|
||||||
className={cn("h-8 w-8 flex-shrink-0 object-contain -ml-2")}
|
|
||||||
/>
|
|
||||||
<span
|
<span
|
||||||
className={cn("text-base transition-all duration-200 -ml-1", {
|
className={cn("text-base transition-all duration-200 -ml-1", {
|
||||||
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
|
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "./ui/alert-dialog";
|
} from "./ui/alert-dialog";
|
||||||
|
import { Textarea } from "./ui/textarea";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
@@ -53,6 +54,7 @@ const defaultValuesForType = {
|
|||||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||||
|
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateRepositoryForm = ({
|
export const CreateRepositoryForm = ({
|
||||||
@@ -141,6 +143,7 @@ export const CreateRepositoryForm = ({
|
|||||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||||
<SelectItem value="rest">REST Server</SelectItem>
|
<SelectItem value="rest">REST Server</SelectItem>
|
||||||
|
<SelectItem value="sftp">SFTP</SelectItem>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||||
@@ -268,18 +271,11 @@ export const CreateRepositoryForm = ({
|
|||||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||||
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowPathWarning(true)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Change
|
Change
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormDescription>
|
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
||||||
The directory where the repository will be stored.
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||||
@@ -290,13 +286,9 @@ export const CreateRepositoryForm = ({
|
|||||||
Important: Host Mount Required
|
Important: Host Mount Required
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="space-y-3">
|
<AlertDialogDescription className="space-y-3">
|
||||||
<p>
|
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
||||||
When selecting a custom path, ensure it is mounted from the host machine into the
|
|
||||||
container.
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
If the path is not a host mount, you will lose your repository data when the container
|
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||||
restarts.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
||||||
@@ -703,6 +695,89 @@ export const CreateRepositoryForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "sftp" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="host"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Host</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="22"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH port (default: 22).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backup-user" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH username for authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backups/ironmount" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="privateKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SSH Private Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{watchedBackend !== "directory" && (
|
{watchedBackend && watchedBackend !== "directory" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
|||||||
case "gcs":
|
case "gcs":
|
||||||
return <Cloud className={className} />;
|
return <Cloud className={className} />;
|
||||||
case "rest":
|
case "rest":
|
||||||
|
case "sftp":
|
||||||
return <Server className={className} />;
|
return <Server className={className} />;
|
||||||
default:
|
default:
|
||||||
return <Database className={className} />;
|
return <Database className={className} />;
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
import type { VolumeStatus } from "~/client/lib/types";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
export const StatusDot = ({ status }: { status: VolumeStatus }) => {
|
type StatusVariant = "success" | "neutral" | "error" | "warning" | "info";
|
||||||
|
|
||||||
|
interface StatusDotProps {
|
||||||
|
variant: StatusVariant;
|
||||||
|
label: string;
|
||||||
|
animated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||||
const statusMapping = {
|
const statusMapping = {
|
||||||
mounted: {
|
success: {
|
||||||
color: "bg-green-500",
|
color: "bg-green-500",
|
||||||
colorLight: "bg-emerald-400",
|
colorLight: "bg-emerald-400",
|
||||||
animated: true,
|
animated: animated ?? true,
|
||||||
},
|
},
|
||||||
unmounted: {
|
neutral: {
|
||||||
color: "bg-gray-500",
|
color: "bg-gray-500",
|
||||||
colorLight: "bg-gray-400",
|
colorLight: "bg-gray-400",
|
||||||
animated: false,
|
animated: animated ?? false,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
color: "bg-red-500",
|
color: "bg-red-500",
|
||||||
colorLight: "bg-amber-700",
|
colorLight: "bg-red-400",
|
||||||
animated: true,
|
animated: animated ?? true,
|
||||||
},
|
},
|
||||||
unknown: {
|
warning: {
|
||||||
color: "bg-yellow-500",
|
color: "bg-yellow-500",
|
||||||
colorLight: "bg-yellow-400",
|
colorLight: "bg-yellow-400",
|
||||||
animated: true,
|
animated: animated ?? true,
|
||||||
},
|
},
|
||||||
}[status];
|
info: {
|
||||||
|
color: "bg-blue-500",
|
||||||
|
colorLight: "bg-blue-400",
|
||||||
|
animated: animated ?? true,
|
||||||
|
},
|
||||||
|
}[variant];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<span className="relative flex size-3 mx-auto">
|
<span className="relative flex size-3 mx-auto">
|
||||||
{statusMapping.animated && (
|
{statusMapping?.animated && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||||
@@ -38,11 +50,11 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping?.color}`)} />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className="capitalize">{status}</p>
|
<p>{label}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
GetMeResponse,
|
GetMeResponse,
|
||||||
GetRepositoryResponse,
|
GetRepositoryResponse,
|
||||||
GetVolumeResponse,
|
GetVolumeResponse,
|
||||||
|
ListNotificationDestinationsResponse,
|
||||||
ListSnapshotsResponse,
|
ListSnapshotsResponse,
|
||||||
} from "../api-client";
|
} from "../api-client";
|
||||||
|
|
||||||
@@ -17,3 +18,5 @@ export type Repository = GetRepositoryResponse;
|
|||||||
export type BackupSchedule = GetBackupScheduleResponse;
|
export type BackupSchedule = GetBackupScheduleResponse;
|
||||||
|
|
||||||
export type Snapshot = ListSnapshotsResponse[number];
|
export type Snapshot = ListSnapshotsResponse[number];
|
||||||
|
|
||||||
|
export type NotificationDestination = ListNotificationDestinationsResponse[number];
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { cn } from "~/client/lib/utils";
|
import { StatusDot } from "~/client/components/status-dot";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
|
||||||
|
|
||||||
type BackupStatus = "active" | "paused" | "error" | "in_progress";
|
|
||||||
|
|
||||||
export const BackupStatusDot = ({
|
export const BackupStatusDot = ({
|
||||||
enabled,
|
enabled,
|
||||||
@@ -12,60 +9,22 @@ export const BackupStatusDot = ({
|
|||||||
hasError?: boolean;
|
hasError?: boolean;
|
||||||
isInProgress?: boolean;
|
isInProgress?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let status: BackupStatus = "paused";
|
let variant: "success" | "neutral" | "error" | "info";
|
||||||
|
let label: string;
|
||||||
|
|
||||||
if (isInProgress) {
|
if (isInProgress) {
|
||||||
status = "in_progress";
|
variant = "info";
|
||||||
|
label = "Backup in progress";
|
||||||
} else if (hasError) {
|
} else if (hasError) {
|
||||||
status = "error";
|
variant = "error";
|
||||||
|
label = "Error";
|
||||||
} else if (enabled) {
|
} else if (enabled) {
|
||||||
status = "active";
|
variant = "success";
|
||||||
|
label = "Active";
|
||||||
|
} else {
|
||||||
|
variant = "neutral";
|
||||||
|
label = "Paused";
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMapping = {
|
return <StatusDot variant={variant} label={label} />;
|
||||||
active: {
|
|
||||||
color: "bg-green-500",
|
|
||||||
colorLight: "bg-emerald-400",
|
|
||||||
animated: true,
|
|
||||||
label: "Active",
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
color: "bg-gray-500",
|
|
||||||
colorLight: "bg-gray-400",
|
|
||||||
animated: false,
|
|
||||||
label: "Paused",
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
color: "bg-red-500",
|
|
||||||
colorLight: "bg-red-400",
|
|
||||||
animated: true,
|
|
||||||
label: "Error",
|
|
||||||
},
|
|
||||||
in_progress: {
|
|
||||||
color: "bg-blue-500",
|
|
||||||
colorLight: "bg-blue-400",
|
|
||||||
animated: true,
|
|
||||||
label: "Backup in progress",
|
|
||||||
},
|
|
||||||
}[status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<span className="relative flex size-3 mx-auto">
|
|
||||||
{statusMapping.animated && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
|
||||||
`${statusMapping.colorLight}`,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{statusMapping.label}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Bell, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { Switch } from "~/client/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
|
import { Badge } from "~/client/components/ui/badge";
|
||||||
|
import {
|
||||||
|
getScheduleNotificationsOptions,
|
||||||
|
updateScheduleNotificationsMutation,
|
||||||
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import type { NotificationDestination } from "~/client/lib/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
scheduleId: number;
|
||||||
|
destinations: NotificationDestination[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationAssignment = {
|
||||||
|
destinationId: number;
|
||||||
|
notifyOnStart: boolean;
|
||||||
|
notifyOnSuccess: boolean;
|
||||||
|
notifyOnFailure: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) => {
|
||||||
|
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(new Map());
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
|
|
||||||
|
const { data: currentAssignments } = useQuery({
|
||||||
|
...getScheduleNotificationsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNotifications = useMutation({
|
||||||
|
...updateScheduleNotificationsMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Notification settings saved successfully");
|
||||||
|
setHasChanges(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to save notification settings", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentAssignments) {
|
||||||
|
const map = new Map<number, NotificationAssignment>();
|
||||||
|
for (const assignment of currentAssignments) {
|
||||||
|
map.set(assignment.destinationId, {
|
||||||
|
destinationId: assignment.destinationId,
|
||||||
|
notifyOnStart: assignment.notifyOnStart,
|
||||||
|
notifyOnSuccess: assignment.notifyOnSuccess,
|
||||||
|
notifyOnFailure: assignment.notifyOnFailure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssignments(map);
|
||||||
|
}
|
||||||
|
}, [currentAssignments]);
|
||||||
|
|
||||||
|
const addDestination = (destinationId: string) => {
|
||||||
|
const id = Number.parseInt(destinationId, 10);
|
||||||
|
const newAssignments = new Map(assignments);
|
||||||
|
newAssignments.set(id, {
|
||||||
|
destinationId: id,
|
||||||
|
notifyOnStart: false,
|
||||||
|
notifyOnSuccess: false,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setAssignments(newAssignments);
|
||||||
|
setHasChanges(true);
|
||||||
|
setIsAddingNew(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDestination = (destinationId: number) => {
|
||||||
|
const newAssignments = new Map(assignments);
|
||||||
|
newAssignments.delete(destinationId);
|
||||||
|
setAssignments(newAssignments);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEvent = (destinationId: number, event: "notifyOnStart" | "notifyOnSuccess" | "notifyOnFailure") => {
|
||||||
|
const assignment = assignments.get(destinationId);
|
||||||
|
if (!assignment) return;
|
||||||
|
|
||||||
|
const newAssignments = new Map(assignments);
|
||||||
|
newAssignments.set(destinationId, {
|
||||||
|
...assignment,
|
||||||
|
[event]: !assignment[event],
|
||||||
|
});
|
||||||
|
|
||||||
|
setAssignments(newAssignments);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const assignmentsList = Array.from(assignments.values());
|
||||||
|
updateNotifications.mutate({
|
||||||
|
path: { scheduleId: scheduleId.toString() },
|
||||||
|
body: {
|
||||||
|
assignments: assignmentsList,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (currentAssignments) {
|
||||||
|
const map = new Map<number, NotificationAssignment>();
|
||||||
|
for (const assignment of currentAssignments) {
|
||||||
|
map.set(assignment.destinationId, {
|
||||||
|
destinationId: assignment.destinationId,
|
||||||
|
notifyOnStart: assignment.notifyOnStart,
|
||||||
|
notifyOnSuccess: assignment.notifyOnSuccess,
|
||||||
|
notifyOnFailure: assignment.notifyOnFailure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAssignments(map);
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDestinationById = (id: number) => {
|
||||||
|
return destinations?.find((d) => d.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableDestinations = destinations?.filter((d) => !assignments.has(d.id)) || [];
|
||||||
|
const assignedDestinations = Array.from(assignments.keys())
|
||||||
|
.map((id) => getDestinationById(id))
|
||||||
|
.filter((d) => d !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
Notifications
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
|
||||||
|
</div>
|
||||||
|
{!isAddingNew && availableDestinations.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add notification
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isAddingNew && (
|
||||||
|
<div className="mb-6 flex items-center gap-2 max-w-md">
|
||||||
|
<Select onValueChange={addDestination}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a notification destination..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableDestinations.map((destination) => (
|
||||||
|
<SelectItem key={destination.id} value={destination.id.toString()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{destination.name}</span>
|
||||||
|
<span className="text-xs uppercase text-muted-foreground">({destination.type})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assignedDestinations.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||||
|
<Bell className="h-8 w-8 mb-2 opacity-20" />
|
||||||
|
<p className="text-sm">No notifications configured for this schedule.</p>
|
||||||
|
<p className="text-xs mt-1">Click "Add notification" to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Destination</TableHead>
|
||||||
|
<TableHead className="text-center w-[100px]">Start</TableHead>
|
||||||
|
<TableHead className="text-center w-[100px]">Success</TableHead>
|
||||||
|
<TableHead className="text-center w-[100px]">Failure</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{assignedDestinations.map((destination) => {
|
||||||
|
const assignment = assignments.get(destination.id);
|
||||||
|
if (!assignment) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={destination.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{destination.name}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] align-middle">
|
||||||
|
{destination.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Switch
|
||||||
|
className="align-middle"
|
||||||
|
checked={assignment.notifyOnStart}
|
||||||
|
onCheckedChange={() => toggleEvent(destination.id, "notifyOnStart")}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Switch
|
||||||
|
className="align-middle"
|
||||||
|
checked={assignment.notifyOnSuccess}
|
||||||
|
onCheckedChange={() => toggleEvent(destination.id, "notifyOnSuccess")}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Switch
|
||||||
|
className="align-middle"
|
||||||
|
checked={assignment.notifyOnFailure}
|
||||||
|
onCheckedChange={() => toggleEvent(destination.id, "notifyOnFailure")}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeDestination(destination.id)}
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="flex gap-2 justify-end mt-4 pt-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" onClick={handleSave} loading={updateNotifications.isPending}>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { FileIcon } from "lucide-react";
|
import { ChevronDown, FileIcon } from "lucide-react";
|
||||||
import { FileTree } from "~/client/components/file-tree";
|
import { FileTree } from "~/client/components/file-tree";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
|
import { Input } from "~/client/components/ui/input";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -39,6 +40,8 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||||
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
||||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [excludeXattr, setExcludeXattr] = useState("");
|
||||||
|
|
||||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||||
|
|
||||||
@@ -64,9 +67,11 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
|
|
||||||
const addBasePath = useCallback(
|
const addBasePath = useCallback(
|
||||||
(displayPath: string): string => {
|
(displayPath: string): string => {
|
||||||
if (!volumeBasePath) return displayPath;
|
let vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
||||||
if (displayPath === "/") return volumeBasePath;
|
|
||||||
return `${volumeBasePath}${displayPath}`;
|
if (!vbp) return displayPath;
|
||||||
|
if (displayPath === "/") return vbp;
|
||||||
|
return `${vbp}${displayPath}`;
|
||||||
},
|
},
|
||||||
[volumeBasePath],
|
[volumeBasePath],
|
||||||
);
|
);
|
||||||
@@ -117,17 +122,23 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
const pathsArray = Array.from(selectedPaths);
|
const pathsArray = Array.from(selectedPaths);
|
||||||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||||
|
|
||||||
|
const excludeXattrArray = excludeXattr
|
||||||
|
?.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
restoreSnapshot({
|
restoreSnapshot({
|
||||||
path: { name: repositoryName },
|
path: { name: repositoryName },
|
||||||
body: {
|
body: {
|
||||||
snapshotId: snapshot.short_id,
|
snapshotId: snapshot.short_id,
|
||||||
include: includePaths,
|
include: includePaths,
|
||||||
delete: deleteExtraFiles,
|
delete: deleteExtraFiles,
|
||||||
|
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowRestoreDialog(false);
|
setShowRestoreDialog(false);
|
||||||
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]);
|
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -220,15 +231,46 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
|
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="flex items-center space-x-2 py-4">
|
<div className="space-y-4">
|
||||||
<Checkbox
|
<div>
|
||||||
id="delete-extra"
|
<Button
|
||||||
checked={deleteExtraFiles}
|
type="button"
|
||||||
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
variant="ghost"
|
||||||
/>
|
size="sm"
|
||||||
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
Delete files not present in the snapshot?
|
className="h-auto p-0 text-sm font-normal"
|
||||||
</Label>
|
>
|
||||||
|
Advanced
|
||||||
|
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Label htmlFor="exclude-xattr" className="text-sm">
|
||||||
|
Exclude Extended Attributes (Optional)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="exclude-xattr"
|
||||||
|
placeholder="com.apple.metadata,user.*,nfs4.*"
|
||||||
|
value={excludeXattr}
|
||||||
|
onChange={(e) => setExcludeXattr(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Exclude specific extended attributes during restore (comma-separated)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
|
<Checkbox
|
||||||
|
id="delete-extra"
|
||||||
|
checked={deleteExtraFiles}
|
||||||
|
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
||||||
|
Delete files not present in the snapshot?
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary";
|
|||||||
import type { Route } from "./+types/backup-details";
|
import type { Route } from "./+types/backup-details";
|
||||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||||
import { getBackupSchedule } from "~/client/api-client";
|
import { getBackupSchedule, listNotificationDestinations } from "~/client/api-client";
|
||||||
|
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
||||||
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: (match: Route.MetaArgs) => [
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
@@ -49,11 +51,12 @@ export function meta(_: Route.MetaArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||||
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
|
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||||
|
const notifs = await listNotificationDestinations();
|
||||||
|
|
||||||
if (!data) return redirect("/backups");
|
if (!schedule.data) return redirect("/backups");
|
||||||
|
|
||||||
return data;
|
return { schedule: schedule.data, notifs: notifs.data };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||||
@@ -66,7 +69,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
|
|
||||||
const { data: schedule } = useQuery({
|
const { data: schedule } = useQuery({
|
||||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||||
initialData: loaderData,
|
initialData: loaderData.schedule,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
@@ -222,6 +225,9 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
setIsEditMode={setIsEditMode}
|
setIsEditMode={setIsEditMode}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
/>
|
/>
|
||||||
|
<div className={cn({ hidden: !loaderData.notifs?.length })}>
|
||||||
|
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
|
||||||
|
</div>
|
||||||
<SnapshotTimeline
|
<SnapshotTimeline
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
snapshots={snapshots ?? []}
|
snapshots={snapshots ?? []}
|
||||||
|
|||||||
@@ -0,0 +1,607 @@
|
|||||||
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { cn, slugify } from "~/client/lib/utils";
|
||||||
|
import { deepClean } from "~/utils/object";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "~/client/components/ui/form";
|
||||||
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
|
import { notificationConfigSchema } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export const formSchema = type({
|
||||||
|
name: "2<=string<=32",
|
||||||
|
}).and(notificationConfigSchema);
|
||||||
|
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||||
|
|
||||||
|
export type NotificationFormValues = typeof formSchema.inferIn;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (values: NotificationFormValues) => void;
|
||||||
|
mode?: "create" | "update";
|
||||||
|
initialValues?: Partial<NotificationFormValues>;
|
||||||
|
formId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValuesForType = {
|
||||||
|
email: {
|
||||||
|
type: "email" as const,
|
||||||
|
smtpHost: "",
|
||||||
|
smtpPort: 587,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
from: "",
|
||||||
|
to: [],
|
||||||
|
useTLS: true,
|
||||||
|
},
|
||||||
|
slack: {
|
||||||
|
type: "slack" as const,
|
||||||
|
webhookUrl: "",
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
type: "discord" as const,
|
||||||
|
webhookUrl: "",
|
||||||
|
},
|
||||||
|
gotify: {
|
||||||
|
type: "gotify" as const,
|
||||||
|
serverUrl: "",
|
||||||
|
token: "",
|
||||||
|
priority: 5,
|
||||||
|
},
|
||||||
|
ntfy: {
|
||||||
|
type: "ntfy" as const,
|
||||||
|
topic: "",
|
||||||
|
priority: "default" as const,
|
||||||
|
},
|
||||||
|
pushover: {
|
||||||
|
type: "pushover" as const,
|
||||||
|
userKey: "",
|
||||||
|
apiToken: "",
|
||||||
|
priority: 0 as const,
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
type: "custom" as const,
|
||||||
|
shoutrrrUrl: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
||||||
|
const form = useForm<NotificationFormValues>({
|
||||||
|
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||||
|
defaultValues: initialValues,
|
||||||
|
resetOptions: {
|
||||||
|
keepDefaultValues: true,
|
||||||
|
keepDirtyValues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { watch } = form;
|
||||||
|
const watchedType = watch("type");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialValues) {
|
||||||
|
form.reset({
|
||||||
|
name: form.getValues().name,
|
||||||
|
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [watchedType, form, initialValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="My notification"
|
||||||
|
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||||
|
max={32}
|
||||||
|
min={2}
|
||||||
|
disabled={mode === "update"}
|
||||||
|
className={mode === "update" ? "bg-gray-50" : ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Unique identifier for this notification destination.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
disabled={mode === "update"}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className={mode === "update" ? "bg-gray-50" : ""}>
|
||||||
|
<SelectValue placeholder="Select notification type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="email">Email (SMTP)</SelectItem>
|
||||||
|
<SelectItem value="slack">Slack</SelectItem>
|
||||||
|
<SelectItem value="discord">Discord</SelectItem>
|
||||||
|
<SelectItem value="gotify">Gotify</SelectItem>
|
||||||
|
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||||
|
<SelectItem value="pushover">Pushover</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Choose the notification delivery method.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{watchedType === "email" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="smtpHost"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SMTP Host</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="smtp.example.com" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="smtpPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SMTP Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
placeholder="587"
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="user@example.com" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="password" placeholder="••••••••" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="from"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>From Address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="noreply@example.com" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="to"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>To Addresses</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="user@example.com, admin@example.com"
|
||||||
|
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value.split(",").map((email) => email.trim()))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="useTLS"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-3">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>Use TLS</FormLabel>
|
||||||
|
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedType === "slack" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="webhookUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Webhook URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="channel"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Channel (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="#backups" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Zerobyte" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="iconEmoji"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Icon Emoji (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder=":floppy_disk:" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedType === "discord" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="webhookUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Webhook URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Zerobyte" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="avatarUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Avatar URL (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="https://example.com/avatar.png" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedType === "gotify" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="https://gotify.example.com" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>App Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="password" placeholder="••••••••" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Application token from Gotify.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedType === "ntfy" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server URL (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="https://ntfy.example.com" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="topic"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Topic</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="ironmount-backups" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>The ntfy topic name to publish to.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Access Token (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="password" placeholder="••••••••" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Required if the topic is protected.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="max">Max (5)</SelectItem>
|
||||||
|
<SelectItem value="high">High (4)</SelectItem>
|
||||||
|
<SelectItem value="default">Default (3)</SelectItem>
|
||||||
|
<SelectItem value="low">Low (2)</SelectItem>
|
||||||
|
<SelectItem value="min">Min (1)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedType === "pushover" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="userKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="apiToken"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="password" placeholder="••••••••" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Application API token from your Pushover application.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="devices"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Devices (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="iphone,android" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => field.onChange(Number(value))}
|
||||||
|
defaultValue={String(field.value)}
|
||||||
|
value={String(field.value)}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="-1">Low (-1)</SelectItem>
|
||||||
|
<SelectItem value="0">Normal (0)</SelectItem>
|
||||||
|
<SelectItem value="1">High (1)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Message priority level.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedType === "custom" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="shoutrrrUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Shoutrrr URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Direct Shoutrrr URL for power users. See
|
||||||
|
<a
|
||||||
|
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-strong-accent hover:underline"
|
||||||
|
>
|
||||||
|
Shoutrrr documentation
|
||||||
|
</a>
|
||||||
|
for supported services and URL formats.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { useId } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { createNotificationDestinationMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import type { Route } from "./+types/create-notification";
|
||||||
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
|
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Zerobyte - Create Notification" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Create a new notification destination for backup alerts.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateNotification() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
|
const createNotification = useMutation({
|
||||||
|
...createNotificationDestinationMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Notification destination created successfully");
|
||||||
|
navigate(`/notifications`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: NotificationFormValues) => {
|
||||||
|
createNotification.mutate({ body: { name: values.name, config: values } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||||
|
<Bell className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create Notification Destination</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{createNotification.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Failed to create notification destination:</strong>
|
||||||
|
<br />
|
||||||
|
{parseError(createNotification.error)?.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<CreateNotificationForm
|
||||||
|
mode="create"
|
||||||
|
formId={formId}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
loading={createNotification.isPending}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} loading={createNotification.isPending}>
|
||||||
|
Create Destination
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
app/client/modules/notifications/routes/notification-details.tsx
Normal file
208
app/client/modules/notifications/routes/notification-details.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { redirect, useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useState, useId } from "react";
|
||||||
|
import {
|
||||||
|
deleteNotificationDestinationMutation,
|
||||||
|
getNotificationDestinationOptions,
|
||||||
|
testNotificationDestinationMutation,
|
||||||
|
updateNotificationDestinationMutation,
|
||||||
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/client/components/ui/alert-dialog";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import { getNotificationDestination } from "~/client/api-client/sdk.gen";
|
||||||
|
import type { Route } from "./+types/notification-details";
|
||||||
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { Bell, TestTube2 } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
|
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
|
{ label: "Notifications", href: "/notifications" },
|
||||||
|
{ label: match.params.id },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: `Zerobyte - Notification ${params.id}` },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "View and edit notification destination settings.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
|
const destination = await getNotificationDestination({ path: { id: params.id ?? "" } });
|
||||||
|
if (destination.data) return destination.data;
|
||||||
|
|
||||||
|
return redirect("/notifications");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const formId = useId();
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
...getNotificationDestinationOptions({ path: { id: String(loaderData.id) } }),
|
||||||
|
initialData: loaderData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteDestination = useMutation({
|
||||||
|
...deleteNotificationDestinationMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Notification destination deleted successfully");
|
||||||
|
navigate("/notifications");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to delete notification destination", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDestination = useMutation({
|
||||||
|
...updateNotificationDestinationMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Notification destination updated successfully");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to update notification destination", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testDestination = useMutation({
|
||||||
|
...testNotificationDestinationMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Test notification sent successfully");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to send test notification", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
deleteDestination.mutate({ path: { id: String(data.id) } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (values: NotificationFormValues) => {
|
||||||
|
updateDestination.mutate({
|
||||||
|
path: { id: String(data.id) },
|
||||||
|
body: {
|
||||||
|
name: values.name,
|
||||||
|
config: values,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = () => {
|
||||||
|
testDestination.mutate({ path: { id: String(data.id) } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||||
|
"bg-green-500/10 text-green-500": data.enabled,
|
||||||
|
"bg-red-500/10 text-red-500": !data.enabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{data.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-primary/10 rounded-md px-2 py-1 capitalize">{data.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testDestination.isPending || !data.enabled}
|
||||||
|
variant="outline"
|
||||||
|
loading={testDestination.isPending}
|
||||||
|
>
|
||||||
|
<TestTube2 className="h-4 w-4 mr-2" />
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
variant="destructive"
|
||||||
|
loading={deleteDestination.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||||
|
<Bell className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>{data.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{updateDestination.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Failed to update notification destination:</strong>
|
||||||
|
<br />
|
||||||
|
{parseError(updateDestination.error)?.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<>
|
||||||
|
<CreateNotificationForm
|
||||||
|
mode="update"
|
||||||
|
formId={formId}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initialValues={data.config}
|
||||||
|
loading={updateDestination.isPending}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<Button type="submit" form={formId} loading={updateDestination.isPending}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Notification Destination</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the notification destination "{data.name}"? This action cannot be undone
|
||||||
|
and will remove this destination from all backup schedules.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
app/client/modules/notifications/routes/notifications.tsx
Normal file
177
app/client/modules/notifications/routes/notifications.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Bell, Plus, RotateCcw } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { EmptyState } from "~/client/components/empty-state";
|
||||||
|
import { StatusDot } from "~/client/components/status-dot";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card } from "~/client/components/ui/card";
|
||||||
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
|
import type { Route } from "./+types/notifications";
|
||||||
|
import { listNotificationDestinations } from "~/client/api-client";
|
||||||
|
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Notifications" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Zerobyte - Notifications" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Manage notification destinations for backup alerts.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientLoader = async () => {
|
||||||
|
const result = await listNotificationDestinations();
|
||||||
|
if (result.data) return result.data;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Notifications({ loaderData }: Route.ComponentProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setTypeFilter("");
|
||||||
|
setStatusFilter("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
...listNotificationDestinationsOptions(),
|
||||||
|
initialData: loaderData,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredNotifications =
|
||||||
|
data?.filter((notification) => {
|
||||||
|
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesType = !typeFilter || notification.type === typeFilter;
|
||||||
|
const matchesStatus =
|
||||||
|
!statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
|
||||||
|
return matchesSearch && matchesType && matchesStatus;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const hasNoNotifications = data.length === 0;
|
||||||
|
const hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications;
|
||||||
|
|
||||||
|
if (hasNoNotifications) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Bell}
|
||||||
|
title="No notification destinations"
|
||||||
|
description="Set up notification channels to receive alerts when your backups complete or fail."
|
||||||
|
button={
|
||||||
|
<Button onClick={() => navigate("/notifications/create")}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create Destination
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-0 gap-0">
|
||||||
|
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||||
|
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||||
|
<Input
|
||||||
|
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||||
|
placeholder="Search destinations…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||||
|
<SelectValue placeholder="All types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="email">Email</SelectItem>
|
||||||
|
<SelectItem value="slack">Slack</SelectItem>
|
||||||
|
<SelectItem value="discord">Discord</SelectItem>
|
||||||
|
<SelectItem value="gotify">Gotify</SelectItem>
|
||||||
|
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||||
|
<SelectItem value="pushover">Pushover</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||||
|
<SelectValue placeholder="All status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="enabled">Enabled</SelectItem>
|
||||||
|
<SelectItem value="disabled">Disabled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(searchQuery || typeFilter || statusFilter) && (
|
||||||
|
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button onClick={() => navigate("/notifications/create")}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create Destination
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="border-t">
|
||||||
|
<TableHeader className="bg-card-header">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||||
|
<TableHead className="uppercase text-left">Type</TableHead>
|
||||||
|
<TableHead className="uppercase text-center">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{hasNoFilteredNotifications ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<p className="text-muted-foreground">No destinations match your filters.</p>
|
||||||
|
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredNotifications.map((notification) => (
|
||||||
|
<TableRow
|
||||||
|
key={notification.id}
|
||||||
|
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||||
|
onClick={() => navigate(`/notifications/${notification.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
|
||||||
|
<TableCell className="capitalize">{notification.type}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<StatusDot variant={notification.enabled ? "success" : "neutral"} label={notification.enabled ? "Enabled" : "Disabled"} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||||
|
<span>
|
||||||
|
<span className="text-strong-accent">{filteredNotifications.length}</span> destination
|
||||||
|
{filteredNotifications.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,12 +52,18 @@ export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const excludeXattr = values.excludeXattr
|
||||||
|
?.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
restore.mutate({
|
restore.mutate({
|
||||||
path: { name },
|
path: { name },
|
||||||
body: {
|
body: {
|
||||||
snapshotId,
|
snapshotId,
|
||||||
include: include && include.length > 0 ? include : undefined,
|
include: include && include.length > 0 ? include : undefined,
|
||||||
exclude: exclude && exclude.length > 0 ? exclude : undefined,
|
exclude: exclude && exclude.length > 0 ? exclude : undefined,
|
||||||
|
excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -11,11 +13,13 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "~/client/components/ui/form";
|
} from "~/client/components/ui/form";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
|
||||||
const restoreSnapshotFormSchema = type({
|
const restoreSnapshotFormSchema = type({
|
||||||
path: "string?",
|
path: "string?",
|
||||||
include: "string?",
|
include: "string?",
|
||||||
exclude: "string?",
|
exclude: "string?",
|
||||||
|
excludeXattr: "string?",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
|
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
|
||||||
@@ -27,12 +31,15 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
|
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
const form = useForm<RestoreSnapshotFormValues>({
|
const form = useForm<RestoreSnapshotFormValues>({
|
||||||
resolver: arktypeResolver(restoreSnapshotFormSchema),
|
resolver: arktypeResolver(restoreSnapshotFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
path: "",
|
path: "",
|
||||||
include: "",
|
include: "",
|
||||||
exclude: "",
|
exclude: "",
|
||||||
|
excludeXattr: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,6 +97,43 @@ export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="h-auto p-0 text-sm font-normal"
|
||||||
|
>
|
||||||
|
Advanced
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="excludeXattr"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="com.apple.metadata,user.custom" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Exclude specific extended attributes during restore (comma-separated)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
Are you sure you want to delete the repository <strong>{data.name}</strong>? This will not remove the
|
||||||
and will remove all backup data.
|
actual data from the backend storage, only the repository configuration will be deleted.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { DockerTabContent } from "../tabs/docker";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { getVolume } from "~/client/api-client";
|
import { getVolume } from "~/client/api-client";
|
||||||
|
import type { VolumeStatus } from "~/client/lib/types";
|
||||||
import {
|
import {
|
||||||
deleteVolumeMutation,
|
deleteVolumeMutation,
|
||||||
getVolumeOptions,
|
getVolumeOptions,
|
||||||
@@ -31,6 +32,16 @@ import {
|
|||||||
unmountVolumeMutation,
|
unmountVolumeMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
|
||||||
|
const statusMap = {
|
||||||
|
mounted: "success" as const,
|
||||||
|
unmounted: "neutral" as const,
|
||||||
|
error: "error" as const,
|
||||||
|
unknown: "warning" as const,
|
||||||
|
};
|
||||||
|
return statusMap[status];
|
||||||
|
};
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
|
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
|
||||||
};
|
};
|
||||||
@@ -124,7 +135,12 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||||
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
<StatusDot
|
||||||
|
variant={getVolumeStatusVariant(volume.status)}
|
||||||
|
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ import { VolumeIcon } from "~/client/components/volume-icon";
|
|||||||
import type { Route } from "./+types/volumes";
|
import type { Route } from "./+types/volumes";
|
||||||
import { listVolumes } from "~/client/api-client";
|
import { listVolumes } from "~/client/api-client";
|
||||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import type { VolumeStatus } from "~/client/lib/types";
|
||||||
|
|
||||||
|
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
|
||||||
|
const statusMap = {
|
||||||
|
mounted: "success" as const,
|
||||||
|
unmounted: "neutral" as const,
|
||||||
|
error: "error" as const,
|
||||||
|
unknown: "warning" as const,
|
||||||
|
};
|
||||||
|
return statusMap[status];
|
||||||
|
};
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: () => [{ label: "Volumes" }],
|
breadcrumb: () => [{ label: "Volumes" }],
|
||||||
@@ -157,7 +168,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||||||
<VolumeIcon backend={volume.type} />
|
<VolumeIcon backend={volume.type} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<StatusDot status={volume.status} />
|
<StatusDot
|
||||||
|
variant={getVolumeStatusVariant(volume.status)}
|
||||||
|
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
23
app/drizzle/0011_familiar_stone_men.sql
Normal file
23
app/drizzle/0011_familiar_stone_men.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE `backup_schedule_notifications_table` (
|
||||||
|
`schedule_id` integer NOT NULL,
|
||||||
|
`destination_id` integer NOT NULL,
|
||||||
|
`notify_on_start` integer DEFAULT false NOT NULL,
|
||||||
|
`notify_on_success` integer DEFAULT false NOT NULL,
|
||||||
|
`notify_on_failure` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
PRIMARY KEY(`schedule_id`, `destination_id`),
|
||||||
|
FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`destination_id`) REFERENCES `notification_destinations_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `notification_destinations_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `notification_destinations_table_name_unique` ON `notification_destinations_table` (`name`);
|
||||||
620
app/drizzle/meta/0011_snapshot.json
Normal file
620
app/drizzle/meta/0011_snapshot.json
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "67552135-fa49-478f-9333-107d3dbd7610",
|
||||||
|
"prevId": "17f234ba-4123-4951-a39f-6002d537435f",
|
||||||
|
"tables": {
|
||||||
|
"backup_schedule_notifications_table": {
|
||||||
|
"name": "backup_schedule_notifications_table",
|
||||||
|
"columns": {
|
||||||
|
"schedule_id": {
|
||||||
|
"name": "schedule_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"destination_id": {
|
||||||
|
"name": "destination_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notify_on_start": {
|
||||||
|
"name": "notify_on_start",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notify_on_success": {
|
||||||
|
"name": "notify_on_success",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notify_on_failure": {
|
||||||
|
"name": "notify_on_failure",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
|
||||||
|
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_notifications_table",
|
||||||
|
"tableTo": "backup_schedules_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"schedule_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
|
||||||
|
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_notifications_table",
|
||||||
|
"tableTo": "notification_destinations_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"destination_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"schedule_id",
|
||||||
|
"destination_id"
|
||||||
|
],
|
||||||
|
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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": {},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"notification_destinations_table": {
|
||||||
|
"name": "notification_destinations_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"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": {
|
||||||
|
"notification_destinations_table_name_unique": {
|
||||||
|
"name": "notification_destinations_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"has_downloaded_restic_password": {
|
||||||
|
"name": "has_downloaded_restic_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,83 +1,90 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755765658194,
|
"when": 1755765658194,
|
||||||
"tag": "0000_known_madelyne_pryor",
|
"tag": "0000_known_madelyne_pryor",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755775437391,
|
"when": 1755775437391,
|
||||||
"tag": "0001_far_frank_castle",
|
"tag": "0001_far_frank_castle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1756930554198,
|
"when": 1756930554198,
|
||||||
"tag": "0002_cheerful_randall",
|
"tag": "0002_cheerful_randall",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758653407064,
|
"when": 1758653407064,
|
||||||
"tag": "0003_mature_hellcat",
|
"tag": "0003_mature_hellcat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758961535488,
|
"when": 1758961535488,
|
||||||
"tag": "0004_wealthy_tomas",
|
"tag": "0004_wealthy_tomas",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1759416698274,
|
"when": 1759416698274,
|
||||||
"tag": "0005_simple_alice",
|
"tag": "0005_simple_alice",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 6,
|
"idx": 6,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1760734377440,
|
"when": 1760734377440,
|
||||||
"tag": "0006_secret_micromacro",
|
"tag": "0006_secret_micromacro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761224911352,
|
"when": 1761224911352,
|
||||||
"tag": "0007_watery_sersi",
|
"tag": "0007_watery_sersi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761414054481,
|
"when": 1761414054481,
|
||||||
"tag": "0008_silent_lady_bullseye",
|
"tag": "0008_silent_lady_bullseye",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 9,
|
"idx": 9,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762095226041,
|
"when": 1762095226041,
|
||||||
"tag": "0009_little_adam_warlock",
|
"tag": "0009_little_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 10,
|
"idx": 10,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762610065889,
|
"when": 1762610065889,
|
||||||
"tag": "0010_perfect_proemial_gods",
|
"tag": "0010_perfect_proemial_gods",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1763644043601,
|
||||||
|
"tag": "0011_familiar_stone_men",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ export default [
|
|||||||
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
||||||
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
||||||
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||||
|
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
|
||||||
|
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
|
||||||
|
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),
|
||||||
route("settings", "./client/modules/settings/routes/settings.tsx"),
|
route("settings", "./client/modules/settings/routes/settings.tsx"),
|
||||||
]),
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
85
app/schemas/notifications.ts
Normal file
85
app/schemas/notifications.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { type } from "arktype";
|
||||||
|
|
||||||
|
export const NOTIFICATION_TYPES = {
|
||||||
|
email: "email",
|
||||||
|
slack: "slack",
|
||||||
|
discord: "discord",
|
||||||
|
gotify: "gotify",
|
||||||
|
ntfy: "ntfy",
|
||||||
|
pushover: "pushover",
|
||||||
|
custom: "custom",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||||
|
|
||||||
|
export const emailNotificationConfigSchema = type({
|
||||||
|
type: "'email'",
|
||||||
|
smtpHost: "string",
|
||||||
|
smtpPort: "1 <= number <= 65535",
|
||||||
|
username: "string",
|
||||||
|
password: "string",
|
||||||
|
from: "string",
|
||||||
|
to: "string[]",
|
||||||
|
useTLS: "boolean",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const slackNotificationConfigSchema = type({
|
||||||
|
type: "'slack'",
|
||||||
|
webhookUrl: "string",
|
||||||
|
channel: "string?",
|
||||||
|
username: "string?",
|
||||||
|
iconEmoji: "string?",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const discordNotificationConfigSchema = type({
|
||||||
|
type: "'discord'",
|
||||||
|
webhookUrl: "string",
|
||||||
|
username: "string?",
|
||||||
|
avatarUrl: "string?",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gotifyNotificationConfigSchema = type({
|
||||||
|
type: "'gotify'",
|
||||||
|
serverUrl: "string",
|
||||||
|
token: "string",
|
||||||
|
priority: "0 <= number <= 10",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ntfyNotificationConfigSchema = type({
|
||||||
|
type: "'ntfy'",
|
||||||
|
serverUrl: "string?",
|
||||||
|
topic: "string",
|
||||||
|
token: "string?",
|
||||||
|
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pushoverNotificationConfigSchema = type({
|
||||||
|
type: "'pushover'",
|
||||||
|
userKey: "string",
|
||||||
|
apiToken: "string",
|
||||||
|
devices: "string?",
|
||||||
|
priority: "-1 | 0 | 1",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const customNotificationConfigSchema = type({
|
||||||
|
type: "'custom'",
|
||||||
|
shoutrrrUrl: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notificationConfigSchema = emailNotificationConfigSchema
|
||||||
|
.or(slackNotificationConfigSchema)
|
||||||
|
.or(discordNotificationConfigSchema)
|
||||||
|
.or(gotifyNotificationConfigSchema)
|
||||||
|
.or(ntfyNotificationConfigSchema)
|
||||||
|
.or(pushoverNotificationConfigSchema)
|
||||||
|
.or(customNotificationConfigSchema);
|
||||||
|
|
||||||
|
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
||||||
|
|
||||||
|
export const NOTIFICATION_EVENTS = {
|
||||||
|
start: "start",
|
||||||
|
success: "success",
|
||||||
|
failure: "failure",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||||
@@ -8,6 +8,7 @@ export const REPOSITORY_BACKENDS = {
|
|||||||
azure: "azure",
|
azure: "azure",
|
||||||
rclone: "rclone",
|
rclone: "rclone",
|
||||||
rest: "rest",
|
rest: "rest",
|
||||||
|
sftp: "sftp",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||||
@@ -69,13 +70,23 @@ export const restRepositoryConfigSchema = type({
|
|||||||
path: "string?",
|
path: "string?",
|
||||||
}).and(baseRepositoryConfigSchema);
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const sftpRepositoryConfigSchema = type({
|
||||||
|
backend: "'sftp'",
|
||||||
|
host: "string",
|
||||||
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||||
|
user: "string",
|
||||||
|
path: "string",
|
||||||
|
privateKey: "string",
|
||||||
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||||
.or(r2RepositoryConfigSchema)
|
.or(r2RepositoryConfigSchema)
|
||||||
.or(localRepositoryConfigSchema)
|
.or(localRepositoryConfigSchema)
|
||||||
.or(gcsRepositoryConfigSchema)
|
.or(gcsRepositoryConfigSchema)
|
||||||
.or(azureRepositoryConfigSchema)
|
.or(azureRepositoryConfigSchema)
|
||||||
.or(rcloneRepositoryConfigSchema)
|
.or(rcloneRepositoryConfigSchema)
|
||||||
.or(restRepositoryConfigSchema);
|
.or(restRepositoryConfigSchema)
|
||||||
|
.or(sftpRepositoryConfigSchema);
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { int, integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core";
|
||||||
import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
|
import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
|
||||||
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
|
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
|
||||||
|
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Volumes Table
|
* Volumes Table
|
||||||
@@ -90,7 +91,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one }) => ({
|
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
|
||||||
volume: one(volumesTable, {
|
volume: one(volumesTable, {
|
||||||
fields: [backupSchedulesTable.volumeId],
|
fields: [backupSchedulesTable.volumeId],
|
||||||
references: [volumesTable.id],
|
references: [volumesTable.id],
|
||||||
@@ -99,5 +100,54 @@ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one })
|
|||||||
fields: [backupSchedulesTable.repositoryId],
|
fields: [backupSchedulesTable.repositoryId],
|
||||||
references: [repositoriesTable.id],
|
references: [repositoriesTable.id],
|
||||||
}),
|
}),
|
||||||
|
notifications: many(backupScheduleNotificationsTable),
|
||||||
}));
|
}));
|
||||||
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification Destinations Table
|
||||||
|
*/
|
||||||
|
export const notificationDestinationsTable = sqliteTable("notification_destinations_table", {
|
||||||
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
|
name: text().notNull().unique(),
|
||||||
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
type: text().$type<NotificationType>().notNull(),
|
||||||
|
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
|
||||||
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
|
||||||
|
schedules: many(backupScheduleNotificationsTable),
|
||||||
|
}));
|
||||||
|
export type NotificationDestination = typeof notificationDestinationsTable.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup Schedule Notifications Junction Table (Many-to-Many)
|
||||||
|
*/
|
||||||
|
export const backupScheduleNotificationsTable = sqliteTable(
|
||||||
|
"backup_schedule_notifications_table",
|
||||||
|
{
|
||||||
|
scheduleId: int("schedule_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => backupSchedulesTable.id, { onDelete: "cascade" }),
|
||||||
|
destinationId: int("destination_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => notificationDestinationsTable.id, { onDelete: "cascade" }),
|
||||||
|
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
|
||||||
|
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
|
||||||
|
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
|
||||||
|
);
|
||||||
|
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
|
||||||
|
schedule: one(backupSchedulesTable, {
|
||||||
|
fields: [backupScheduleNotificationsTable.scheduleId],
|
||||||
|
references: [backupSchedulesTable.id],
|
||||||
|
}),
|
||||||
|
destination: one(notificationDestinationsTable, {
|
||||||
|
fields: [backupScheduleNotificationsTable.destinationId],
|
||||||
|
references: [notificationDestinationsTable.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { systemController } from "./modules/system/system.controller";
|
|||||||
import { volumeController } from "./modules/volumes/volume.controller";
|
import { volumeController } from "./modules/volumes/volume.controller";
|
||||||
import { backupScheduleController } from "./modules/backups/backups.controller";
|
import { backupScheduleController } from "./modules/backups/backups.controller";
|
||||||
import { eventsController } from "./modules/events/events.controller";
|
import { eventsController } from "./modules/events/events.controller";
|
||||||
|
import { notificationsController } from "./modules/notifications/notifications.controller";
|
||||||
import { handleServiceError } from "./utils/errors";
|
import { handleServiceError } from "./utils/errors";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { shutdown } from "./modules/lifecycle/shutdown";
|
import { shutdown } from "./modules/lifecycle/shutdown";
|
||||||
@@ -46,6 +47,7 @@ const app = new Hono()
|
|||||||
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
||||||
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
|
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
|
||||||
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
|
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
|
||||||
|
.route("/api/v1/notifications", notificationsController.use(requireAuth))
|
||||||
.route("/api/v1/system", systemController.use(requireAuth))
|
.route("/api/v1/system", systemController.use(requireAuth))
|
||||||
.route("/api/v1/events", eventsController.use(requireAuth));
|
.route("/api/v1/events", eventsController.use(requireAuth));
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import {
|
|||||||
type UpdateBackupScheduleDto,
|
type UpdateBackupScheduleDto,
|
||||||
} from "./backups.dto";
|
} from "./backups.dto";
|
||||||
import { backupsService } from "./backups.service";
|
import { backupsService } from "./backups.service";
|
||||||
|
import {
|
||||||
|
getScheduleNotificationsDto,
|
||||||
|
updateScheduleNotificationsBody,
|
||||||
|
updateScheduleNotificationsDto,
|
||||||
|
type GetScheduleNotificationsDto,
|
||||||
|
type UpdateScheduleNotificationsDto,
|
||||||
|
} from "../notifications/notifications.dto";
|
||||||
|
import { notificationsService } from "../notifications/notifications.service";
|
||||||
|
|
||||||
export const backupScheduleController = new Hono()
|
export const backupScheduleController = new Hono()
|
||||||
.get("/", listBackupSchedulesDto, async (c) => {
|
.get("/", listBackupSchedulesDto, async (c) => {
|
||||||
@@ -87,4 +95,22 @@ export const backupScheduleController = new Hono()
|
|||||||
await backupsService.runForget(Number(scheduleId));
|
await backupsService.runForget(Number(scheduleId));
|
||||||
|
|
||||||
return c.json<RunForgetDto>({ success: true }, 200);
|
return c.json<RunForgetDto>({ success: true }, 200);
|
||||||
});
|
})
|
||||||
|
.get("/:scheduleId/notifications", getScheduleNotificationsDto, async (c) => {
|
||||||
|
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||||
|
const assignments = await notificationsService.getScheduleNotifications(scheduleId);
|
||||||
|
|
||||||
|
return c.json<GetScheduleNotificationsDto>(assignments, 200);
|
||||||
|
})
|
||||||
|
.put(
|
||||||
|
"/:scheduleId/notifications",
|
||||||
|
updateScheduleNotificationsDto,
|
||||||
|
validator("json", updateScheduleNotificationsBody),
|
||||||
|
async (c) => {
|
||||||
|
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const assignments = await notificationsService.updateScheduleNotifications(scheduleId, body.assignments);
|
||||||
|
|
||||||
|
return c.json<UpdateScheduleNotificationsDto>(assignments, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getVolumePath } from "../volumes/helpers";
|
|||||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
|
import { notificationsService } from "../notifications/notifications.service";
|
||||||
|
|
||||||
const runningBackups = new Map<number, AbortController>();
|
const runningBackups = new Map<number, AbortController>();
|
||||||
|
|
||||||
@@ -195,6 +196,15 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notificationsService
|
||||||
|
.sendBackupNotification(scheduleId, "start", {
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(`Failed to send backup start notification: ${toMessage(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -262,6 +272,15 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
status: "success",
|
status: "success",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notificationsService
|
||||||
|
.sendBackupNotification(scheduleId, "success", {
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(`Failed to send backup success notification: ${toMessage(error)}`);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
|
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
|
||||||
|
|
||||||
@@ -282,6 +301,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
status: "error",
|
status: "error",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notificationsService
|
||||||
|
.sendBackupNotification(scheduleId, "failure", {
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
error: toMessage(error),
|
||||||
|
})
|
||||||
|
.catch((notifError) => {
|
||||||
|
logger.error(`Failed to send backup failure notification: ${toMessage(notifError)}`);
|
||||||
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
runningBackups.delete(scheduleId);
|
runningBackups.delete(scheduleId);
|
||||||
|
|||||||
5
app/server/modules/notifications/builders/custom.ts
Normal file
5
app/server/modules/notifications/builders/custom.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildCustomShoutrrrUrl(config: Extract<NotificationConfig, { type: "custom" }>): string {
|
||||||
|
return config.shoutrrrUrl;
|
||||||
|
}
|
||||||
28
app/server/modules/notifications/builders/discord.ts
Normal file
28
app/server/modules/notifications/builders/discord.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { type: "discord" }>): string {
|
||||||
|
const url = new URL(config.webhookUrl);
|
||||||
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (pathParts.length < 4 || pathParts[0] !== "api" || pathParts[1] !== "webhooks") {
|
||||||
|
throw new Error("Invalid Discord webhook URL format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, , webhookId, webhookToken] = pathParts;
|
||||||
|
|
||||||
|
let shoutrrrUrl = `discord://${webhookToken}@${webhookId}`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (config.username) {
|
||||||
|
params.append("username", config.username);
|
||||||
|
}
|
||||||
|
if (config.avatarUrl) {
|
||||||
|
params.append("avatar_url", config.avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
shoutrrrUrl += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shoutrrrUrl;
|
||||||
|
}
|
||||||
10
app/server/modules/notifications/builders/email.ts
Normal file
10
app/server/modules/notifications/builders/email.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
|
||||||
|
const protocol = config.useTLS ? "smtps" : "smtp";
|
||||||
|
const auth = `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}`;
|
||||||
|
const host = `${config.smtpHost}:${config.smtpPort}`;
|
||||||
|
const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(",");
|
||||||
|
|
||||||
|
return `${protocol}://${auth}@${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}`;
|
||||||
|
}
|
||||||
15
app/server/modules/notifications/builders/gotify.ts
Normal file
15
app/server/modules/notifications/builders/gotify.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { type: "gotify" }>): string {
|
||||||
|
const url = new URL(config.serverUrl);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
const port = url.port ? `:${url.port}` : "";
|
||||||
|
|
||||||
|
let shoutrrrUrl = `gotify://${hostname}${port}/${config.token}`;
|
||||||
|
|
||||||
|
if (config.priority !== undefined) {
|
||||||
|
shoutrrrUrl += `?priority=${config.priority}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shoutrrrUrl;
|
||||||
|
}
|
||||||
32
app/server/modules/notifications/builders/index.ts
Normal file
32
app/server/modules/notifications/builders/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
import { buildEmailShoutrrrUrl } from "./email";
|
||||||
|
import { buildSlackShoutrrrUrl } from "./slack";
|
||||||
|
import { buildDiscordShoutrrrUrl } from "./discord";
|
||||||
|
import { buildGotifyShoutrrrUrl } from "./gotify";
|
||||||
|
import { buildNtfyShoutrrrUrl } from "./ntfy";
|
||||||
|
import { buildPushoverShoutrrrUrl } from "./pushover";
|
||||||
|
import { buildCustomShoutrrrUrl } from "./custom";
|
||||||
|
|
||||||
|
export function buildShoutrrrUrl(config: NotificationConfig): string {
|
||||||
|
switch (config.type) {
|
||||||
|
case "email":
|
||||||
|
return buildEmailShoutrrrUrl(config);
|
||||||
|
case "slack":
|
||||||
|
return buildSlackShoutrrrUrl(config);
|
||||||
|
case "discord":
|
||||||
|
return buildDiscordShoutrrrUrl(config);
|
||||||
|
case "gotify":
|
||||||
|
return buildGotifyShoutrrrUrl(config);
|
||||||
|
case "ntfy":
|
||||||
|
return buildNtfyShoutrrrUrl(config);
|
||||||
|
case "pushover":
|
||||||
|
return buildPushoverShoutrrrUrl(config);
|
||||||
|
case "custom":
|
||||||
|
return buildCustomShoutrrrUrl(config);
|
||||||
|
default: {
|
||||||
|
// TypeScript exhaustiveness check
|
||||||
|
const _exhaustive: never = config;
|
||||||
|
throw new Error(`Unsupported notification type: ${(_exhaustive as NotificationConfig).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/server/modules/notifications/builders/ntfy.ts
Normal file
28
app/server/modules/notifications/builders/ntfy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
|
||||||
|
let shoutrrrUrl: string;
|
||||||
|
|
||||||
|
if (config.serverUrl) {
|
||||||
|
const url = new URL(config.serverUrl);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
const port = url.port ? `:${url.port}` : "";
|
||||||
|
shoutrrrUrl = `ntfy://${hostname}${port}/${config.topic}`;
|
||||||
|
} else {
|
||||||
|
shoutrrrUrl = `ntfy://ntfy.sh/${config.topic}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (config.token) {
|
||||||
|
params.append("token", config.token);
|
||||||
|
}
|
||||||
|
if (config.priority) {
|
||||||
|
params.append("priority", config.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
shoutrrrUrl += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shoutrrrUrl;
|
||||||
|
}
|
||||||
24
app/server/modules/notifications/builders/pushover.ts
Normal file
24
app/server/modules/notifications/builders/pushover.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildPushoverShoutrrrUrl(
|
||||||
|
config: Extract<NotificationConfig, { type: "pushover" }>,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (config.devices) {
|
||||||
|
params.append("devices", config.devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.priority !== undefined) {
|
||||||
|
params.append("priority", config.priority.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
let shoutrrrUrl = `pushover://shoutrrr:${config.apiToken}@${config.userKey}/`;
|
||||||
|
|
||||||
|
if (queryString) {
|
||||||
|
shoutrrrUrl += `?${queryString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shoutrrrUrl;
|
||||||
|
}
|
||||||
31
app/server/modules/notifications/builders/slack.ts
Normal file
31
app/server/modules/notifications/builders/slack.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type: "slack" }>): string {
|
||||||
|
const url = new URL(config.webhookUrl);
|
||||||
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (pathParts.length < 4 || pathParts[0] !== "services") {
|
||||||
|
throw new Error("Invalid Slack webhook URL format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, tokenA, tokenB, tokenC] = pathParts;
|
||||||
|
|
||||||
|
let shoutrrrUrl = `slack://hook:${tokenA}-${tokenB}-${tokenC}@webhook`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (config.channel) {
|
||||||
|
params.append("channel", config.channel);
|
||||||
|
}
|
||||||
|
if (config.username) {
|
||||||
|
params.append("username", config.username);
|
||||||
|
}
|
||||||
|
if (config.iconEmoji) {
|
||||||
|
params.append("icon", config.iconEmoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
shoutrrrUrl += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shoutrrrUrl;
|
||||||
|
}
|
||||||
51
app/server/modules/notifications/notifications.controller.ts
Normal file
51
app/server/modules/notifications/notifications.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { validator } from "hono-openapi";
|
||||||
|
import {
|
||||||
|
createDestinationBody,
|
||||||
|
createDestinationDto,
|
||||||
|
deleteDestinationDto,
|
||||||
|
getDestinationDto,
|
||||||
|
listDestinationsDto,
|
||||||
|
testDestinationDto,
|
||||||
|
updateDestinationBody,
|
||||||
|
updateDestinationDto,
|
||||||
|
type CreateDestinationDto,
|
||||||
|
type DeleteDestinationDto,
|
||||||
|
type GetDestinationDto,
|
||||||
|
type ListDestinationsDto,
|
||||||
|
type TestDestinationDto,
|
||||||
|
type UpdateDestinationDto,
|
||||||
|
} from "./notifications.dto";
|
||||||
|
import { notificationsService } from "./notifications.service";
|
||||||
|
|
||||||
|
export const notificationsController = new Hono()
|
||||||
|
.get("/destinations", listDestinationsDto, async (c) => {
|
||||||
|
const destinations = await notificationsService.listDestinations();
|
||||||
|
return c.json<ListDestinationsDto>(destinations, 200);
|
||||||
|
})
|
||||||
|
.post("/destinations", createDestinationDto, validator("json", createDestinationBody), async (c) => {
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const destination = await notificationsService.createDestination(body.name, body.config);
|
||||||
|
return c.json<CreateDestinationDto>(destination, 201);
|
||||||
|
})
|
||||||
|
.get("/destinations/:id", getDestinationDto, async (c) => {
|
||||||
|
const id = Number.parseInt(c.req.param("id"), 10);
|
||||||
|
const destination = await notificationsService.getDestination(id);
|
||||||
|
return c.json<GetDestinationDto>(destination, 200);
|
||||||
|
})
|
||||||
|
.patch("/destinations/:id", updateDestinationDto, validator("json", updateDestinationBody), async (c) => {
|
||||||
|
const id = Number.parseInt(c.req.param("id"), 10);
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const destination = await notificationsService.updateDestination(id, body);
|
||||||
|
return c.json<UpdateDestinationDto>(destination, 200);
|
||||||
|
})
|
||||||
|
.delete("/destinations/:id", deleteDestinationDto, async (c) => {
|
||||||
|
const id = Number.parseInt(c.req.param("id"), 10);
|
||||||
|
await notificationsService.deleteDestination(id);
|
||||||
|
return c.json<DeleteDestinationDto>({ message: "Notification destination deleted" }, 200);
|
||||||
|
})
|
||||||
|
.post("/destinations/:id/test", testDestinationDto, async (c) => {
|
||||||
|
const id = Number.parseInt(c.req.param("id"), 10);
|
||||||
|
const result = await notificationsService.testDestination(id);
|
||||||
|
return c.json<TestDestinationDto>(result, 200);
|
||||||
|
});
|
||||||
251
app/server/modules/notifications/notifications.dto.ts
Normal file
251
app/server/modules/notifications/notifications.dto.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { type } from "arktype";
|
||||||
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
import { NOTIFICATION_TYPES, notificationConfigSchema } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification Destination Schema
|
||||||
|
*/
|
||||||
|
export const notificationDestinationSchema = type({
|
||||||
|
id: "number",
|
||||||
|
name: "string",
|
||||||
|
enabled: "boolean",
|
||||||
|
type: type.valueOf(NOTIFICATION_TYPES),
|
||||||
|
config: notificationConfigSchema,
|
||||||
|
createdAt: "number",
|
||||||
|
updatedAt: "number",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NotificationDestinationDto = typeof notificationDestinationSchema.infer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all notification destinations
|
||||||
|
*/
|
||||||
|
export const listDestinationsResponse = notificationDestinationSchema.array();
|
||||||
|
export type ListDestinationsDto = typeof listDestinationsResponse.infer;
|
||||||
|
|
||||||
|
export const listDestinationsDto = describeRoute({
|
||||||
|
description: "List all notification destinations",
|
||||||
|
tags: ["Notifications"],
|
||||||
|
operationId: "listNotificationDestinations",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "A list of notification destinations",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(listDestinationsResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification destination
|
||||||
|
*/
|
||||||
|
export const createDestinationBody = type({
|
||||||
|
name: "string",
|
||||||
|
config: notificationConfigSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDestinationResponse = notificationDestinationSchema;
|
||||||
|
export type CreateDestinationDto = typeof createDestinationResponse.infer;
|
||||||
|
|
||||||
|
export const createDestinationDto = describeRoute({
|
||||||
|
description: "Create a new notification destination",
|
||||||
|
operationId: "createNotificationDestination",
|
||||||
|
tags: ["Notifications"],
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "Notification destination created successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(createDestinationResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single notification destination
|
||||||
|
*/
|
||||||
|
export const getDestinationResponse = notificationDestinationSchema;
|
||||||
|
export type GetDestinationDto = typeof getDestinationResponse.infer;
|
||||||
|
|
||||||
|
export const getDestinationDto = describeRoute({
|
||||||
|
description: "Get a notification destination by ID",
|
||||||
|
operationId: "getNotificationDestination",
|
||||||
|
tags: ["Notifications"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Notification destination details",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(getDestinationResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Notification destination not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a notification destination
|
||||||
|
*/
|
||||||
|
export const updateDestinationBody = type({
|
||||||
|
"name?": "string",
|
||||||
|
"enabled?": "boolean",
|
||||||
|
"config?": notificationConfigSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateDestinationResponse = notificationDestinationSchema;
|
||||||
|
export type UpdateDestinationDto = typeof updateDestinationResponse.infer;
|
||||||
|
|
||||||
|
export const updateDestinationDto = describeRoute({
|
||||||
|
description: "Update a notification destination",
|
||||||
|
operationId: "updateNotificationDestination",
|
||||||
|
tags: ["Notifications"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Notification destination updated successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(updateDestinationResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Notification destination not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a notification destination
|
||||||
|
*/
|
||||||
|
export const deleteDestinationResponse = type({
|
||||||
|
message: "string",
|
||||||
|
});
|
||||||
|
export type DeleteDestinationDto = typeof deleteDestinationResponse.infer;
|
||||||
|
|
||||||
|
export const deleteDestinationDto = describeRoute({
|
||||||
|
description: "Delete a notification destination",
|
||||||
|
operationId: "deleteNotificationDestination",
|
||||||
|
tags: ["Notifications"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Notification destination deleted successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(deleteDestinationResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Notification destination not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a notification destination
|
||||||
|
*/
|
||||||
|
export const testDestinationResponse = type({
|
||||||
|
success: "boolean",
|
||||||
|
});
|
||||||
|
export type TestDestinationDto = typeof testDestinationResponse.infer;
|
||||||
|
|
||||||
|
export const testDestinationDto = describeRoute({
|
||||||
|
description: "Test a notification destination by sending a test message",
|
||||||
|
operationId: "testNotificationDestination",
|
||||||
|
tags: ["Notifications"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Test notification sent successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(testDestinationResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Notification destination not found",
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: "Cannot test disabled destination",
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Failed to send test notification",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup Schedule Notification Assignment Schema
|
||||||
|
*/
|
||||||
|
export const scheduleNotificationAssignmentSchema = type({
|
||||||
|
scheduleId: "number",
|
||||||
|
destinationId: "number",
|
||||||
|
notifyOnStart: "boolean",
|
||||||
|
notifyOnSuccess: "boolean",
|
||||||
|
notifyOnFailure: "boolean",
|
||||||
|
createdAt: "number",
|
||||||
|
destination: notificationDestinationSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ScheduleNotificationAssignmentDto = typeof scheduleNotificationAssignmentSchema.infer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notifications for a backup schedule
|
||||||
|
*/
|
||||||
|
export const getScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
|
||||||
|
export type GetScheduleNotificationsDto = typeof getScheduleNotificationsResponse.infer;
|
||||||
|
|
||||||
|
export const getScheduleNotificationsDto = describeRoute({
|
||||||
|
description: "Get notification assignments for a backup schedule",
|
||||||
|
operationId: "getScheduleNotifications",
|
||||||
|
tags: ["Backups", "Notifications"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of notification assignments for the schedule",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(getScheduleNotificationsResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notifications for a backup schedule
|
||||||
|
*/
|
||||||
|
export const updateScheduleNotificationsBody = type({
|
||||||
|
assignments: type({
|
||||||
|
destinationId: "number",
|
||||||
|
notifyOnStart: "boolean",
|
||||||
|
notifyOnSuccess: "boolean",
|
||||||
|
notifyOnFailure: "boolean",
|
||||||
|
}).array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
|
||||||
|
export type UpdateScheduleNotificationsDto = typeof updateScheduleNotificationsResponse.infer;
|
||||||
|
|
||||||
|
export const updateScheduleNotificationsDto = describeRoute({
|
||||||
|
description: "Update notification assignments for a backup schedule",
|
||||||
|
operationId: "updateScheduleNotifications",
|
||||||
|
tags: ["Backups", "Notifications"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Notification assignments updated successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(updateScheduleNotificationsResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
415
app/server/modules/notifications/notifications.service.ts
Normal file
415
app/server/modules/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
|
import slugify from "slugify";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
import {
|
||||||
|
notificationDestinationsTable,
|
||||||
|
backupScheduleNotificationsTable,
|
||||||
|
type NotificationDestination,
|
||||||
|
} from "../../db/schema";
|
||||||
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { sendNotification } from "../../utils/shoutrrr";
|
||||||
|
import { buildShoutrrrUrl } from "./builders";
|
||||||
|
import type { NotificationConfig, NotificationEvent } from "~/schemas/notifications";
|
||||||
|
import { toMessage } from "../../utils/errors";
|
||||||
|
|
||||||
|
const listDestinations = async () => {
|
||||||
|
const destinations = await db.query.notificationDestinationsTable.findMany({
|
||||||
|
orderBy: (destinations, { asc }) => [asc(destinations.name)],
|
||||||
|
});
|
||||||
|
return destinations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDestination = async (id: number) => {
|
||||||
|
const destination = await db.query.notificationDestinationsTable.findFirst({
|
||||||
|
where: eq(notificationDestinationsTable.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!destination) {
|
||||||
|
throw new NotFoundError("Notification destination not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return destination;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function encryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
|
||||||
|
switch (config.type) {
|
||||||
|
case "email":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
password: await cryptoUtils.encrypt(config.password),
|
||||||
|
};
|
||||||
|
case "slack":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
|
||||||
|
};
|
||||||
|
case "discord":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
|
||||||
|
};
|
||||||
|
case "gotify":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
token: await cryptoUtils.encrypt(config.token),
|
||||||
|
};
|
||||||
|
case "ntfy":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
token: config.token ? await cryptoUtils.encrypt(config.token) : undefined,
|
||||||
|
};
|
||||||
|
case "pushover":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
apiToken: await cryptoUtils.encrypt(config.apiToken),
|
||||||
|
};
|
||||||
|
case "custom":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
|
||||||
|
switch (config.type) {
|
||||||
|
case "email":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
password: await cryptoUtils.decrypt(config.password),
|
||||||
|
};
|
||||||
|
case "slack":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
|
||||||
|
};
|
||||||
|
case "discord":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
|
||||||
|
};
|
||||||
|
case "gotify":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
token: await cryptoUtils.decrypt(config.token),
|
||||||
|
};
|
||||||
|
case "ntfy":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
token: config.token ? await cryptoUtils.decrypt(config.token) : undefined,
|
||||||
|
};
|
||||||
|
case "pushover":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
apiToken: await cryptoUtils.decrypt(config.apiToken),
|
||||||
|
};
|
||||||
|
case "custom":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
shoutrrrUrl: await cryptoUtils.decrypt(config.shoutrrrUrl),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDestination = async (name: string, config: NotificationConfig) => {
|
||||||
|
const slug = slugify(name, { lower: true, strict: true });
|
||||||
|
|
||||||
|
const existing = await db.query.notificationDestinationsTable.findFirst({
|
||||||
|
where: eq(notificationDestinationsTable.name, slug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError("Notification destination with this name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedConfig = await encryptSensitiveFields(config);
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(notificationDestinationsTable)
|
||||||
|
.values({
|
||||||
|
name: slug,
|
||||||
|
type: config.type,
|
||||||
|
config: encryptedConfig,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new InternalServerError("Failed to create notification destination");
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDestination = async (
|
||||||
|
id: number,
|
||||||
|
updates: { name?: string; enabled?: boolean; config?: NotificationConfig },
|
||||||
|
) => {
|
||||||
|
const existing = await getDestination(id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError("Notification destination not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<NotificationDestination> = {
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (updates.name !== undefined) {
|
||||||
|
const slug = slugify(updates.name, { lower: true, strict: true });
|
||||||
|
|
||||||
|
const conflict = await db.query.notificationDestinationsTable.findFirst({
|
||||||
|
where: and(eq(notificationDestinationsTable.name, slug), eq(notificationDestinationsTable.id, id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflict && conflict.id !== id) {
|
||||||
|
throw new ConflictError("Notification destination with this name already exists");
|
||||||
|
}
|
||||||
|
updateData.name = slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.enabled !== undefined) {
|
||||||
|
updateData.enabled = updates.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.config !== undefined) {
|
||||||
|
const encryptedConfig = await encryptSensitiveFields(updates.config);
|
||||||
|
updateData.config = encryptedConfig;
|
||||||
|
updateData.type = updates.config.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(notificationDestinationsTable)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(notificationDestinationsTable.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new InternalServerError("Failed to update notification destination");
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDestination = async (id: number) => {
|
||||||
|
await db.delete(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDestination = async (id: number) => {
|
||||||
|
const destination = await getDestination(id);
|
||||||
|
|
||||||
|
if (!destination.enabled) {
|
||||||
|
throw new ConflictError("Cannot test disabled notification destination");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedConfig = await decryptSensitiveFields(destination.config);
|
||||||
|
|
||||||
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
||||||
|
|
||||||
|
console.log("Testing notification with Shoutrrr URL:", shoutrrrUrl);
|
||||||
|
|
||||||
|
const result = await sendNotification({
|
||||||
|
shoutrrrUrl,
|
||||||
|
title: "Zerobyte Test Notification",
|
||||||
|
body: `This is a test notification from Zerobyte for destination: ${destination.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new InternalServerError(`Failed to send test notification: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScheduleNotifications = async (scheduleId: number) => {
|
||||||
|
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
||||||
|
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
||||||
|
with: {
|
||||||
|
destination: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateScheduleNotifications = async (
|
||||||
|
scheduleId: number,
|
||||||
|
assignments: Array<{
|
||||||
|
destinationId: number;
|
||||||
|
notifyOnStart: boolean;
|
||||||
|
notifyOnSuccess: boolean;
|
||||||
|
notifyOnFailure: boolean;
|
||||||
|
}>,
|
||||||
|
) => {
|
||||||
|
await db.delete(backupScheduleNotificationsTable).where(eq(backupScheduleNotificationsTable.scheduleId, scheduleId));
|
||||||
|
|
||||||
|
if (assignments.length > 0) {
|
||||||
|
await db.insert(backupScheduleNotificationsTable).values(
|
||||||
|
assignments.map((assignment) => ({
|
||||||
|
scheduleId,
|
||||||
|
...assignment,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getScheduleNotifications(scheduleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendBackupNotification = async (
|
||||||
|
scheduleId: number,
|
||||||
|
event: NotificationEvent,
|
||||||
|
context: {
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
scheduleName?: string;
|
||||||
|
error?: string;
|
||||||
|
duration?: number;
|
||||||
|
filesProcessed?: number;
|
||||||
|
bytesProcessed?: string;
|
||||||
|
snapshotId?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
||||||
|
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
||||||
|
with: {
|
||||||
|
destination: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const relevantAssignments = assignments.filter((assignment) => {
|
||||||
|
if (!assignment.destination.enabled) return false;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case "start":
|
||||||
|
return assignment.notifyOnStart;
|
||||||
|
case "success":
|
||||||
|
return assignment.notifyOnSuccess;
|
||||||
|
case "failure":
|
||||||
|
return assignment.notifyOnFailure;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!relevantAssignments.length) {
|
||||||
|
logger.debug(`No notification destinations configured for backup ${scheduleId} event ${event}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, body } = buildNotificationMessage(event, context);
|
||||||
|
|
||||||
|
for (const assignment of relevantAssignments) {
|
||||||
|
try {
|
||||||
|
const decryptedConfig = await decryptSensitiveFields(assignment.destination.config);
|
||||||
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
||||||
|
|
||||||
|
const result = await sendNotification({
|
||||||
|
shoutrrrUrl,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(
|
||||||
|
`Notification sent successfully to ${assignment.destination.name} for backup ${scheduleId} event ${event}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`Failed to send notification to ${assignment.destination.name} for backup ${scheduleId}: ${result.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error sending notification to ${assignment.destination.name} for backup ${scheduleId}: ${toMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error processing backup notifications for schedule ${scheduleId}: ${toMessage(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildNotificationMessage(
|
||||||
|
event: NotificationEvent,
|
||||||
|
context: {
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
scheduleName?: string;
|
||||||
|
error?: string;
|
||||||
|
duration?: number;
|
||||||
|
filesProcessed?: number;
|
||||||
|
bytesProcessed?: string;
|
||||||
|
snapshotId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const date = new Date().toLocaleDateString();
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case "start":
|
||||||
|
return {
|
||||||
|
title: "🔵 Backup Started",
|
||||||
|
body: [
|
||||||
|
`Volume: ${context.volumeName}`,
|
||||||
|
`Repository: ${context.repositoryName}`,
|
||||||
|
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
||||||
|
`Time: ${date} - ${time}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "success":
|
||||||
|
return {
|
||||||
|
title: "✅ Backup Completed Successfully",
|
||||||
|
body: [
|
||||||
|
`Volume: ${context.volumeName}`,
|
||||||
|
`Repository: ${context.repositoryName}`,
|
||||||
|
context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null,
|
||||||
|
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null,
|
||||||
|
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
|
||||||
|
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
|
||||||
|
`Time: ${date} - ${time}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "failure":
|
||||||
|
return {
|
||||||
|
title: "❌ Backup Failed",
|
||||||
|
body: [
|
||||||
|
`Volume: ${context.volumeName}`,
|
||||||
|
`Repository: ${context.repositoryName}`,
|
||||||
|
context.error ? `Error: ${context.error}` : null,
|
||||||
|
`Time: ${date} - ${time}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: "Backup Notification",
|
||||||
|
body: `Volume: ${context.volumeName}\nRepository: ${context.repositoryName}\nTime: ${date} - ${time}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsService = {
|
||||||
|
listDestinations,
|
||||||
|
getDestination,
|
||||||
|
createDestination,
|
||||||
|
updateDestination,
|
||||||
|
deleteDestination,
|
||||||
|
testDestination,
|
||||||
|
getScheduleNotifications,
|
||||||
|
updateScheduleNotifications,
|
||||||
|
sendBackupNotification,
|
||||||
|
};
|
||||||
@@ -123,7 +123,8 @@ export const repositoriesController = new Hono()
|
|||||||
const { name, snapshotId } = c.req.param();
|
const { name, snapshotId } = c.req.param();
|
||||||
const { path } = c.req.valid("query");
|
const { path } = c.req.valid("query");
|
||||||
|
|
||||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
||||||
|
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
|
||||||
|
|
||||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export const restoreSnapshotBody = type({
|
|||||||
snapshotId: "string",
|
snapshotId: "string",
|
||||||
include: "string[]?",
|
include: "string[]?",
|
||||||
exclude: "string[]?",
|
exclude: "string[]?",
|
||||||
|
excludeXattr: "string[]?",
|
||||||
delete: "boolean?",
|
delete: "boolean?",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const listRepositories = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
|
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
|
||||||
const encryptedConfig: Record<string, string | boolean> = { ...config };
|
const encryptedConfig: Record<string, string | boolean | number> = { ...config };
|
||||||
|
|
||||||
if (config.customPassword) {
|
if (config.customPassword) {
|
||||||
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
|
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
|
||||||
@@ -41,6 +41,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||||||
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "sftp":
|
||||||
|
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return encryptedConfig as RepositoryConfig;
|
return encryptedConfig as RepositoryConfig;
|
||||||
@@ -190,7 +193,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
|
|||||||
const restoreSnapshot = async (
|
const restoreSnapshot = async (
|
||||||
name: string,
|
name: string,
|
||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
options?: { include?: string[]; exclude?: string[]; delete?: boolean },
|
options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const repository = await db.query.repositoriesTable.findFirst({
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
where: eq(repositoriesTable.name, name),
|
where: eq(repositoriesTable.name, name),
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
|||||||
const path = config.path ? `/${config.path}` : "";
|
const path = config.path ? `/${config.path}` : "";
|
||||||
return `rest:${config.url}${path}`;
|
return `rest:${config.url}${path}`;
|
||||||
}
|
}
|
||||||
|
case "sftp":
|
||||||
|
return `sftp:${config.user}@${config.host}:${config.path}`;
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
||||||
}
|
}
|
||||||
@@ -146,6 +148,43 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "sftp": {
|
||||||
|
const decryptedKey = await cryptoUtils.decrypt(config.privateKey);
|
||||||
|
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
|
||||||
|
|
||||||
|
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
||||||
|
if (!normalizedKey.endsWith("\n")) {
|
||||||
|
normalizedKey += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.includes("ENCRYPTED")) {
|
||||||
|
logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.");
|
||||||
|
throw new Error("Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
env._SFTP_KEY_PATH = keyPath;
|
||||||
|
|
||||||
|
const sshArgs = [
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"UserKnownHostsFile=/dev/null",
|
||||||
|
"-o",
|
||||||
|
"LogLevel=VERBOSE",
|
||||||
|
"-i",
|
||||||
|
keyPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.port && config.port !== 22) {
|
||||||
|
sshArgs.push("-p", String(config.port));
|
||||||
|
}
|
||||||
|
|
||||||
|
env._SFTP_SSH_ARGS = sshArgs.join(" ");
|
||||||
|
logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
@@ -160,7 +199,11 @@ const init = async (config: RepositoryConfig) => {
|
|||||||
|
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
const args = ["init", "--repo", repoUrl, "--json"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic init failed: ${res.stderr}`);
|
logger.error(`Restic init failed: ${res.stderr}`);
|
||||||
@@ -225,6 +268,7 @@ const backup = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const logData = throttle((data: string) => {
|
const logData = throttle((data: string) => {
|
||||||
@@ -265,6 +309,7 @@ const backup = async (
|
|||||||
},
|
},
|
||||||
finally: async () => {
|
finally: async () => {
|
||||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,6 +351,7 @@ const restore = async (
|
|||||||
options?: {
|
options?: {
|
||||||
include?: string[];
|
include?: string[];
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
|
excludeXattr?: string[];
|
||||||
path?: string;
|
path?: string;
|
||||||
delete?: boolean;
|
delete?: boolean;
|
||||||
},
|
},
|
||||||
@@ -335,11 +381,17 @@ const restore = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.excludeXattr && options.excludeXattr.length > 0) {
|
||||||
|
for (const xattr of options.excludeXattr) {
|
||||||
|
args.push("--exclude-xattr", xattr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||||
@@ -361,6 +413,7 @@ const restore = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(`Restic restore output last line: ${lastLine}`);
|
||||||
const resSummary = JSON.parse(lastLine);
|
const resSummary = JSON.parse(lastLine);
|
||||||
const result = restoreOutputSchema(resSummary);
|
const result = restoreOutputSchema(resSummary);
|
||||||
|
|
||||||
@@ -397,9 +450,11 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||||
@@ -445,9 +500,11 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args.push("--prune");
|
args.push("--prune");
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||||
@@ -462,8 +519,10 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
|||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||||
@@ -510,7 +569,10 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
args.push(path);
|
args.push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await safeSpawn({ command: "restic", args, env });
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||||
@@ -518,7 +580,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
||||||
const stdout = res.text();
|
const stdout = res.stdout;
|
||||||
const lines = stdout
|
const lines = stdout
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -557,7 +619,11 @@ const unlock = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
@@ -578,7 +644,10 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
|||||||
args.push("--read-data");
|
args.push("--read-data");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
const stdout = res.text();
|
const stdout = res.text();
|
||||||
const stderr = res.stderr.toString();
|
const stderr = res.stderr.toString();
|
||||||
@@ -608,7 +677,11 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
const args = ["repair", "index", "--repo", repoUrl];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
const stdout = res.text();
|
const stdout = res.text();
|
||||||
const stderr = res.stderr.toString();
|
const stderr = res.stderr.toString();
|
||||||
@@ -626,6 +699,22 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
||||||
|
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||||
|
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||||
|
if (config.backend === "sftp" && env._SFTP_KEY_PATH) {
|
||||||
|
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
|
||||||
|
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
|
||||||
|
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
|
||||||
|
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||||
|
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const restic = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
|
|||||||
43
app/server/utils/shoutrrr.ts
Normal file
43
app/server/utils/shoutrrr.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { safeSpawn } from "./spawn";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
import { toMessage } from "./errors";
|
||||||
|
|
||||||
|
export interface SendNotificationParams {
|
||||||
|
shoutrrrUrl: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNotification(params: SendNotificationParams) {
|
||||||
|
const { shoutrrrUrl, title, body } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = ["send", "--url", shoutrrrUrl, "--title", title, "--message", body];
|
||||||
|
|
||||||
|
logger.debug(`Sending notification via Shoutrrr: ${title}`);
|
||||||
|
|
||||||
|
const result = await safeSpawn({
|
||||||
|
command: "shoutrrr",
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
logger.debug(`Notification sent successfully: ${title}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = result.stderr || result.stdout || "Unknown error";
|
||||||
|
logger.error(`Failed to send notification: ${errorMessage}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = toMessage(error);
|
||||||
|
logger.error(`Error sending notification: ${errorMessage}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1
bun.lock
1
bun.lock
@@ -2,7 +2,6 @@
|
|||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@ironmount/client",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "^0.1.5",
|
"@hono/standard-validator": "^0.1.5",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
|||||||
BIN
screenshots/backup-details.webp
Normal file
BIN
screenshots/backup-details.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Reference in New Issue
Block a user