diff --git a/.gitignore b/.gitignore index 22378573b..76671db37 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,4 @@ nix/docker/wings/etc/ nix/docker/wings/lib/ nix/docker/maria/mariadb_data/ nix/mariadb/ -wings/ mariadb_data/ diff --git a/resources/scripts/components/server/backups/wings/BackupContextMenu.tsx b/resources/scripts/components/server/backups/wings/BackupContextMenu.tsx new file mode 100644 index 000000000..d8a59be94 --- /dev/null +++ b/resources/scripts/components/server/backups/wings/BackupContextMenu.tsx @@ -0,0 +1,513 @@ +import { + ArrowDownToLine, + Bars, + CloudArrowUpIn, + Pencil, + Shield, + TrashBin, + TriangleExclamation, +} from '@gravity-ui/icons'; +import { useStoreState } from 'easy-peasy'; +import { useEffect, useState } from 'react'; + +import FlashMessageRender from '@/components/FlashMessageRender'; +import ActionButton from '@/components/elements/ActionButton'; +import Can from '@/components/elements/Can'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/elements/DropdownMenu'; +import Spinner from '@/components/elements/Spinner'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import { Dialog } from '@/components/elements/dialog'; + +import http, { httpErrorToHuman } from '@/api/http'; +import { getServerBackupDownloadUrl } from '@/api/server/backups'; +import { ServerBackup } from '@/api/server/types'; + +import { ApplicationStore } from '@/state'; +import { ServerContext } from '@/state/server'; + +import useFlash from '@/plugins/useFlash'; + +import { useUnifiedBackups } from '../useUnifiedBackups'; +import { getGlobalDaemonType } from '@/api/server/getServer'; + +interface Props { + backup: ServerBackup; +} + +const BackupContextMenu = ({ backup }: Props) => { + const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + const daemonType = getGlobalDaemonType(); + const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState); + const [modal, setModal] = useState(''); + const [loading, setLoading] = useState(false); + const [countdown, setCountdown] = useState(5); + const [newName, setNewName] = useState(backup.name); + const [deletePassword, setDeletePassword] = useState(''); + const [deleteTotpCode, setDeleteTotpCode] = useState(''); + const [restorePassword, setRestorePassword] = useState(''); + const [restoreTotpCode, setRestoreTotpCode] = useState(''); + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); + const { deleteBackup, restoreBackup, renameBackup, toggleBackupLock, refresh } = useUnifiedBackups(); + const hasTwoFactor = useStoreState((state: ApplicationStore) => state.user.data?.useTotp || false); + + const doDownload = () => { + setLoading(true); + clearFlashes('backups'); + getServerBackupDownloadUrl(uuid, backup.uuid) + .then((url) => { + // @ts-expect-error this is valid + window.location = url; + }) + .catch((error) => { + clearAndAddHttpError({ key: 'backups', error }); + }) + .then(() => setLoading(false)); + }; + + const doDeletion = async () => { + if (!deletePassword) { + addFlash({ + key: 'backup:delete', + type: 'error', + message: 'Password is required to delete this backup.', + }); + return; + } + + if (hasTwoFactor && !deleteTotpCode) { + addFlash({ + key: 'backup:delete', + type: 'error', + message: 'Two-factor authentication code is required.', + }); + return; + } + + setLoading(true); + clearFlashes('backup:delete'); + + try { + await http.delete(`/api/client/servers/${daemonType}/${uuid}/backups/${backup.uuid}`, { + data: { + password: deletePassword, + ...(hasTwoFactor ? { totp_code: deleteTotpCode } : {}), + }, + }); + + setLoading(false); + setModal(''); + setDeletePassword(''); + setDeleteTotpCode(''); + + // Refresh the backup list to reflect the deletion + await refresh(); + } catch (error) { + clearAndAddHttpError({ key: 'backup:delete', error }); + setLoading(false); + } + }; + + const doRestorationAction = async () => { + if (!restorePassword) { + addFlash({ + key: 'backup:restore', + type: 'error', + message: 'Password is required to restore this backup.', + }); + return; + } + + if (hasTwoFactor && !restoreTotpCode) { + addFlash({ + key: 'backup:restore', + type: 'error', + message: 'Two-factor authentication code is required.', + }); + return; + } + + setLoading(true); + clearFlashes('backup:restore'); + + try { + await http.post(`/api/client/servers/${daemonType}/backups/${backup.uuid}/restore`, { + password: restorePassword, + ...(hasTwoFactor ? { totp_code: restoreTotpCode } : {}), + }); + + // Set server status to restoring + setServerFromState((s) => ({ + ...s, + status: 'restoring_backup', + })); + + setLoading(false); + setModal(''); + setRestorePassword(''); + setRestoreTotpCode(''); + } catch (error) { + clearAndAddHttpError({ key: 'backup:restore', error }); + setLoading(false); + } + }; + + const onLockToggle = async () => { + if (backup.isLocked && modal !== 'unlock') { + return setModal('unlock'); + } + + try { + await toggleBackupLock(backup.uuid); + setModal(''); + } catch (error) { + alert(httpErrorToHuman(error)); + } + }; + + const doRename = async () => { + setLoading(true); + clearFlashes('backups'); + + try { + await renameBackup(backup.uuid, newName.trim()); + setLoading(false); + setModal(''); + } catch (error) { + clearAndAddHttpError({ key: 'backups', error }); + setLoading(false); + setModal(''); + } + }; + + useEffect(() => { + let interval: NodeJS.Timeout; + if (modal === 'restore' && countdown > 0) { + interval = setInterval(() => { + setCountdown((prev) => prev - 1); + }, 1000); + } + return () => { + if (interval) clearInterval(interval); + }; + }, [modal, countdown]); + + useEffect(() => { + if (modal === 'restore') { + setCountdown(5); + } + }, [modal]); + + useEffect(() => { + if (modal === 'rename') { + setNewName(backup.name); + } + }, [modal, backup.name]); + + return ( + <> + setModal('')} title='Rename Backup'> + + + Backup Name + setNewName(e.target.value)} + className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 placeholder-zinc-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500' + placeholder='Enter backup name...' + maxLength={191} + /> + + + + + setModal('')} variant='secondary'> + Cancel + + + Rename Backup + + + + setModal('')} + title={`Unlock "${backup.name}"`} + onConfirmed={onLockToggle} + > + This backup will no longer be protected from automated or accidental deletions. + + { + setModal(''); + setRestorePassword(''); + setRestoreTotpCode(''); + }} + title='Restore Backup' + > + + + + "{backup.name}" + + Your server will be stopped during the restoration process. You will not be able to control + the power state, access the file manager, or create additional backups until completed. + + + + + + + + + Destructive Action - Complete Server Restore + + + All current files and server configuration will be deleted and replaced with the + backup data. This action cannot be undone. + + + + + + + + + Password + + setRestorePassword(e.target.value)} + disabled={loading} + /> + + + {hasTwoFactor && ( + + + Two-Factor Authentication Code + + setRestoreTotpCode(e.target.value.replace(/[^0-9]/g, ''))} + disabled={loading} + /> + + )} + + + + + { + setModal(''); + setRestorePassword(''); + setRestoreTotpCode(''); + }} + variant='secondary' + disabled={loading} + > + Cancel + + doRestorationAction()} + variant='danger' + disabled={countdown > 0 || loading} + > + {loading && } + {loading + ? 'Restoring...' + : countdown > 0 + ? `Delete All & Restore (${countdown}s)` + : 'Delete All & Restore Backup'} + + + + { + setModal(''); + setDeletePassword(''); + setDeleteTotpCode(''); + }} + title={`Delete "${backup.name}"`} + > + + + + This is a permanent operation. The backup cannot be recovered once deleted. + + + + + + + + + Warning + + The backup file and its snapshot will be permanently deleted. + + + + + + + + + Password + + setDeletePassword(e.target.value)} + disabled={loading} + /> + + + {hasTwoFactor && ( + + + Two-Factor Authentication Code + + setDeleteTotpCode(e.target.value.replace(/[^0-9]/g, ''))} + disabled={loading} + /> + + )} + + + + + { + setModal(''); + setDeletePassword(''); + setDeleteTotpCode(''); + }} + disabled={loading} + > + Cancel + + + {loading && } + {loading ? 'Deleting...' : 'Delete Backup'} + + + + + {backup.isSuccessful ? ( + + + + + + + + + + + + + Download + + + + setModal('restore')} className='cursor-pointer'> + + Restore + + + + + setModal('rename')} className='cursor-pointer'> + + Rename + + + + {backup.isLocked ? 'Unlock' : 'Lock'} + + {!backup.isLocked && ( + <> + + setModal('delete')} + className='cursor-pointer text-red-400 focus:text-red-300' + > + + Delete + + > + )} + + + + ) : ( + setModal('delete')} + disabled={loading} + className='flex items-center gap-2' + > + + Delete + + )} + > + ); +}; + +export default BackupContextMenu; diff --git a/resources/scripts/components/server/backups/wings/BackupItem.tsx b/resources/scripts/components/server/backups/wings/BackupItem.tsx new file mode 100644 index 000000000..c0d21d887 --- /dev/null +++ b/resources/scripts/components/server/backups/wings/BackupItem.tsx @@ -0,0 +1,134 @@ +import { Cloud, CloudArrowUpIn, Lock, File } from '@gravity-ui/icons'; + +import { format, formatDistanceToNow } from 'date-fns'; + +import Can from '@/components/elements/Can'; +import { ContextMenu, ContextMenuTrigger } from '@/components/elements/ContextMenu'; +import Spinner from '@/components/elements/Spinner'; +import { PageListItem } from '@/components/elements/pages/PageList'; +import { SocketEvent } from '@/components/server/events'; + +import { bytesToString } from '@/lib/formatters'; + +import { ServerBackup } from '@/api/server/types'; +import getServerBackups from '@/api/swr/getServerBackups'; + +// import Can from '@/components/elements/Can'; +import useWebsocketEvent from '@/plugins/useWebsocketEvent'; + +import BackupContextMenu from './BackupContextMenu'; + +interface Props { + backup: ServerBackup; +} + +const BackupItem = ({ backup }: Props) => { + const { mutate } = getServerBackups(); + + useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async (data) => { + try { + const parsed = JSON.parse(data); + + await mutate( + (data) => ({ + ...data!, + items: data!.items.map((b) => + b.uuid !== backup.uuid + ? b + : { + ...b, + isSuccessful: parsed.is_successful || true, + checksum: (parsed.checksum_type || '') + ':' + (parsed.checksum || ''), + bytes: parsed.file_size || 0, + completedAt: new Date(), + }, + ), + }), + false, + ); + } catch (e) { + console.warn(e); + } + }); + + const getStatusIcon = () => { + const isActive = backup.isInProgress === true || backup.isInProgress === false; + if (backup.completedAt === null) { + return ; + } + if (isActive) { + return ; + } else if (backup.isLocked) { + return ; + } else if (backup.isInProgress === true || backup.isSuccessful) { + return ; + } else { + return ; + } + }; + + return ( + + + + + + + {getStatusIcon()} + + + + {backup.name} + {backup.isAutomatic && ( + + Automatic + + )} + {backup.isLocked && ( + + Locked + + )} + + {backup.checksum && {backup.checksum}} + + + + + + {backup.completedAt && backup.bytes ? ( + <> + Size + {bytesToString(backup.bytes)} + > + ) : ( + <> + Size + - + > + )} + + + + Created + + {formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })} + + + + + + + + {!backup.completedAt ? <>> : } + + + + + ); +}; + +export default BackupItem;
"{backup.name}"
+ Your server will be stopped during the restoration process. You will not be able to control + the power state, access the file manager, or create additional backups until completed. +
+ All current files and server configuration will be deleted and replaced with the + backup data. This action cannot be undone. +
+ This is a permanent operation. The backup cannot be recovered once deleted. +
Warning
+ The backup file and its snapshot will be permanently deleted. +
{backup.checksum}
Size
{bytesToString(backup.bytes)}
-
Created
+ {formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })} +