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;