feat(frontend): Add ability to list backups and create a backup on demand.

This commit is contained in:
Elisiário Couto
2025-09-28 23:22:36 +01:00
committed by Elisiário Couto
parent 222bb2ec64
commit 473f126d3e
4 changed files with 270 additions and 153 deletions

View File

@@ -73,11 +73,16 @@ export default function S3BackupConfigDrawer({
service: "s3", service: "s3",
config: config, config: config,
}), }),
onSuccess: () => { onSuccess: (response) => {
if (response.success) {
console.log("S3 connection test successful"); console.log("S3 connection test successful");
toast.success( toast.success(
"S3 connection test successful! Your configuration is working correctly.", "S3 connection test successful! Your configuration is working correctly.",
); );
} else {
console.error("S3 connection test failed:", response.message);
toast.error(response.message || "S3 connection test failed. Please verify your credentials and settings.");
}
}, },
onError: (error: Error & { response?: { data?: { detail?: string } } }) => { onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to test S3 connection:", error); console.error("Failed to test S3 connection:", error);

View File

@@ -17,7 +17,10 @@ import {
User, User,
Filter, Filter,
Cloud, Cloud,
Archive,
Eye,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils"; import { formatCurrency, formatDate } from "../lib/utils";
import { import {
@@ -43,6 +46,7 @@ import type {
NotificationSettings, NotificationSettings,
NotificationService, NotificationService,
BackupSettings, BackupSettings,
BackupInfo,
} from "../types/api"; } from "../types/api";
// Helper function to get status indicator color and styles // Helper function to get status indicator color and styles
@@ -83,6 +87,7 @@ export default function Settings() {
const [editingAccountId, setEditingAccountId] = useState<string | null>(null); const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set()); const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const [showBackups, setShowBackups] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -139,6 +144,17 @@ export default function Settings() {
queryFn: apiClient.getBackupSettings, queryFn: apiClient.getBackupSettings,
}); });
const {
data: backups,
isLoading: backupsLoading,
error: backupsError,
refetch: refetchBackups,
} = useQuery<BackupInfo[]>({
queryKey: ["backups"],
queryFn: apiClient.listBackups,
enabled: showBackups,
});
// Account mutations // Account mutations
const updateAccountMutation = useMutation({ const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) => mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
@@ -172,6 +188,26 @@ export default function Settings() {
}, },
}); });
// Backup mutations
const createBackupMutation = useMutation({
mutationFn: () => apiClient.performBackupOperation({ operation: "backup" }),
onSuccess: (response) => {
if (response.success) {
toast.success(response.message || "Backup created successfully!");
queryClient.invalidateQueries({ queryKey: ["backups"] });
} else {
toast.error(response.message || "Failed to create backup.");
}
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to create backup:", error);
const message =
error?.response?.data?.detail ||
"Failed to create backup. Please check your S3 configuration.";
toast.error(message);
},
});
// Account handlers // Account handlers
const handleEditStart = (account: Account) => { const handleEditStart = (account: Account) => {
setEditingAccountId(account.id); setEditingAccountId(account.id);
@@ -203,6 +239,23 @@ export default function Settings() {
} }
}; };
// Backup handlers
const handleCreateBackup = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
createBackupMutation.mutate();
};
const handleViewBackups = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
setShowBackups(true);
};
const isLoading = const isLoading =
accountsLoading || settingsLoading || servicesLoading || backupLoading; accountsLoading || settingsLoading || servicesLoading || backupLoading;
const hasError = const hasError =
@@ -836,25 +889,82 @@ export default function Settings() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={handleCreateBackup}
// TODO: Implement manual backup trigger disabled={createBackupMutation.isPending}
console.log("Manual backup triggered");
}}
> >
{createBackupMutation.isPending ? (
<>
<Archive className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Create Backup Now Create Backup Now
</>
)}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={handleViewBackups}
// TODO: Implement backup list view
console.log("View backups");
}}
> >
<Eye className="h-4 w-4 mr-2" />
View Backups View Backups
</Button> </Button>
</div> </div>
</div> </div>
{/* Backup List Modal/View */}
{showBackups && (
<div className="mt-6 p-4 border rounded-lg bg-background">
<div className="flex items-center justify-between mb-4">
<h5 className="font-medium">Available Backups</h5>
<Button
size="sm"
variant="ghost"
onClick={() => setShowBackups(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{backupsLoading ? (
<p className="text-sm text-muted-foreground">Loading backups...</p>
) : backupsError ? (
<div className="space-y-2">
<p className="text-sm text-destructive">Failed to load backups</p>
<Button
size="sm"
variant="outline"
onClick={() => refetchBackups()}
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
) : !backups || backups.length === 0 ? (
<p className="text-sm text-muted-foreground">No backups found</p>
) : (
<div className="space-y-2">
{backups.map((backup, index) => (
<div
key={backup.key || index}
className="flex items-center justify-between p-3 border rounded bg-muted/50"
>
<div>
<p className="text-sm font-medium">{backup.key}</p>
<div className="flex items-center space-x-4 text-xs text-muted-foreground mt-1">
<span>Modified: {formatDate(backup.last_modified)}</span>
<span>Size: {(backup.size / 1024 / 1024).toFixed(2)} MB</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -296,8 +296,9 @@ export const apiClient = {
return response.data.data; return response.data.data;
}, },
testBackupConnection: async (test: BackupTest): Promise<void> => { testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => {
await api.post("/backup/test", test); const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test);
return response.data;
}, },
listBackups: async (): Promise<BackupInfo[]> => { listBackups: async (): Promise<BackupInfo[]> => {
@@ -305,8 +306,9 @@ export const apiClient = {
return response.data.data; return response.data.data;
}, },
performBackupOperation: async (operation: BackupOperation): Promise<void> => { performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => {
await api.post("/backup/operation", operation); const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation);
return response.data;
}, },
}; };

View File

@@ -8,170 +8,170 @@
// You should NOT make any changes in this file as it will be overwritten. // You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root"; import { Route as rootRouteImport } from './routes/__root'
import { Route as TransactionsRouteImport } from "./routes/transactions"; import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as SystemRouteImport } from "./routes/system"; import { Route as SystemRouteImport } from './routes/system'
import { Route as SettingsRouteImport } from "./routes/settings"; import { Route as SettingsRouteImport } from './routes/settings'
import { Route as NotificationsRouteImport } from "./routes/notifications"; import { Route as NotificationsRouteImport } from './routes/notifications'
import { Route as BankConnectedRouteImport } from "./routes/bank-connected"; import { Route as BankConnectedRouteImport } from './routes/bank-connected'
import { Route as AnalyticsRouteImport } from "./routes/analytics"; import { Route as AnalyticsRouteImport } from './routes/analytics'
import { Route as IndexRouteImport } from "./routes/index"; import { Route as IndexRouteImport } from './routes/index'
const TransactionsRoute = TransactionsRouteImport.update({ const TransactionsRoute = TransactionsRouteImport.update({
id: "/transactions", id: '/transactions',
path: "/transactions", path: '/transactions',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
const SystemRoute = SystemRouteImport.update({ const SystemRoute = SystemRouteImport.update({
id: "/system", id: '/system',
path: "/system", path: '/system',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
const SettingsRoute = SettingsRouteImport.update({ const SettingsRoute = SettingsRouteImport.update({
id: "/settings", id: '/settings',
path: "/settings", path: '/settings',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
const NotificationsRoute = NotificationsRouteImport.update({ const NotificationsRoute = NotificationsRouteImport.update({
id: "/notifications", id: '/notifications',
path: "/notifications", path: '/notifications',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
const BankConnectedRoute = BankConnectedRouteImport.update({ const BankConnectedRoute = BankConnectedRouteImport.update({
id: "/bank-connected", id: '/bank-connected',
path: "/bank-connected", path: '/bank-connected',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
const AnalyticsRoute = AnalyticsRouteImport.update({ const AnalyticsRoute = AnalyticsRouteImport.update({
id: "/analytics", id: '/analytics',
path: "/analytics", path: '/analytics',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: "/", id: '/',
path: "/", path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
"/": typeof IndexRoute; '/': typeof IndexRoute
"/analytics": typeof AnalyticsRoute; '/analytics': typeof AnalyticsRoute
"/bank-connected": typeof BankConnectedRoute; '/bank-connected': typeof BankConnectedRoute
"/notifications": typeof NotificationsRoute; '/notifications': typeof NotificationsRoute
"/settings": typeof SettingsRoute; '/settings': typeof SettingsRoute
"/system": typeof SystemRoute; '/system': typeof SystemRoute
"/transactions": typeof TransactionsRoute; '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
"/": typeof IndexRoute; '/': typeof IndexRoute
"/analytics": typeof AnalyticsRoute; '/analytics': typeof AnalyticsRoute
"/bank-connected": typeof BankConnectedRoute; '/bank-connected': typeof BankConnectedRoute
"/notifications": typeof NotificationsRoute; '/notifications': typeof NotificationsRoute
"/settings": typeof SettingsRoute; '/settings': typeof SettingsRoute
"/system": typeof SystemRoute; '/system': typeof SystemRoute
"/transactions": typeof TransactionsRoute; '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport; __root__: typeof rootRouteImport
"/": typeof IndexRoute; '/': typeof IndexRoute
"/analytics": typeof AnalyticsRoute; '/analytics': typeof AnalyticsRoute
"/bank-connected": typeof BankConnectedRoute; '/bank-connected': typeof BankConnectedRoute
"/notifications": typeof NotificationsRoute; '/notifications': typeof NotificationsRoute
"/settings": typeof SettingsRoute; '/settings': typeof SettingsRoute
"/system": typeof SystemRoute; '/system': typeof SystemRoute
"/transactions": typeof TransactionsRoute; '/transactions': typeof TransactionsRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath; fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| "/" | '/'
| "/analytics" | '/analytics'
| "/bank-connected" | '/bank-connected'
| "/notifications" | '/notifications'
| "/settings" | '/settings'
| "/system" | '/system'
| "/transactions"; | '/transactions'
fileRoutesByTo: FileRoutesByTo; fileRoutesByTo: FileRoutesByTo
to: to:
| "/" | '/'
| "/analytics" | '/analytics'
| "/bank-connected" | '/bank-connected'
| "/notifications" | '/notifications'
| "/settings" | '/settings'
| "/system" | '/system'
| "/transactions"; | '/transactions'
id: id:
| "__root__" | '__root__'
| "/" | '/'
| "/analytics" | '/analytics'
| "/bank-connected" | '/bank-connected'
| "/notifications" | '/notifications'
| "/settings" | '/settings'
| "/system" | '/system'
| "/transactions"; | '/transactions'
fileRoutesById: FileRoutesById; fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute; IndexRoute: typeof IndexRoute
AnalyticsRoute: typeof AnalyticsRoute; AnalyticsRoute: typeof AnalyticsRoute
BankConnectedRoute: typeof BankConnectedRoute; BankConnectedRoute: typeof BankConnectedRoute
NotificationsRoute: typeof NotificationsRoute; NotificationsRoute: typeof NotificationsRoute
SettingsRoute: typeof SettingsRoute; SettingsRoute: typeof SettingsRoute
SystemRoute: typeof SystemRoute; SystemRoute: typeof SystemRoute
TransactionsRoute: typeof TransactionsRoute; TransactionsRoute: typeof TransactionsRoute
} }
declare module "@tanstack/react-router" { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
"/transactions": { '/transactions': {
id: "/transactions"; id: '/transactions'
path: "/transactions"; path: '/transactions'
fullPath: "/transactions"; fullPath: '/transactions'
preLoaderRoute: typeof TransactionsRouteImport; preLoaderRoute: typeof TransactionsRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
"/system": { '/system': {
id: "/system"; id: '/system'
path: "/system"; path: '/system'
fullPath: "/system"; fullPath: '/system'
preLoaderRoute: typeof SystemRouteImport; preLoaderRoute: typeof SystemRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
"/settings": { '/settings': {
id: "/settings"; id: '/settings'
path: "/settings"; path: '/settings'
fullPath: "/settings"; fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport; preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
"/notifications": { '/notifications': {
id: "/notifications"; id: '/notifications'
path: "/notifications"; path: '/notifications'
fullPath: "/notifications"; fullPath: '/notifications'
preLoaderRoute: typeof NotificationsRouteImport; preLoaderRoute: typeof NotificationsRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
"/bank-connected": { '/bank-connected': {
id: "/bank-connected"; id: '/bank-connected'
path: "/bank-connected"; path: '/bank-connected'
fullPath: "/bank-connected"; fullPath: '/bank-connected'
preLoaderRoute: typeof BankConnectedRouteImport; preLoaderRoute: typeof BankConnectedRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
"/analytics": { '/analytics': {
id: "/analytics"; id: '/analytics'
path: "/analytics"; path: '/analytics'
fullPath: "/analytics"; fullPath: '/analytics'
preLoaderRoute: typeof AnalyticsRouteImport; preLoaderRoute: typeof AnalyticsRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
"/": { '/': {
id: "/"; id: '/'
path: "/"; path: '/'
fullPath: "/"; fullPath: '/'
preLoaderRoute: typeof IndexRouteImport; preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
} }
} }
@@ -183,7 +183,7 @@ const rootRouteChildren: RootRouteChildren = {
SettingsRoute: SettingsRoute, SettingsRoute: SettingsRoute,
SystemRoute: SystemRoute, SystemRoute: SystemRoute,
TransactionsRoute: TransactionsRoute, TransactionsRoute: TransactionsRoute,
}; }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>(); ._addFileTypes<FileRouteTypes>()