first commit

This commit is contained in:
2025-10-19 13:31:11 +00:00
commit 8bfc183b66
1248 changed files with 195992 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
import EditNode from "./EditNode";
export default EditNode;

View 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>
);
};

View 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;

View 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;