mirror of
https://github.com/samuelncui/yatm.git
synced 2026-01-08 14:21:19 +00:00
feat: optimize frontend for mobile device
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
40
frontend/src/components/job-file-list-item.tsx
Normal file
40
frontend/src/components/job-file-list-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user