first commit
This commit is contained in:
167
src/component/Admin/Node/EditNode/BasicInfoSection.tsx
Executable file
167
src/component/Admin/Node/EditNode/BasicInfoSection.tsx
Executable file
@@ -0,0 +1,167 @@
|
||||
import { Alert, FormControl, FormControlLabel, Switch, Typography } from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { testNode } from "../../../../api/api";
|
||||
import { Node, NodeStatus, NodeType } from "../../../../api/dashboard";
|
||||
import { useAppDispatch } from "../../../../redux/hooks";
|
||||
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
|
||||
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents";
|
||||
import SettingForm from "../../../Pages/Setting/SettingForm";
|
||||
import { Code } from "../../../Common/Code.tsx";
|
||||
import { EndpointInput } from "../../Common/EndpointInput";
|
||||
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
|
||||
import { NodeSettingContext } from "./NodeSettingWrapper";
|
||||
const BasicInfoSection = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const { values, setNode } = useContext(NodeSettingContext);
|
||||
const [testNodeLoading, setTestNodeLoading] = useState(false);
|
||||
|
||||
const onNameChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({ ...p, name: e.target.value }));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onServerChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({ ...p, server: e.target.value }));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onSlaveKeyChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({ ...p, slave_key: e.target.value }));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onWeightChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const weight = parseInt(e.target.value);
|
||||
setNode((p: Node) => ({ ...p, weight: isNaN(weight) ? 1 : weight }));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
status: e.target.checked ? NodeStatus.active : NodeStatus.suspended,
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const isActive = useMemo(() => {
|
||||
return values.status === NodeStatus.active;
|
||||
}, [values.status]);
|
||||
|
||||
const nodeTypeText = useMemo(() => {
|
||||
return values.type === NodeType.master ? t("node.master") : t("node.slave");
|
||||
}, [values.type, t]);
|
||||
|
||||
const onTestNode = useCallback(() => {
|
||||
setTestNodeLoading(true);
|
||||
dispatch(testNode({ node: values }))
|
||||
.then((res) => {
|
||||
enqueueSnackbar(t("node.testNodeSuccess"), { variant: "success", action: DefaultCloseAction });
|
||||
})
|
||||
.finally(() => {
|
||||
setTestNodeLoading(false);
|
||||
});
|
||||
}, [dispatch, values]);
|
||||
|
||||
return (
|
||||
<SettingSection>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("policy.basicInfo")}
|
||||
</Typography>
|
||||
<SettingSectionContent>
|
||||
{values.type === NodeType.master && (
|
||||
<SettingForm lgWidth={5}>
|
||||
<Alert severity="info">{t("node.thisIsMasterNodes")}</Alert>
|
||||
</SettingForm>
|
||||
)}
|
||||
<SettingForm lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
disabled={values.type === NodeType.master}
|
||||
control={<Switch checked={isActive} onChange={onStatusChange} />}
|
||||
label={t("node.enableNode")}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.enableNodeDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("node.name")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField required value={values.name} onChange={onNameChange} />
|
||||
<NoMarginHelperText>{t("node.nameNode")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("node.type")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField disabled value={nodeTypeText} />
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
{values.type === NodeType.slave && (
|
||||
<>
|
||||
<SettingForm title={t("node.server")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<EndpointInput
|
||||
fullWidth
|
||||
enforceProtocol
|
||||
required
|
||||
value={values.server}
|
||||
onChange={onServerChange}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.serverDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("node.slaveSecret")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField required value={values.slave_key} onChange={onSlaveKeyChange} />
|
||||
<NoMarginHelperText>
|
||||
<Trans i18nKey="node.slaveSecretDes" ns="dashboard" components={[<Code />, <Code />]} />
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
</>
|
||||
)}
|
||||
<SettingForm title={t("node.loadBalancerRank")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
type="number"
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputProps: {
|
||||
min: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
required
|
||||
value={values.weight}
|
||||
onChange={onWeightChange}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.loadBalancerRankDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
{values.type === NodeType.slave && (
|
||||
<SettingForm lgWidth={5}>
|
||||
<SecondaryButton loading={testNodeLoading} variant="contained" onClick={onTestNode}>
|
||||
{t("node.testNode")}
|
||||
</SecondaryButton>
|
||||
</SettingForm>
|
||||
)}
|
||||
</SettingSectionContent>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoSection;
|
||||
568
src/component/Admin/Node/EditNode/CapabilitiesSection.tsx
Executable file
568
src/component/Admin/Node/EditNode/CapabilitiesSection.tsx
Executable file
@@ -0,0 +1,568 @@
|
||||
import {
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Link,
|
||||
ListItemText,
|
||||
SelectChangeEvent,
|
||||
Switch,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { testNodeDownloader } from "../../../../api/api";
|
||||
import { DownloaderProvider, Node, NodeType } from "../../../../api/dashboard";
|
||||
import { NodeCapability } from "../../../../api/workflow";
|
||||
import { useAppDispatch } from "../../../../redux/hooks";
|
||||
import Boolset from "../../../../util/boolset";
|
||||
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
|
||||
import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../../Common/StyledComponents";
|
||||
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
|
||||
import QuestionCircle from "../../../Icons/QuestionCircle";
|
||||
import SettingForm from "../../../Pages/Setting/SettingForm";
|
||||
import { Code } from "../../../Common/Code.tsx";
|
||||
import { EndpointInput } from "../../Common/EndpointInput";
|
||||
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
|
||||
import { NodeSettingContext } from "./NodeSettingWrapper";
|
||||
import StoreFilesHintDialog from "./StoreFilesHintDialog";
|
||||
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor"));
|
||||
|
||||
const CapabilitiesSection = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { values, setNode } = useContext(NodeSettingContext);
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [editedConfigAria2, setEditedConfigAria2] = useState("");
|
||||
const [editedConfigQbittorrent, setEditedConfigQbittorrent] = useState("");
|
||||
const [testDownloaderLoading, setTestDownloaderLoading] = useState(false);
|
||||
const [storeFilesHintDialogOpen, setStoreFilesHintDialogOpen] = useState(false);
|
||||
|
||||
const capabilities = useMemo(() => {
|
||||
return new Boolset(values.capabilities ?? "");
|
||||
}, [values.capabilities]);
|
||||
|
||||
const hasRemoteDownload = useMemo(() => {
|
||||
return capabilities.enabled(NodeCapability.remote_download);
|
||||
}, [capabilities]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditedConfigAria2(
|
||||
values.settings?.aria2?.options ? JSON.stringify(values.settings?.aria2?.options, null, 2) : "",
|
||||
);
|
||||
}, [values.settings?.aria2?.options]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditedConfigQbittorrent(
|
||||
values.settings?.qbittorrent?.options ? JSON.stringify(values.settings?.qbittorrent?.options, null, 2) : "",
|
||||
);
|
||||
}, [values.settings?.qbittorrent?.options]);
|
||||
|
||||
const onCapabilityChange = useCallback(
|
||||
(capability: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
capabilities: new Boolset(p.capabilities).set(capability, e.target.checked).toString(),
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onProviderChange = useCallback(
|
||||
(e: SelectChangeEvent<unknown>) => {
|
||||
const provider = e.target.value as DownloaderProvider;
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
provider,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onAria2ServerChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
aria2: {
|
||||
...p.settings?.aria2,
|
||||
server: e.target.value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onAria2TokenChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
aria2: {
|
||||
...p.settings?.aria2,
|
||||
token: e.target.value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onAria2TempPathChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
aria2: {
|
||||
...p.settings?.aria2,
|
||||
temp_path: e.target.value ? e.target.value : undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onQBittorrentServerChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
qbittorrent: {
|
||||
...p.settings?.qbittorrent,
|
||||
server: e.target.value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onQBittorrentUserChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
qbittorrent: {
|
||||
...p.settings?.qbittorrent,
|
||||
user: e.target.value ? e.target.value : undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onQBittorrentPasswordChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
qbittorrent: {
|
||||
...p.settings?.qbittorrent,
|
||||
password: e.target.value ? e.target.value : undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onQBittorrentTempPathChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
qbittorrent: {
|
||||
...p.settings?.qbittorrent,
|
||||
temp_path: e.target.value ? e.target.value : undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onIntervalChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const interval = parseInt(e.target.value);
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
interval: isNaN(interval) ? undefined : interval,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onWaitForSeedingChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
wait_for_seeding: e.target.checked ? true : undefined,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setNode],
|
||||
);
|
||||
|
||||
const onEditedConfigAria2Blur = useCallback(
|
||||
(value: string) => {
|
||||
var res: Record<string, any> | undefined = undefined;
|
||||
if (value) {
|
||||
try {
|
||||
res = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
setNode((p: Node) => ({ ...p, settings: { ...p.settings, aria2: { ...p.settings?.aria2, options: res } } }));
|
||||
},
|
||||
[editedConfigAria2, setNode],
|
||||
);
|
||||
|
||||
const onEditedConfigQbittorrentBlur = useCallback(
|
||||
(value: string) => {
|
||||
var res: Record<string, any> | undefined = undefined;
|
||||
if (value) {
|
||||
try {
|
||||
res = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
setNode((p: Node) => ({
|
||||
...p,
|
||||
settings: { ...p.settings, qbittorrent: { ...p.settings?.qbittorrent, options: res } },
|
||||
}));
|
||||
},
|
||||
[editedConfigQbittorrent, setNode],
|
||||
);
|
||||
|
||||
const onTestDownloaderClick = useCallback(() => {
|
||||
setTestDownloaderLoading(true);
|
||||
dispatch(testNodeDownloader({ node: values }))
|
||||
.then((res) => {
|
||||
enqueueSnackbar({
|
||||
variant: "success",
|
||||
message: t("node.downloaderTestPass", { version: res }),
|
||||
action: DefaultCloseAction,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setTestDownloaderLoading(false);
|
||||
});
|
||||
}, [values]);
|
||||
|
||||
const onStoreFilesClick = useCallback(() => {
|
||||
setStoreFilesHintDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StoreFilesHintDialog open={storeFilesHintDialogOpen} onClose={() => setStoreFilesHintDialogOpen(false)} />
|
||||
<SettingSection>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("node.features")}
|
||||
</Typography>
|
||||
<SettingSectionContent>
|
||||
<SettingForm lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={capabilities.enabled(NodeCapability.create_archive)}
|
||||
onChange={onCapabilityChange(NodeCapability.create_archive)}
|
||||
/>
|
||||
}
|
||||
label={t("application:fileManager.createArchive")}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.createArchiveDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={capabilities.enabled(NodeCapability.extract_archive)}
|
||||
onChange={onCapabilityChange(NodeCapability.extract_archive)}
|
||||
/>
|
||||
}
|
||||
label={t("application:fileManager.extractArchive")}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.extractArchiveDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={capabilities.enabled(NodeCapability.remote_download)}
|
||||
onChange={onCapabilityChange(NodeCapability.remote_download)}
|
||||
/>
|
||||
}
|
||||
label={t("application:navbar.remoteDownload")}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.remoteDownloadDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
{values.type === NodeType.slave && (
|
||||
<SettingForm lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
onChange={onStoreFilesClick}
|
||||
disabled={(values.edges?.storage_policy?.length ?? 0) > 0}
|
||||
checked={(values.edges?.storage_policy?.length ?? 0) > 0}
|
||||
/>
|
||||
}
|
||||
label={t("node.storeFiles")}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.storeFilesDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
)}
|
||||
</SettingSectionContent>
|
||||
</SettingSection>
|
||||
|
||||
<Collapse in={hasRemoteDownload} unmountOnExit>
|
||||
<SettingSection>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{t("node.remoteDownload")}
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
window.open("https://docs.cloudreve.org/usage/remote-download", "_blank");
|
||||
}}
|
||||
>
|
||||
<QuestionCircle />
|
||||
</IconButton>
|
||||
</Typography>
|
||||
<SettingSectionContent>
|
||||
<SettingForm title={t("node.downloader")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseSelect value={values.settings?.provider || DownloaderProvider.aria2} onChange={onProviderChange}>
|
||||
<SquareMenuItem value={DownloaderProvider.aria2}>
|
||||
<ListItemText primary="Aria2" slotProps={{ primary: { variant: "body2" } }} />
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem value={DownloaderProvider.qbittorrent}>
|
||||
<ListItemText primary="qBittorrent" slotProps={{ primary: { variant: "body2" } }} />
|
||||
</SquareMenuItem>
|
||||
</DenseSelect>
|
||||
<NoMarginHelperText>
|
||||
{values.settings?.provider === DownloaderProvider.qbittorrent
|
||||
? t("node.qbittorrentDes")
|
||||
: t("node.aria2Des")}
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
|
||||
{values.settings?.provider === DownloaderProvider.aria2 && (
|
||||
<>
|
||||
<SettingForm title={t("node.rpcServer")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<EndpointInput
|
||||
fullWidth
|
||||
required
|
||||
value={values.settings?.aria2?.server || ""}
|
||||
onChange={onAria2ServerChange}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
<NoMarginHelperText>
|
||||
<Trans i18nKey="node.rpcServerHelpDes" ns="dashboard" components={[<Code />]} />
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("node.rpcToken")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField value={values.settings?.aria2?.token || ""} onChange={onAria2TokenChange} />
|
||||
<NoMarginHelperText>
|
||||
<Trans i18nKey="node.rpcTokenDes" ns="dashboard" components={[<Code />]} />
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("group.aria2Options")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<MonacoEditor
|
||||
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
|
||||
language="json"
|
||||
value={editedConfigAria2}
|
||||
onChange={(value) => setEditedConfigAria2(value || "")}
|
||||
onBlur={onEditedConfigAria2Blur}
|
||||
height="200px"
|
||||
minHeight="200px"
|
||||
options={{
|
||||
wordWrap: "on",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
<NoMarginHelperText>
|
||||
<Trans
|
||||
i18nKey="node.downloaderOptionDes"
|
||||
ns="dashboard"
|
||||
components={[
|
||||
<Link href="https://aria2.github.io/manual/en/html/aria2c.html#id2" target="_blank" />,
|
||||
]}
|
||||
/>
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("node.tempPath")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
value={values.settings?.aria2?.temp_path || ""}
|
||||
onChange={onAria2TempPathChange}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.tempPathDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
</>
|
||||
)}
|
||||
|
||||
{values.settings?.provider === DownloaderProvider.qbittorrent && (
|
||||
<>
|
||||
<SettingForm title={t("node.webUIEndpoint")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<EndpointInput
|
||||
fullWidth
|
||||
required
|
||||
value={values.settings?.qbittorrent?.server || ""}
|
||||
onChange={onQBittorrentServerChange}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
<NoMarginHelperText>
|
||||
<Trans i18nKey="node.webUIEndpointDes" ns="dashboard" components={[<Code />]} />
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("policy.accessCredential")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
placeholder={t("node.webUIUsername")}
|
||||
value={values.settings?.qbittorrent?.user || ""}
|
||||
onChange={onQBittorrentUserChange}
|
||||
/>
|
||||
<DenseFilledTextField
|
||||
placeholder={t("node.webUIPassword")}
|
||||
type="password"
|
||||
sx={{ mt: 1 }}
|
||||
value={values.settings?.qbittorrent?.password || ""}
|
||||
onChange={onQBittorrentPasswordChange}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.webUICredDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("group.aria2Options")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<MonacoEditor
|
||||
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
|
||||
language="json"
|
||||
value={editedConfigQbittorrent}
|
||||
onChange={(value) => setEditedConfigQbittorrent(value || "")}
|
||||
onBlur={onEditedConfigQbittorrentBlur}
|
||||
height="200px"
|
||||
minHeight="200px"
|
||||
options={{
|
||||
wordWrap: "on",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
<NoMarginHelperText>
|
||||
<Trans
|
||||
i18nKey="node.downloaderOptionDes"
|
||||
ns="dashboard"
|
||||
components={[
|
||||
<Link
|
||||
href="https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#add-new-torrent"
|
||||
target="_blank"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("node.tempPath")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
value={values.settings?.qbittorrent?.temp_path || ""}
|
||||
onChange={onQBittorrentTempPathChange}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.tempPathDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingForm title={t("node.refreshInterval")} lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
type="number"
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
required
|
||||
value={values.settings?.interval || ""}
|
||||
onChange={onIntervalChange}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.refreshIntervalDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
|
||||
<SettingForm lgWidth={5}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={values.settings?.wait_for_seeding || false} onChange={onWaitForSeedingChange} />
|
||||
}
|
||||
label={t("node.waitForSeeding")}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.waitForSeedingDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm lgWidth={5}>
|
||||
<SecondaryButton onClick={onTestDownloaderClick} variant="contained" loading={testDownloaderLoading}>
|
||||
{t("node.testDownloader")}
|
||||
</SecondaryButton>
|
||||
</SettingForm>
|
||||
</SettingSectionContent>
|
||||
</SettingSection>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapabilitiesSection;
|
||||
34
src/component/Admin/Node/EditNode/EditNode.tsx
Executable file
34
src/component/Admin/Node/EditNode/EditNode.tsx
Executable file
@@ -0,0 +1,34 @@
|
||||
import { Container } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Node } from "../../../../api/dashboard";
|
||||
import PageContainer from "../../../Pages/PageContainer";
|
||||
import PageHeader from "../../../Pages/PageHeader";
|
||||
import BasicInfoSection from "./BasicInfoSection";
|
||||
import CapabilitiesSection from "./CapabilitiesSection";
|
||||
import NodeForm from "./NodeForm";
|
||||
import NodeSettingWrapper from "./NodeSettingWrapper";
|
||||
|
||||
const EditNode = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [node, setNode] = useState<Node | null>(null);
|
||||
const nodeID = parseInt(id ?? "0");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Container maxWidth="xl">
|
||||
<PageHeader title={t("node.editNode", { node: node?.name })} />
|
||||
<NodeSettingWrapper nodeID={nodeID} onNodeChange={setNode}>
|
||||
<NodeForm>
|
||||
<BasicInfoSection />
|
||||
<CapabilitiesSection />
|
||||
</NodeForm>
|
||||
</NodeSettingWrapper>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditNode;
|
||||
19
src/component/Admin/Node/EditNode/NodeForm.tsx
Executable file
19
src/component/Admin/Node/EditNode/NodeForm.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import { Box, Stack } from "@mui/material";
|
||||
import { useContext } from "react";
|
||||
import { NodeSettingContext } from "./NodeSettingWrapper";
|
||||
|
||||
export interface NodeFormProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const NodeForm = ({ children }: NodeFormProps) => {
|
||||
const { formRef } = useContext(NodeSettingContext);
|
||||
|
||||
return (
|
||||
<Box component="form" ref={formRef} noValidate>
|
||||
<Stack spacing={5}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeForm;
|
||||
157
src/component/Admin/Node/EditNode/NodeSettingWrapper.tsx
Executable file
157
src/component/Admin/Node/EditNode/NodeSettingWrapper.tsx
Executable file
@@ -0,0 +1,157 @@
|
||||
import { Box } from "@mui/material";
|
||||
import * as React from "react";
|
||||
import { createContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import { getNodeDetail, upsertNode } from "../../../../api/api.ts";
|
||||
import { Node, StoragePolicy } from "../../../../api/dashboard.ts";
|
||||
import { useAppDispatch } from "../../../../redux/hooks.ts";
|
||||
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
|
||||
import { SavingFloat } from "../../Settings/SettingWrapper.tsx";
|
||||
|
||||
export interface NodeSettingWrapperProps {
|
||||
nodeID: number;
|
||||
children: React.ReactNode;
|
||||
onNodeChange: (node: Node) => void;
|
||||
}
|
||||
|
||||
export interface NodeSettingContextProps {
|
||||
values: Node;
|
||||
setNode: (f: (p: Node) => Node) => void;
|
||||
formRef?: React.RefObject<HTMLFormElement>;
|
||||
}
|
||||
|
||||
const defaultNode: Node = {
|
||||
id: 0,
|
||||
name: "",
|
||||
status: undefined,
|
||||
type: undefined,
|
||||
server: "",
|
||||
slave_key: "",
|
||||
capabilities: "",
|
||||
weight: 1,
|
||||
settings: {},
|
||||
edges: {
|
||||
storage_policy: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const NodeSettingContext = createContext<NodeSettingContextProps>({
|
||||
values: { ...defaultNode },
|
||||
setNode: () => {},
|
||||
});
|
||||
|
||||
const nodeValueFilter = (node: Node): Node => {
|
||||
return {
|
||||
...node,
|
||||
edges: {
|
||||
storage_policy: node.edges.storage_policy?.map(
|
||||
(p): StoragePolicy =>
|
||||
({
|
||||
id: p.id,
|
||||
}) as StoragePolicy,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const NodeSettingWrapper = ({ nodeID, children, onNodeChange }: NodeSettingWrapperProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation("dashboard");
|
||||
const [values, setValues] = useState<Node>({
|
||||
...defaultNode,
|
||||
});
|
||||
const [modifiedValues, setModifiedValues] = useState<Node>({
|
||||
...defaultNode,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const showSaveButton = useMemo(() => {
|
||||
return JSON.stringify(modifiedValues) !== JSON.stringify(values);
|
||||
}, [modifiedValues, values]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
dispatch(getNodeDetail(nodeID))
|
||||
.then((res) => {
|
||||
setValues(nodeValueFilter(res));
|
||||
setModifiedValues(nodeValueFilter(res));
|
||||
onNodeChange(nodeValueFilter(res));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [nodeID]);
|
||||
|
||||
const revert = () => {
|
||||
setModifiedValues(values);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (formRef.current) {
|
||||
if (!formRef.current.checkValidity()) {
|
||||
formRef.current.reportValidity();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
dispatch(
|
||||
upsertNode({
|
||||
node: { ...modifiedValues },
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
setValues(nodeValueFilter(res));
|
||||
setModifiedValues(nodeValueFilter(res));
|
||||
onNodeChange(nodeValueFilter(res));
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeSettingContext.Provider
|
||||
value={{
|
||||
values: modifiedValues,
|
||||
setNode: setModifiedValues,
|
||||
formRef,
|
||||
}}
|
||||
>
|
||||
<SwitchTransition>
|
||||
<CSSTransition
|
||||
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
|
||||
classNames="fade"
|
||||
key={`${loading}`}
|
||||
>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{loading && (
|
||||
<Box
|
||||
sx={{
|
||||
pt: 20,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FacebookCircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{!loading && (
|
||||
<Box>
|
||||
{children}
|
||||
<SavingFloat in={showSaveButton} submitting={submitting} revert={revert} submit={submit} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
</NodeSettingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeSettingWrapper;
|
||||
35
src/component/Admin/Node/EditNode/StoreFilesHintDialog.tsx
Executable file
35
src/component/Admin/Node/EditNode/StoreFilesHintDialog.tsx
Executable file
@@ -0,0 +1,35 @@
|
||||
import { DialogContent, Link, Typography } from "@mui/material";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import DraggableDialog from "../../../Dialogs/DraggableDialog";
|
||||
export interface StoreFilesHintDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StoreFilesHintDialog = ({ open, onClose }: StoreFilesHintDialogProps) => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
return (
|
||||
<DraggableDialog
|
||||
dialogProps={{
|
||||
open,
|
||||
onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
title={t("node.storeFiles")}
|
||||
>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
<Trans
|
||||
i18nKey="node.storeFilesHint"
|
||||
ns="dashboard"
|
||||
components={[<Link component={RouterLink} to="/admin/policy" />]}
|
||||
/>
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoreFilesHintDialog;
|
||||
3
src/component/Admin/Node/EditNode/index.tsx
Executable file
3
src/component/Admin/Node/EditNode/index.tsx
Executable file
@@ -0,0 +1,3 @@
|
||||
import EditNode from "./EditNode";
|
||||
|
||||
export default EditNode;
|
||||
234
src/component/Admin/Node/NewNode/NewNodeDialog.tsx
Executable file
234
src/component/Admin/Node/NewNode/NewNodeDialog.tsx
Executable file
@@ -0,0 +1,234 @@
|
||||
import { Box, Stack, Typography, useTheme } from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { testNode, upsertNode } from "../../../../api/api";
|
||||
import { DownloaderProvider, Node, NodeStatus, NodeType } from "../../../../api/dashboard";
|
||||
import { useAppDispatch } from "../../../../redux/hooks";
|
||||
import { randomString } from "../../../../util";
|
||||
import FacebookCircularProgress from "../../../Common/CircularProgress";
|
||||
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
|
||||
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents";
|
||||
import DraggableDialog from "../../../Dialogs/DraggableDialog";
|
||||
import SettingForm from "../../../Pages/Setting/SettingForm";
|
||||
import { Code } from "../../../Common/Code.tsx";
|
||||
import { EndpointInput } from "../../Common/EndpointInput";
|
||||
import { NoMarginHelperText } from "../../Settings/Settings";
|
||||
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor"));
|
||||
|
||||
export interface NewNodeDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const defaultNode: Node = {
|
||||
id: 0,
|
||||
name: "",
|
||||
type: NodeType.slave,
|
||||
status: NodeStatus.active,
|
||||
server: "",
|
||||
slave_key: "",
|
||||
capabilities: "",
|
||||
weight: 1,
|
||||
settings: {
|
||||
provider: DownloaderProvider.aria2,
|
||||
qbittorrent: {},
|
||||
aria2: {},
|
||||
interval: 5,
|
||||
},
|
||||
edges: {
|
||||
storage_policy: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const Step = ({ step, children }: { step: number; children: React.ReactNode }) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
padding: "10px",
|
||||
transition: (theme) =>
|
||||
theme.transitions.create("background-color", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
"&:focus-within": {
|
||||
backgroundColor: (theme) => (theme.palette.mode == "dark" ? grey[900] : grey[100]),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ ml: "20px" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "20px",
|
||||
fontSize: (t) => t.typography.body2.fontSize,
|
||||
height: "20px",
|
||||
backgroundColor: (theme) => theme.palette.primary.light,
|
||||
color: (theme) => theme.palette.primary.contrastText,
|
||||
textAlign: "center",
|
||||
borderRadius: " 50%",
|
||||
}}
|
||||
>
|
||||
{step}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ ml: "10px", mr: "20px", flexGrow: 1 }}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewNodeDialog = ({ open, onClose }: NewNodeDialogProps) => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [node, setNode] = useState<Node>({ ...defaultNode });
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setNode({ ...defaultNode, slave_key: randomString(64) });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.current?.checkValidity()) {
|
||||
formRef.current?.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(upsertNode({ node }))
|
||||
.then((r) => {
|
||||
navigate(`/admin/node/${r.id}`);
|
||||
onClose();
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const config = useMemo(() => {
|
||||
return `[System]
|
||||
Mode = slave
|
||||
Listen = :5212
|
||||
|
||||
[Slave]
|
||||
Secret = ${node.slave_key}
|
||||
|
||||
; ${t("node.keepIfUpload")}
|
||||
[CORS]
|
||||
AllowOrigins = *
|
||||
AllowMethods = OPTIONS,GET,POST
|
||||
AllowHeaders = *
|
||||
`;
|
||||
}, [t, node.slave_key]);
|
||||
|
||||
const handleTest = () => {
|
||||
if (!formRef.current?.checkValidity()) {
|
||||
formRef.current?.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(testNode({ node }))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
enqueueSnackbar(t("node.testNodeSuccess"), { variant: "success", action: DefaultCloseAction });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
onAccept={handleSubmit}
|
||||
loading={loading}
|
||||
title={t("node.addNewNode")}
|
||||
showActions
|
||||
showCancel
|
||||
dialogProps={{
|
||||
open,
|
||||
onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
>
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<Stack spacing={1}>
|
||||
<Step step={1}>
|
||||
<SettingForm lgWidth={12}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{t("node.nameTheNode")}
|
||||
</Typography>
|
||||
<DenseFilledTextField
|
||||
fullWidth
|
||||
required
|
||||
value={node.name}
|
||||
onChange={(e) => setNode({ ...node, name: e.target.value })}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.nameNode")}</NoMarginHelperText>
|
||||
</SettingForm>
|
||||
</Step>
|
||||
<Step step={2}>
|
||||
<SettingForm lgWidth={12}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{t("node.runCrSlave")}
|
||||
</Typography>
|
||||
<Suspense fallback={<FacebookCircularProgress />}>
|
||||
<MonacoEditor
|
||||
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
|
||||
language="ini"
|
||||
value={config}
|
||||
height="200px"
|
||||
minHeight="200px"
|
||||
options={{
|
||||
wordWrap: "on",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
<NoMarginHelperText sx={{ mt: 1 }}>
|
||||
<Trans i18nKey="node.runCrWithConfig" ns="dashboard" components={[<Code />]} />
|
||||
</NoMarginHelperText>
|
||||
</SettingForm>
|
||||
</Step>
|
||||
<Step step={3}>
|
||||
<SettingForm lgWidth={12}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{t("node.inputServer")}
|
||||
</Typography>
|
||||
<EndpointInput
|
||||
variant={"outlined"}
|
||||
fullWidth
|
||||
required
|
||||
enforceProtocol
|
||||
value={node.server}
|
||||
onChange={(e) => setNode({ ...node, server: e.target.value })}
|
||||
/>
|
||||
<NoMarginHelperText>{t("node.serverDes")}</NoMarginHelperText>
|
||||
</SettingForm>
|
||||
</Step>
|
||||
<Step step={4}>
|
||||
<SettingForm lgWidth={12}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{t("node.testButton")}
|
||||
</Typography>
|
||||
<SecondaryButton loading={loading} variant="contained" onClick={handleTest}>
|
||||
{t("node.testNode")}
|
||||
</SecondaryButton>
|
||||
<NoMarginHelperText sx={{ mt: 1 }}>
|
||||
<Trans i18nKey="node.hostHeaderHint" ns="dashboard" components={[<Code />]} />
|
||||
</NoMarginHelperText>
|
||||
</SettingForm>
|
||||
</Step>
|
||||
</Stack>
|
||||
</form>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
200
src/component/Admin/Node/NodeCard.tsx
Executable file
200
src/component/Admin/Node/NodeCard.tsx
Executable file
@@ -0,0 +1,200 @@
|
||||
import { Box, Divider, IconButton, Skeleton, Typography } from "@mui/material";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { deleteNode } from "../../../api/api";
|
||||
import { Node, NodeStatus, NodeType } from "../../../api/dashboard";
|
||||
import { NodeCapability } from "../../../api/workflow";
|
||||
import { useAppDispatch } from "../../../redux/hooks";
|
||||
import { confirmOperation } from "../../../redux/thunks/dialog";
|
||||
import Boolset from "../../../util/boolset";
|
||||
import { NoWrapBox, SquareChip } from "../../Common/StyledComponents";
|
||||
import CheckCircleFilled from "../../Icons/CheckCircleFilled";
|
||||
import Delete from "../../Icons/Delete";
|
||||
import DismissCircleFilled from "../../Icons/DismissCircleFilled";
|
||||
import Info from "../../Icons/Info";
|
||||
import StarFilled from "../../Icons/StarFilled";
|
||||
import { BorderedCardClickable } from "../Common/AdminCard";
|
||||
|
||||
export interface NodeCardProps {
|
||||
node?: Node;
|
||||
onRefresh?: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const NodeCard = ({ node, onRefresh, loading }: NodeCardProps) => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
dispatch(confirmOperation(t("node.deleteNodeConfirmation", { name: node?.name ?? "" }))).then(() => {
|
||||
setDeleteLoading(true);
|
||||
dispatch(deleteNode(node?.id ?? 0))
|
||||
.then(() => {
|
||||
onRefresh?.();
|
||||
})
|
||||
.finally(() => {
|
||||
setDeleteLoading(false);
|
||||
});
|
||||
});
|
||||
},
|
||||
[node, dispatch, onRefresh],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/admin/node/${node?.id}`);
|
||||
},
|
||||
[node, navigate],
|
||||
);
|
||||
|
||||
// Decode node capabilities
|
||||
const getCapabilities = useCallback(() => {
|
||||
if (!node?.capabilities) return [];
|
||||
|
||||
const boolset = new Boolset(node.capabilities);
|
||||
const capabilities = [];
|
||||
|
||||
if (boolset.enabled(NodeCapability.create_archive)) {
|
||||
capabilities.push({ id: NodeCapability.create_archive, name: t("application:fileManager.createArchive") });
|
||||
}
|
||||
if (boolset.enabled(NodeCapability.extract_archive)) {
|
||||
capabilities.push({ id: NodeCapability.extract_archive, name: t("application:fileManager.extractArchive") });
|
||||
}
|
||||
if (boolset.enabled(NodeCapability.remote_download)) {
|
||||
capabilities.push({ id: NodeCapability.remote_download, name: t("application:navbar.remoteDownload") });
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}, [node, t]);
|
||||
|
||||
// If loading is true, render a skeleton placeholder
|
||||
if (loading) {
|
||||
return (
|
||||
<Grid
|
||||
size={{
|
||||
xs: 12,
|
||||
md: 6,
|
||||
lg: 4,
|
||||
}}
|
||||
>
|
||||
<BorderedCardClickable>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
<Skeleton variant="text" width={100} />
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Skeleton variant="text" width={60} />
|
||||
</Typography>
|
||||
</Box>
|
||||
<NoWrapBox sx={{ mt: 1, mb: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", minHeight: "25px" }}>
|
||||
<Skeleton width={60} height={25} sx={{ mr: 1 }} />
|
||||
</Box>
|
||||
</NoWrapBox>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="text" width="40%" height={20} />
|
||||
<Skeleton variant="circular" width={30} height={30} />
|
||||
</Box>
|
||||
</BorderedCardClickable>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const capabilities = getCapabilities();
|
||||
|
||||
return (
|
||||
<Grid
|
||||
size={{
|
||||
xs: 12,
|
||||
md: 6,
|
||||
lg: 4,
|
||||
}}
|
||||
>
|
||||
<BorderedCardClickable onClick={handleEdit}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
{node?.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{node?.type === NodeType.master && <StarFilled sx={{ mr: 0.5, fontSize: 16, color: "primary.main" }} />}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{node?.type === NodeType.master ? t("node.master") : t("node.slave")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<NoWrapBox sx={{ mt: 1, mb: 2 }}>
|
||||
{capabilities.length > 0 ? (
|
||||
capabilities.map((capability) => (
|
||||
<SquareChip sx={{ mr: 1 }} key={capability.id} label={capability.name} size="small" />
|
||||
))
|
||||
) : (
|
||||
<Box sx={{ display: "flex", alignItems: "center", minHeight: "25px" }} color={"text.secondary"}>
|
||||
<Info sx={{ mr: 0.5, fontSize: 20 }} />
|
||||
<Typography variant="caption">{t("node.noCapabilities")}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</NoWrapBox>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{node?.status === NodeStatus.active ? (
|
||||
<>
|
||||
<CheckCircleFilled sx={{ mr: 0.5, fontSize: 20, color: "success.main" }} />
|
||||
<Typography variant="body2" color="success.main">
|
||||
{t("node.active")}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DismissCircleFilled sx={{ mr: 0.5, fontSize: 20, color: "text.secondary" }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("node.suspended")}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<IconButton size="small" onClick={handleDelete} disabled={deleteLoading}>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</BorderedCardClickable>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeCard;
|
||||
131
src/component/Admin/Node/NodeSetting.tsx
Executable file
131
src/component/Admin/Node/NodeSetting.tsx
Executable file
@@ -0,0 +1,131 @@
|
||||
import { Add } from "@mui/icons-material";
|
||||
import { Box, Container, Grid2 as Grid, IconButton, Stack, Typography } from "@mui/material";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getNodeList } from "../../../api/api";
|
||||
import { Node } from "../../../api/dashboard";
|
||||
import { useAppDispatch } from "../../../redux/hooks";
|
||||
import { SecondaryButton } from "../../Common/StyledComponents";
|
||||
import ArrowSync from "../../Icons/ArrowSync";
|
||||
import QuestionCircle from "../../Icons/QuestionCircle";
|
||||
import PageContainer from "../../Pages/PageContainer";
|
||||
import PageHeader from "../../Pages/PageHeader";
|
||||
import { BorderedCardClickable } from "../Common/AdminCard";
|
||||
import TablePagination from "../Common/TablePagination";
|
||||
import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting";
|
||||
import { NewNodeDialog } from "./NewNode/NewNodeDialog";
|
||||
import NodeCard from "./NodeCard";
|
||||
|
||||
const NodeSetting = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const dispatch = useAppDispatch();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [page, setPage] = useQueryState(PageQuery, { defaultValue: "1" });
|
||||
const [pageSize, setPageSize] = useQueryState(PageSizeQuery, {
|
||||
defaultValue: "11",
|
||||
});
|
||||
const [orderBy, setOrderBy] = useQueryState(OrderByQuery, {
|
||||
defaultValue: "",
|
||||
});
|
||||
const [orderDirection, setOrderDirection] = useQueryState(OrderDirectionQuery, { defaultValue: "desc" });
|
||||
const [count, setCount] = useState(0);
|
||||
const [selectProviderOpen, setSelectProviderOpen] = useState(false);
|
||||
const [createNewOpen, setCreateNewOpen] = useState(false);
|
||||
|
||||
const pageInt = parseInt(page) ?? 1;
|
||||
const pageSizeInt = parseInt(pageSize) ?? 11;
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [page, pageSize, orderBy, orderDirection]);
|
||||
|
||||
const fetchNodes = () => {
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
getNodeList({
|
||||
page: pageInt,
|
||||
page_size: pageSizeInt,
|
||||
order_by: orderBy ?? "",
|
||||
order_direction: orderDirection ?? "desc",
|
||||
conditions: {},
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
setNodes(res.nodes);
|
||||
setPage((res.pagination.page + 1).toString());
|
||||
setPageSize(res.pagination.page_size.toString());
|
||||
setCount(res.pagination.total_items ?? 0);
|
||||
setLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NewNodeDialog open={createNewOpen} onClose={() => setCreateNewOpen(false)} />
|
||||
<Container maxWidth="xl">
|
||||
<PageHeader
|
||||
title={t("dashboard:nav.nodes")}
|
||||
secondaryAction={
|
||||
<IconButton onClick={() => window.open("https://docs.cloudreve.org/usage/slave-node", "_blank")}>
|
||||
<QuestionCircle />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<SecondaryButton onClick={fetchNodes} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}>
|
||||
{t("node.refresh")}
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
<Grid container spacing={2}>
|
||||
<Grid
|
||||
size={{
|
||||
xs: 12,
|
||||
md: 6,
|
||||
lg: 4,
|
||||
}}
|
||||
>
|
||||
<BorderedCardClickable
|
||||
onClick={() => setCreateNewOpen(true)}
|
||||
sx={{
|
||||
height: "100%",
|
||||
borderStyle: "dashed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
justifyContent: "center",
|
||||
color: (t) => t.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Add />
|
||||
<Typography variant="h6">{t("node.addNewNode")}</Typography>
|
||||
</BorderedCardClickable>
|
||||
</Grid>
|
||||
{!loading && nodes.map((n) => <NodeCard key={n.name} node={n} onRefresh={fetchNodes} />)}
|
||||
{loading && nodes.length > 0 && nodes.map((n) => <NodeCard key={`loading-${n.name}`} loading={true} />)}
|
||||
{loading &&
|
||||
nodes.length === 0 &&
|
||||
Array.from(Array(5)).map((_, index) => <NodeCard key={`loading-${index}`} loading={true} />)}
|
||||
</Grid>
|
||||
{count > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<TablePagination
|
||||
page={pageInt}
|
||||
totalItems={count}
|
||||
rowsPerPage={pageSizeInt}
|
||||
rowsPerPageOptions={[11, 25, 50, 100, 200, 500, 1000]}
|
||||
onRowsPerPageChange={(value) => setPageSize(value.toString())}
|
||||
onChange={(_, value) => setPage(value.toString())}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeSetting;
|
||||
Reference in New Issue
Block a user