Feat/notifications alerts (#52)

* feat: notifications backend & creation

* feat: assign notification to backup schedule

* refactor: status dot one component

* chore(notification-details): remove refetchInterval
This commit is contained in:
Nico
2025-11-22 14:58:21 +01:00
committed by GitHub
parent 043f73ea87
commit 6c30e7e357
37 changed files with 3940 additions and 172 deletions

View File

@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
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, 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
@@ -703,6 +703,145 @@ export const runForgetMutation = (options?: Partial<Options<RunForgetData>>): Us
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);
/**

View File

@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, 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> & {
/**
@@ -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
*/

View File

@@ -1769,6 +1769,536 @@ export type 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: {
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' | '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: {
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' | '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: {
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' | 'slack';
updatedAt: number;
}>;
};
export type ListNotificationDestinationsResponse = ListNotificationDestinationsResponses[keyof ListNotificationDestinationsResponses];
export type CreateNotificationDestinationData = {
body?: {
config: {
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: {
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' | '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: {
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' | 'slack';
updatedAt: number;
};
};
export type GetNotificationDestinationResponse = GetNotificationDestinationResponses[keyof GetNotificationDestinationResponses];
export type UpdateNotificationDestinationData = {
body?: {
config?: {
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: {
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' | '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 = {
body?: never;
path?: never;

View File

@@ -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 {
Sidebar,
@@ -32,6 +32,11 @@ const items = [
url: "/backups",
icon: CalendarClock,
},
{
title: "Notifications",
url: "/notifications",
icon: Bell,
},
{
title: "Settings",
url: "/settings",
@@ -46,11 +51,7 @@ export function AppSidebar() {
<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">
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<img
src="/images/zerobyte.png"
alt="Zerobyte Logo"
className={cn("h-8 w-8 flex-shrink-0 object-contain -ml-2")}
/>
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
<span
className={cn("text-base transition-all duration-200 -ml-1", {
"opacity-0 w-0 overflow-hidden ": state === "collapsed",

View File

@@ -1,30 +1,42 @@
import type { VolumeStatus } from "~/client/lib/types";
import { cn } from "~/client/lib/utils";
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 = {
mounted: {
success: {
color: "bg-green-500",
colorLight: "bg-emerald-400",
animated: true,
animated: animated ?? true,
},
unmounted: {
neutral: {
color: "bg-gray-500",
colorLight: "bg-gray-400",
animated: false,
animated: animated ?? false,
},
error: {
color: "bg-red-500",
colorLight: "bg-amber-700",
animated: true,
colorLight: "bg-red-400",
animated: animated ?? true,
},
unknown: {
warning: {
color: "bg-yellow-500",
colorLight: "bg-yellow-400",
animated: true,
animated: animated ?? true,
},
}[status];
info: {
color: "bg-blue-500",
colorLight: "bg-blue-400",
animated: animated ?? true,
},
}[variant];
return (
<Tooltip>
@@ -42,7 +54,7 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
</span>
</TooltipTrigger>
<TooltipContent>
<p className="capitalize">{status}</p>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);

View File

@@ -3,6 +3,7 @@ import type {
GetMeResponse,
GetRepositoryResponse,
GetVolumeResponse,
ListNotificationDestinationsResponse,
ListSnapshotsResponse,
} from "../api-client";
@@ -17,3 +18,5 @@ export type Repository = GetRepositoryResponse;
export type BackupSchedule = GetBackupScheduleResponse;
export type Snapshot = ListSnapshotsResponse[number];
export type NotificationDestination = ListNotificationDestinationsResponse[number];

View File

@@ -1,7 +1,4 @@
import { cn } from "~/client/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
type BackupStatus = "active" | "paused" | "error" | "in_progress";
import { StatusDot } from "~/client/components/status-dot";
export const BackupStatusDot = ({
enabled,
@@ -12,60 +9,22 @@ export const BackupStatusDot = ({
hasError?: boolean;
isInProgress?: boolean;
}) => {
let status: BackupStatus = "paused";
let variant: "success" | "neutral" | "error" | "info";
let label: string;
if (isInProgress) {
status = "in_progress";
variant = "info";
label = "Backup in progress";
} else if (hasError) {
status = "error";
variant = "error";
label = "Error";
} else if (enabled) {
status = "active";
variant = "success";
label = "Active";
} else {
variant = "neutral";
label = "Paused";
}
const statusMapping = {
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>
);
return <StatusDot variant={variant} label={label} />;
};

View File

@@ -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>
);
};

View File

@@ -29,7 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary";
import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
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 = {
breadcrumb: (match: Route.MetaArgs) => [
@@ -49,11 +51,12 @@ export function meta(_: Route.MetaArgs) {
}
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) {
@@ -66,7 +69,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
initialData: loaderData,
initialData: loaderData.schedule,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
@@ -222,6 +225,9 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
setIsEditMode={setIsEditMode}
schedule={schedule}
/>
<div className={cn({ hidden: !loaderData.notifs?.length })}>
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
</div>
<SnapshotTimeline
loading={isLoading}
snapshots={snapshots ?? []}

View File

@@ -0,0 +1,526 @@
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,
},
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="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 === "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&nbsp;
<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>
&nbsp; for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
);
};

View File

@@ -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>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,176 @@
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="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>
);
}

View File

@@ -24,6 +24,7 @@ import { DockerTabContent } from "../tabs/docker";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client";
import type { VolumeStatus } from "~/client/lib/types";
import {
deleteVolumeMutation,
getVolumeOptions,
@@ -31,6 +32,16 @@ import {
unmountVolumeMutation,
} 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 = {
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="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground 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)}
/>
&nbsp;
{volume.status[0].toUpperCase() + volume.status.slice(1)}
</span>
<VolumeIcon size={14} backend={volume?.config.backend} />
</div>

View File

@@ -13,6 +13,17 @@ import { VolumeIcon } from "~/client/components/volume-icon";
import type { Route } from "./+types/volumes";
import { listVolumes } from "~/client/api-client";
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 = {
breadcrumb: () => [{ label: "Volumes" }],
@@ -157,7 +168,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
<StatusDot
variant={getVolumeStatusVariant(volume.status)}
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
/>
</TableCell>
</TableRow>
))