Support failed backup display on the frontend; use SWR for backup pages

This commit is contained in:
Dane Everitt 2020-08-20 21:44:33 -07:00
parent e3178ba6f0
commit 1e735cf0e8
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
11 changed files with 140 additions and 150 deletions

View file

@ -1,5 +1,6 @@
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
import http from '@/api/http'; import http from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/server/transformers';
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => { export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -1,32 +0,0 @@
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
export interface ServerBackup {
uuid: string;
name: string;
ignoredFiles: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
sha256Hash: attributes.sha256_hash,
bytes: attributes.bytes,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});
export default (uuid: string, page?: number | string): Promise<PaginatedResult<ServerBackup>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups`, { params: { page } })
.then(({ data }) => resolve({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
}))
.catch(reject);
});
};

View file

@ -0,0 +1,13 @@
import { FractalResponseData } from '@/api/http';
import { ServerBackup } from '@/api/server/types';
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
isSuccessful: attributes.is_successful,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
sha256Hash: attributes.sha256_hash,
bytes: attributes.bytes,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});

10
resources/scripts/api/server/types.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
export interface ServerBackup {
uuid: string;
isSuccessful: boolean;
name: string;
ignoredFiles: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}

View file

@ -0,0 +1,18 @@
import useSWR from 'swr';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/server/transformers';
import useServer from '@/plugins/useServer';
export default (page?: number | string) => {
const { uuid } = useServer();
return useSWR<PaginatedResult<ServerBackup>>([ 'server:backups', uuid, page ], async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
return ({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
});
});
};

View file

@ -1,38 +1,33 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import getServerBackups from '@/api/server/backups/getServerBackups';
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import CreateBackupButton from '@/components/server/backups/CreateBackupButton'; import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import BackupRow from '@/components/server/backups/BackupRow'; import BackupRow from '@/components/server/backups/BackupRow';
import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
export default () => { export default () => {
const { uuid, featureLimits, name: serverName } = useServer(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const { addError, clearFlashes } = useFlash(); const { featureLimits, name: serverName } = useServer();
const [ loading, setLoading ] = useState(true);
const backups = ServerContext.useStoreState(state => state.backups.data); const { data: backups, error, isValidating } = getServerBackups();
const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups);
useEffect(() => { useEffect(() => {
if (!error) {
clearFlashes('backups'); clearFlashes('backups');
getServerBackups(uuid)
.then(data => setBackups(data.items))
.catch(error => {
console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) });
})
.then(() => setLoading(false));
}, []);
if (backups.length === 0 && loading) { return;
}
clearAndAddHttpError({ error, key: 'backups' });
}, [ error ]);
if (!backups || (error && isValidating)) {
return <Spinner size={'large'} centered/>; return <Spinner size={'large'} centered/>;
} }
@ -42,13 +37,13 @@ export default () => {
<title>{serverName} | Backups</title> <title>{serverName} | Backups</title>
</Helmet> </Helmet>
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/> <FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
{!backups.length ? {!backups.items.length ?
<p css={tw`text-center text-sm text-neutral-400`}> <p css={tw`text-center text-sm text-neutral-400`}>
There are no backups stored for this server. There are no backups stored for this server.
</p> </p>
: :
<div> <div>
{backups.map((backup, index) => <BackupRow {backups.items.map((backup, index) => <BackupRow
key={backup.uuid} key={backup.uuid}
backup={backup} backup={backup}
css={index > 0 ? tw`mt-2` : undefined} css={index > 0 ? tw`mt-2` : undefined}
@ -61,12 +56,12 @@ export default () => {
</p> </p>
} }
<Can action={'backup.create'}> <Can action={'backup.create'}>
{(featureLimits.backups > 0 && backups.length > 0) && {(featureLimits.backups > 0 && backups.items.length > 0) &&
<p css={tw`text-center text-xs text-neutral-400 mt-2`}> <p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{backups.length} of {featureLimits.backups} backups have been created for this server. {backups.items.length} of {featureLimits.backups} backups have been created for this server.
</p> </p>
} }
{featureLimits.backups > 0 && featureLimits.backups !== backups.length && {featureLimits.backups > 0 && featureLimits.backups !== backups.items.length &&
<div css={tw`mt-6 flex justify-end`}> <div css={tw`mt-6 flex justify-end`}>
<CreateBackupButton/> <CreateBackupButton/>
</div> </div>

View file

@ -1,19 +1,18 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu'; import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl'; import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import { httpErrorToHuman } from '@/api/http';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import ChecksumModal from '@/components/server/backups/ChecksumModal'; import ChecksumModal from '@/components/server/backups/ChecksumModal';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import deleteBackup from '@/api/server/backups/deleteBackup'; import deleteBackup from '@/api/server/backups/deleteBackup';
import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal'; import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import tw from 'twin.macro'; import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -24,8 +23,8 @@ export default ({ backup }: Props) => {
const [ loading, setLoading ] = useState(false); const [ loading, setLoading ] = useState(false);
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const [ deleteVisible, setDeleteVisible ] = useState(false); const [ deleteVisible, setDeleteVisible ] = useState(false);
const { addError, clearFlashes } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const removeBackup = ServerContext.useStoreActions(actions => actions.backups.removeBackup); const { mutate } = getServerBackups();
const doDownload = () => { const doDownload = () => {
setLoading(true); setLoading(true);
@ -37,7 +36,7 @@ export default ({ backup }: Props) => {
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) }); clearAndAddHttpError({ key: 'backups', error });
}) })
.then(() => setLoading(false)); .then(() => setLoading(false));
}; };
@ -46,10 +45,15 @@ export default ({ backup }: Props) => {
setLoading(true); setLoading(true);
clearFlashes('backups'); clearFlashes('backups');
deleteBackup(uuid, backup.uuid) deleteBackup(uuid, backup.uuid)
.then(() => removeBackup(backup.uuid)) .then(() => {
mutate(data => ({
...data,
items: data.items.filter(b => b.uuid !== backup.uuid),
}), false);
})
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) }); clearAndAddHttpError({ key: 'backups', error });
setLoading(false); setLoading(false);
setDeleteVisible(false); setDeleteVisible(false);
}); });
@ -76,6 +80,7 @@ export default ({ backup }: Props) => {
be recovered once deleted. be recovered once deleted.
</ConfirmationModal> </ConfirmationModal>
<SpinnerOverlay visible={loading} fixed/> <SpinnerOverlay visible={loading} fixed/>
{backup.isSuccessful ?
<DropdownMenu <DropdownMenu
renderToggle={onClick => ( renderToggle={onClick => (
<button <button
@ -105,6 +110,14 @@ export default ({ backup }: Props) => {
</Can> </Can>
</div> </div>
</DropdownMenu> </DropdownMenu>
:
<button
onClick={() => setDeleteVisible(true)}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
}
</> </>
); );
}; };

View file

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns'; import { format, formatDistanceToNow } from 'date-fns';
@ -7,10 +6,11 @@ import Spinner from '@/components/elements/Spinner';
import { bytesToHuman } from '@/helpers'; import { bytesToHuman } from '@/helpers';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import BackupContextMenu from '@/components/server/backups/BackupContextMenu'; import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
import tw from 'twin.macro'; import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox'; import GreyRowBox from '@/components/elements/GreyRowBox';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -18,17 +18,22 @@ interface Props {
} }
export default ({ backup, className }: Props) => { export default ({ backup, className }: Props) => {
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup); const { mutate } = getServerBackups();
useWebsocketEvent(`backup completed:${backup.uuid}`, data => { useWebsocketEvent(`backup completed:${backup.uuid}`, data => {
try { try {
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
appendBackup({
...backup, mutate(data => ({
...data,
items: data.items.map(b => b.uuid !== backup.uuid ? b : ({
...b,
isSuccessful: parsed.is_successful || true,
sha256Hash: parsed.sha256_hash || '', sha256Hash: parsed.sha256_hash || '',
bytes: parsed.file_size || 0, bytes: parsed.file_size || 0,
completedAt: new Date(), completedAt: new Date(),
}); })),
}), false);
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
} }
@ -45,8 +50,13 @@ export default ({ backup, className }: Props) => {
</div> </div>
<div css={tw`flex-1`}> <div css={tw`flex-1`}>
<p css={tw`text-sm mb-1`}> <p css={tw`text-sm mb-1`}>
{!backup.isSuccessful &&
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
Failed
</span>
}
{backup.name} {backup.name}
{backup.completedAt && {(backup.completedAt && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span> <span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
} }
</p> </p>

View file

@ -7,12 +7,11 @@ import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import createServerBackup from '@/api/server/backups/createServerBackup'; import createServerBackup from '@/api/server/backups/createServerBackup';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input'; import { Textarea } from '@/components/elements/Input';
import getServerBackups from '@/api/swr/getServerBackups';
interface Values { interface Values {
name: string; name: string;
@ -60,10 +59,9 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
export default () => { export default () => {
const { uuid } = useServer(); const { uuid } = useServer();
const { addError, clearFlashes } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const { mutate } = getServerBackups();
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
useEffect(() => { useEffect(() => {
clearFlashes('backups:create'); clearFlashes('backups:create');
@ -73,12 +71,11 @@ export default () => {
clearFlashes('backups:create'); clearFlashes('backups:create');
createServerBackup(uuid, name, ignored) createServerBackup(uuid, name, ignored)
.then(backup => { .then(backup => {
appendBackup(backup); mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
setVisible(false); setVisible(false);
}) })
.catch(error => { .catch(error => {
console.error(error); clearAndAddHttpError({ key: 'backups:create', error });
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
setSubmitting(false); setSubmitting(false);
}); });
}; };

View file

@ -1,31 +0,0 @@
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { action, Action } from 'easy-peasy';
export interface ServerBackupStore {
data: ServerBackup[];
setBackups: Action<ServerBackupStore, ServerBackup[]>;
appendBackup: Action<ServerBackupStore, ServerBackup>;
removeBackup: Action<ServerBackupStore, string>;
}
const backups: ServerBackupStore = {
data: [],
setBackups: action((state, payload) => {
state.data = payload;
}),
appendBackup: action((state, payload) => {
if (state.data.find(backup => backup.uuid === payload.uuid)) {
state.data = state.data.map(backup => backup.uuid === payload.uuid ? payload : backup);
} else {
state.data = [ ...state.data, payload ];
}
}),
removeBackup: action((state, payload) => {
state.data = [ ...state.data.filter(backup => backup.uuid !== payload) ];
}),
};
export default backups;

View file

@ -4,7 +4,6 @@ import socket, { SocketStore } from './socket';
import files, { ServerFileStore } from '@/state/server/files'; import files, { ServerFileStore } from '@/state/server/files';
import subusers, { ServerSubuserStore } from '@/state/server/subusers'; import subusers, { ServerSubuserStore } from '@/state/server/subusers';
import { composeWithDevTools } from 'redux-devtools-extension'; import { composeWithDevTools } from 'redux-devtools-extension';
import backups, { ServerBackupStore } from '@/state/server/backups';
import schedules, { ServerScheduleStore } from '@/state/server/schedules'; import schedules, { ServerScheduleStore } from '@/state/server/schedules';
import databases, { ServerDatabaseStore } from '@/state/server/databases'; import databases, { ServerDatabaseStore } from '@/state/server/databases';
@ -56,7 +55,6 @@ export interface ServerStore {
databases: ServerDatabaseStore; databases: ServerDatabaseStore;
files: ServerFileStore; files: ServerFileStore;
schedules: ServerScheduleStore; schedules: ServerScheduleStore;
backups: ServerBackupStore;
socket: SocketStore; socket: SocketStore;
status: ServerStatusStore; status: ServerStatusStore;
clearServerState: Action<ServerStore>; clearServerState: Action<ServerStore>;
@ -69,7 +67,6 @@ export const ServerContext = createContextStore<ServerStore>({
databases, databases,
files, files,
subusers, subusers,
backups,
schedules, schedules,
clearServerState: action(state => { clearServerState: action(state => {
state.server.data = undefined; state.server.data = undefined;
@ -78,7 +75,6 @@ export const ServerContext = createContextStore<ServerStore>({
state.subusers.data = []; state.subusers.data = [];
state.files.directory = '/'; state.files.directory = '/';
state.files.selectedFiles = []; state.files.selectedFiles = [];
state.backups.data = [];
state.schedules.data = []; state.schedules.data = [];
if (state.socket.instance) { if (state.socket.instance) {