feat: optimize frontend for mobile device

This commit is contained in:
Samuel N Cui
2023-10-13 14:04:04 +08:00
parent baf0166957
commit b0505b4955
6 changed files with 199 additions and 126 deletions

View File

@@ -13,8 +13,6 @@ import { JobsBrowser, JobsType } from "./pages/jobs";
import "./app.less";
import { sleep } from "./tools";
import { Nullable } from "tsdef";
import { Job } from "./entity";
import { useEffect } from "react";
import { useState } from "react";
@@ -52,7 +50,15 @@ const App = () => {
return (
<div id="app">
<ThemeProvider theme={theme}>
<Tabs className="tabs" value={location.pathname.slice(1)} onChange={handleTabChange} indicatorColor="secondary">
<Tabs
className="tabs"
value={location.pathname.slice(1)}
onChange={handleTabChange}
indicatorColor="secondary"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
>
<Tab label="File" value={FileBrowserType} />
<Tab label="Backup" value={BackupType} />
<Tab label="Restore" value={RestoreType} />

View File

@@ -4,6 +4,7 @@ import format from "format-duration";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { styled } from "@mui/material/styles";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import ListItem from "@mui/material/ListItem";
@@ -28,6 +29,7 @@ import { formatFilesize, sleep } from "../tools";
import { JobCard } from "./job-card";
import { RefreshContext } from "../pages/jobs";
import { FileListItem } from "./job-file-list-item";
export const ArchiveCard = ({ job, state, display }: { job: Job; state: JobArchiveState; display: JobArchiveDisplay | null }): JSX.Element => {
const [fields, progress] = useMemo(() => {
@@ -191,66 +193,12 @@ const ArchiveViewFilesDialog = ({ sources }: { sources: SourceState[] }) => {
<Button size="small" onClick={handleClickOpen}>
View Files
</Button>
{open && <FileList handleClose={handleClose} sources={sources} />}
{open && <FileList title="View Files" onClose={handleClose} sources={sources} />}
</Fragment>
);
};
const FileList = memo(({ handleClose, sources }: { handleClose: () => void; sources: SourceState[] }) => {
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
useEffect(() => {
(async () => {
const idx = sources.findIndex((src) => src.status !== CopyStatus.SUBMITED && src.status !== CopyStatus.STAGED);
if (idx < 0) {
return;
}
await sleep(100);
console.log(idx, virtuosoRef);
if (!virtuosoRef.current) {
return;
}
virtuosoRef.current.scrollToIndex({ index: idx - 5, align: "center", behavior: "smooth" });
})();
}, [sources]);
return (
<Dialog open={true} onClose={handleClose} maxWidth={"lg"} fullWidth scroll="paper" sx={{ height: "100%" }} className="view-log-dialog">
<DialogTitle>View Files</DialogTitle>
<DialogContent dividers style={{ padding: 0 }}>
<Virtuoso
style={{ width: "100%", height: "100%" }}
totalCount={sources.length}
ref={virtuosoRef}
itemContent={(idx) => {
const src = sources[idx];
if (!src || !src.source) {
return null;
}
return (
<ListItem key={idx} component="div" disablePadding>
<ListItemText
primary={src.source.base + src.source.path.join("/")}
secondary={`Size: ${formatFilesize(src.size)} Status: ${CopyStatus[src.status]}`}
style={{ padding: 0, margin: 5 }}
/>
</ListItem>
);
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>
</DialogActions>
</Dialog>
);
});
const RollbackDialog = ({ jobID, state }: { jobID: bigint; state: JobArchiveState }) => {
const refresh = useContext(RefreshContext);
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
setOpen(true);
@@ -259,6 +207,18 @@ const RollbackDialog = ({ jobID, state }: { jobID: bigint; state: JobArchiveStat
setOpen(false);
};
return (
<Fragment>
<Button size="small" onClick={handleClickOpen}>
Rollback
</Button>
{open && <RollbackFileList onClose={handleClose} jobID={jobID} state={state} />}
</Fragment>
);
};
const RollbackFileList = ({ onClose, jobID, state }: { onClose: () => void; jobID: bigint; state: JobArchiveState }) => {
const refresh = useContext(RefreshContext);
const handleClickItem = useCallback(
async (idx: number) => {
const found = state.sources[idx];
@@ -277,53 +237,67 @@ const RollbackDialog = ({ jobID, state }: { jobID: bigint; state: JobArchiveStat
}
await cli.jobEditState({ id: jobID, state: { state: { oneofKind: "archive", archive: { ...state, sources } } } });
alert(`Rollback to file '${path}' success!`);
await refresh();
alert(`Rollback to file '${path}' success!`);
},
[state, refresh],
);
return (
<Fragment>
<Button size="small" onClick={handleClickOpen}>
Rollback
</Button>
{open && (
<Dialog open={true} onClose={handleClose} maxWidth={"lg"} fullWidth scroll="paper" sx={{ height: "100%" }} className="view-log-dialog">
<DialogTitle>Click File to Rollback</DialogTitle>
<DialogContent dividers style={{ padding: 0 }}>
<Virtuoso
style={{ width: "100%", height: "100%" }}
totalCount={state.sources.length}
itemContent={(idx) => {
const src = state.sources[idx];
if (!src || !src.source) {
return null;
}
return (
<ListItem key={idx} component="div" disablePadding>
<ListItemButton style={{ padding: 0 }} onClick={() => handleClickItem(idx)}>
<ListItemText
primary={src.source.base + src.source.path.join("/")}
secondary={`Size: ${formatFilesize(src.size)} Status: ${CopyStatus[src.status]}`}
style={{ padding: 0, margin: 5 }}
/>
</ListItemButton>
</ListItem>
);
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>
</DialogActions>
</Dialog>
)}
</Fragment>
);
return <FileList title="Click Rollback Target File" onClose={onClose} onClickItem={handleClickItem} sources={state.sources} />;
};
const FileList = memo(
({ onClose, onClickItem, title, sources }: { onClose: () => void; onClickItem?: (idx: number) => void; title: string; sources: SourceState[] }) => {
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
useEffect(() => {
(async () => {
const idx = sources.findIndex((src) => src.status !== CopyStatus.SUBMITED && src.status !== CopyStatus.STAGED);
if (idx < 0) {
return;
}
await sleep(100);
if (!virtuosoRef.current) {
return;
}
virtuosoRef.current.scrollToIndex({ index: idx, align: "center", behavior: "smooth" });
})();
}, [sources]);
return (
<Dialog open={true} onClose={onClose} maxWidth={"lg"} fullWidth scroll="paper" sx={{ height: "100%" }} className="view-log-dialog">
<DialogTitle>{title}</DialogTitle>
<DialogContent dividers style={{ padding: 0 }}>
<Virtuoso
style={{ width: "100%", height: "100%" }}
totalCount={sources.length}
ref={virtuosoRef}
itemContent={(idx) => {
const src = sources[idx];
if (!src || !src.source) {
return null;
}
return (
<FileListItem
src={{ path: src.source.base + src.source.path.join("/"), size: src.size, status: src.status }}
onClick={onClickItem ? () => onClickItem(idx) : undefined}
/>
);
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
},
);
function makeArchiveCopyingParam(jobID: bigint, param: JobArchiveCopyingParam): JobDispatchRequest {
return {
id: jobID,

View File

@@ -1,5 +1,6 @@
import { useCallback, useContext } from "react";
import { styled } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import Card from "@mui/material/Card";
import CardActions from "@mui/material/CardActions";
@@ -27,6 +28,8 @@ const DeleteJobButton = ({ jobID }: { jobID: bigint }) => {
);
};
const RightButtonsContainer = styled("div")({ marginLeft: "auto !important", marginRight: 0 });
export const JobCard = ({ job, detail, buttons }: { job: Job; detail?: JSX.Element; buttons?: JSX.Element }) => {
return (
<Card sx={{ textAlign: "left" }} className="job-detail">
@@ -40,10 +43,10 @@ export const JobCard = ({ job, detail, buttons }: { job: Job; detail?: JSX.Eleme
<Divider />
<CardActions>
<div>{buttons}</div>
<div style={{ marginLeft: "auto", marginRight: 0 }}>
<RightButtonsContainer>
<ViewLogDialog key="VIEW_LOG" jobID={job.id} />
<DeleteJobButton key="DELETE_JOB" jobID={job.id} />
</div>
</RightButtonsContainer>
</CardActions>
</Card>
);

View File

@@ -0,0 +1,40 @@
import { memo } from "react";
import { styled } from "@mui/material/styles";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import ListItemButton from "@mui/material/ListItemButton";
import { CopyStatus, SourceState } from "../entity";
import { formatFilesize } from "../tools";
const FileListItemText = styled(ListItemText)({ padding: 0, margin: 5, marginLeft: 10 });
const FileListItemButton = styled(ListItemButton)({ padding: 0 });
export interface FileState {
path: string;
status: CopyStatus;
size: bigint;
// message?: string;
}
export const FileListItem = memo(({ src, onClick, className }: { src?: FileState; onClick?: () => void; className?: string }) => {
if (!src) {
return null;
}
const text = <FileListItemText primary={src.path} secondary={`Size: ${formatFilesize(src.size)} Status: ${CopyStatus[src.status]}`} />;
if (!onClick) {
return (
<ListItem component="div" className={className} disablePadding>
{text}
</ListItem>
);
}
return (
<ListItem component="div" className={className} disablePadding>
<FileListItemButton onClick={onClick}>{text}</FileListItemButton>
</ListItem>
);
});

View File

@@ -1,9 +1,13 @@
import { Fragment, ChangeEvent, useState, useMemo, useContext } from "react";
import { Fragment, ChangeEvent, useState, useMemo, useContext, useCallback } from "react";
import format from "format-duration";
import { Virtuoso } from "react-virtuoso";
import { styled } from "@mui/material/styles";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import ListItemButton from "@mui/material/ListItemButton";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
@@ -17,7 +21,6 @@ import DialogTitle from "@mui/material/DialogTitle";
import LinearProgress from "@mui/material/LinearProgress";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { TreeView, TreeItem } from "@mui/x-tree-view";
import { cli } from "../api";
@@ -29,6 +32,7 @@ import { formatFilesize } from "../tools";
import { JobCard } from "./job-card";
import { RefreshContext } from "../pages/jobs";
import { FileListItem } from "./job-file-list-item";
const tapeStatusToColor = (status: CopyStatus): ChipProps["color"] => {
switch (status) {
@@ -121,7 +125,7 @@ export const RestoreCard = ({ job, state, display }: { job: Job; state: JobResto
</Grid>
))}
<Grid item xs={12} md={12}>
<Stack direction="row" spacing={1} style={{ flexWrap: "wrap" }}>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
{state.tapes.map((tape) => (
<Chip label={`${tape.barcode}: ${CopyStatus[tape.status]}`} color={tapeStatusToColor(tape.status)} variant="outlined" key={`${tape.tapeId}`} />
))}
@@ -202,6 +206,19 @@ const LoadTapeDialog = ({ job }: { job: Job }) => {
);
};
const TapeRow = styled(ListItemButton)({
padding: "0.2rem",
width: "100%",
cursor: "pointer",
});
const FileRow = styled(FileListItem)({ paddingLeft: "1rem" });
interface RowData {
type: "tape" | "file";
label: React.ReactNode;
opened?: boolean;
}
const RestoreViewFilesDialog = ({ tapes }: { tapes: RestoreTape[] }) => {
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
@@ -210,7 +227,50 @@ const RestoreViewFilesDialog = ({ tapes }: { tapes: RestoreTape[] }) => {
const handleClose = () => {
setOpen(false);
};
const counts = useMemo(() => tapes.map((tape) => tape.files.length), [tapes]);
const [openedTapeIDs, setOpenedTapeIDs] = useState<bigint[]>([]);
const clickTapeRow = useCallback(
(id: bigint, opened: boolean) => {
if (opened) {
setOpenedTapeIDs(openedTapeIDs.filter((tapeID) => tapeID !== id));
return;
}
setOpenedTapeIDs([...openedTapeIDs, id]);
return;
},
[openedTapeIDs, setOpenedTapeIDs],
);
const rows = useMemo(() => {
const rows: RowData[] = [];
for (const tape of tapes) {
const opened = openedTapeIDs.includes(tape.tapeId);
rows.push({
type: "tape",
label: (
<TapeRow onClick={() => clickTapeRow(tape.tapeId, opened)}>
{opened ? <ExpandMoreIcon /> : <ChevronRightIcon />}
{tape.barcode}
</TapeRow>
),
opened,
});
if (!opened) {
continue;
}
for (const file of tape.files) {
rows.push({
type: "file",
label: <FileRow src={{ path: file.tapePath, size: file.size, status: file.status }} />,
});
}
}
return rows;
}, [tapes, openedTapeIDs]);
return (
<Fragment>
@@ -220,29 +280,19 @@ const RestoreViewFilesDialog = ({ tapes }: { tapes: RestoreTape[] }) => {
{open && (
<Dialog open={true} onClose={handleClose} maxWidth={"lg"} fullWidth scroll="paper" sx={{ height: "100%" }} className="view-log-dialog">
<DialogTitle>View Files</DialogTitle>
<DialogContent dividers>
<TreeView defaultCollapseIcon={<ExpandMoreIcon />} defaultExpandIcon={<ChevronRightIcon />}>
{tapes.map((tape) => {
if (!tape.files) {
<DialogContent dividers style={{ padding: 0 }}>
<Virtuoso
style={{ width: "100%", height: "100%" }}
totalCount={rows.length}
itemContent={(idx) => {
const row = rows[idx];
if (!row) {
return null;
}
return (
<TreeItem label={tape.barcode} nodeId={`tape-${tape.tapeId}`}>
{tape.files.map((file) => (
<TreeItem
label={
<pre style={{ margin: 0 }}>
{file.tapePath} <b>{CopyStatus[file.status]}</b>
</pre>
}
nodeId={`file-${file.positionId}`}
/>
))}
</TreeItem>
);
})}
</TreeView>
return row.label;
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>

View File

@@ -118,7 +118,7 @@ export const JobsBrowser = () => {
<RefreshContext.Provider value={refresh}>
<Box className="browser-box">
<Grid className="browser-container" container>
<Grid className="browser" item xs={2}>
<Grid className="browser" item xs={12} md={2} sx={{ display: { xs: "none", md: "block" } }}>
<List
sx={{
width: "100%",
@@ -144,7 +144,7 @@ export const JobsBrowser = () => {
<ImportDatabaseDialog />
</List>
</Grid>
<Grid className="browser" item xs={10}>
<Grid className="browser" item xs={12} md={10}>
<div className="job-list">{jobs ? jobs.map((job) => <GetJobCard job={job} key={job.id.toString()} />) : <LinearProgress />}</div>
</Grid>
</Grid>