gitscope
← marzneshin / Diff · 5bb2712 → 5bb2712
Changes 5bb2712..5bb2712
Patch commit
diff --git a/app/dashboard/public/locales/en.json b/app/dashboard/public/locales/en.json
index 19dd6b0..22616c4 100644
--- a/app/dashboard/public/locales/en.json
+++ b/app/dashboard/public/locales/en.json
@@ -8,6 +8,9 @@
"createService": "Create Service",
"username": "Username",
"name": "Name",
+ "node": "Node",
+ "xrayVersion": "xray version",
+ "addNode": "Add Node",
"expires": "Expires in {{time}}",
"expired": "Expired {{time}} ago",
"dateFormat": "MMMM d, yyy",
@@ -56,6 +59,20 @@
"inboundsNumber": "Inbounds",
"total": "Total"
},
+ "nodesTable": {
+ "status": "status",
+ "dataUsage": "data usage",
+ "editNode": "Edit node",
+ "copyCertificate": "Copy Certificate",
+ "copied": "Certificate Copied",
+ "addNode": "Add node",
+ "ipPort": "Node IP Port",
+ "xrayVersion": "xray version",
+ "noNodeMatched": "It seems there is no node matched with what you are looking for",
+ "noNode": "There is no node added to the system",
+ "copyConfigs": "Copy Configs",
+ "total": "Total"
+ },
"userDialog": {
"dataLimit": "Data Limit",
"periodicUsageReset": "Periodic Usage Reset",
@@ -96,6 +113,19 @@
"revokeSubscription": "Revoke Subscription",
"total": "Total: "
},
+ "nodeDialog": {
+ "editNodeTitle": "Edit node",
+ "editNode": "Edit node",
+ "addNode": "Add node",
+ "createNewNode": "Create node",
+ "nodeEdited": "Node {{name}} edited.",
+ "nodeCreated": "Node {{name}} created.",
+ "nodeAlreadyExists": "Node already exists",
+ "optional": "optional",
+ "total": "Total: ",
+ "updateSubmit": "Submit update",
+ "addSubmit": "Submit"
+ },
"serviceDialog": {
"editServiceTitle": "Edit service",
"editService": "Edit service",
@@ -104,10 +134,6 @@
"serviceCreated": "Service {{name}} created.",
"serviceAlreadyExists": "Service already exists",
"resetStrategyNo": "No",
- "resetStrategyDaily": "Daily",
- "resetStrategyWeekly": "Weekly",
- "resetStrategyMonthly": "Monthly",
- "resetStrategyAnnually": "Annually",
"optional": "optional",
"total": "Total: "
},
diff --git a/app/dashboard/src/App.tsx b/app/dashboard/src/App.tsx
index 9f42eb4..7dfc343 100644
--- a/app/dashboard/src/App.tsx
+++ b/app/dashboard/src/App.tsx
@@ -37,8 +37,8 @@ function App() {
</GridItem>
<GridItem area={'main'}>
<Routes>
- {pages.map((route: PageRoute) =>
- <Route {...route} errorElement={<Login />} loader={fetchAdminLoader} />
+ {pages.map((route: PageRoute, i) =>
+ <Route {...route} errorElement={<Login />} loader={fetchAdminLoader} key={i} />
)}
</Routes>
diff --git a/app/dashboard/src/components/DeleteNodeModal.tsx b/app/dashboard/src/components/DeleteNodeModal.tsx
index b88cfc6..566c8bd 100644
--- a/app/dashboard/src/components/DeleteNodeModal.tsx
+++ b/app/dashboard/src/components/DeleteNodeModal.tsx
@@ -11,43 +11,39 @@ import {
Text,
useToast,
} from '@chakra-ui/react';
-import { FetchNodesQueryKey, useNodes } from 'contexts/NodesContext';
-import { FC } from 'react';
+import { useNodes } from 'contexts/NodesContext';
+import { FC, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
-import { useMutation, useQueryClient } from 'react-query';
-import {
- generateErrorMessage,
- generateSuccessMessage,
-} from 'utils/toastHandler';
-import { DeleteUserModalProps } from './DeleteUserModal';
import { DeleteIcon } from './Dialog/Icons';
import { Icon } from './Icon';
-export const DeleteNodeModal: FC<DeleteUserModalProps> = ({
- deleteCallback,
-}) => {
- const { deleteNode, deletingNode, setDeletingNode } = useNodes();
+export const DeleteNodeModal: FC = () => {
+ const { deleteNode, refetchNodes, deletingNode, onDeletingNode } = useNodes();
const { t } = useTranslation();
const toast = useToast();
- const queryClient = useQueryClient();
+ const [loading, setLoading] = useState(false);
const onClose = () => {
- setDeletingNode(null);
+ onDeletingNode(undefined);
};
- const { isLoading, mutate: onDelete } = useMutation(deleteNode, {
- onSuccess: () => {
- generateSuccessMessage(
- t('deleteNode.deleteSuccess', { name: deletingNode && deletingNode.name }),
- toast
- );
- setDeletingNode(null);
- queryClient.invalidateQueries(FetchNodesQueryKey);
- deleteCallback && deleteCallback();
- },
- onError: (e) => {
- generateErrorMessage(e, toast);
- },
- });
+ const onDelete = () => {
+ if (deletingNode) {
+ setLoading(true);
+ deleteNode(deletingNode)
+ .then(() => {
+ refetchNodes();
+ toast({
+ title: t('deleteNode.deleteSuccess', { name: deletingNode.name }),
+ status: 'success',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .then(onClose)
+ .finally(setLoading.bind(null, false));
+ }
+ };
return (
<Modal isCentered isOpen={!!deletingNode} onClose={onClose} size="sm">
@@ -85,7 +81,7 @@ export const DeleteNodeModal: FC<DeleteUserModalProps> = ({
w="full"
colorScheme="red"
onClick={() => onDelete()}
- leftIcon={isLoading ? <Spinner size="xs" /> : undefined}
+ leftIcon={loading ? <Spinner size="xs" /> : undefined}
>
{t('delete')}
</Button>
diff --git a/app/dashboard/src/components/Header.tsx b/app/dashboard/src/components/Header.tsx
index 6d1e0b8..e9364d0 100644
--- a/app/dashboard/src/components/Header.tsx
+++ b/app/dashboard/src/components/Header.tsx
@@ -19,7 +19,6 @@ import {
DocumentMinusIcon,
LinkIcon,
MoonIcon,
- SquaresPlusIcon,
SunIcon,
} from '@heroicons/react/24/outline';
import { pages } from 'constants/Pages';
@@ -54,7 +53,6 @@ const SettingsIcon = chakra(Bars3Icon, iconProps);
const LogoutIcon = chakra(ArrowLeftStartOnRectangleIcon, iconProps);
const DonationIcon = chakra(CurrencyDollarIcon, iconProps);
const HostsIcon = chakra(LinkIcon, iconProps);
-const NodesIcon = chakra(SquaresPlusIcon, iconProps);
const NodesUsageIcon = chakra(ChartPieIcon, iconProps);
const ResetUsageIcon = chakra(DocumentMinusIcon, iconProps);
const NotificationCircle = chakra(Box, {
@@ -95,7 +93,6 @@ export const Header: FC<HeaderProps> = () => {
onEditingHosts,
} = useHosts();
const {
- onEditingNodes,
onShowingNodesUsage,
} = useNodes();
const { t } = useTranslation();
@@ -150,14 +147,6 @@ export const Header: FC<HeaderProps> = () => {
>
{t('header.hostSettings')}
</MenuItem>
- <MenuItem
- maxW="170px"
- fontSize="sm"
- icon={<NodesIcon />}
- onClick={onEditingNodes.bind(null, true)}
- >
- {t('header.nodeSettings')}
- </MenuItem>
<MenuItem
maxW="170px"
fontSize="sm"
diff --git a/app/dashboard/src/components/NodesFilters.tsx b/app/dashboard/src/components/NodesFilters.tsx
new file mode 100644
index 0000000..79538be
--- /dev/null
+++ b/app/dashboard/src/components/NodesFilters.tsx
@@ -0,0 +1,191 @@
+import {
+ BoxProps,
+ Button,
+ chakra,
+ Grid,
+ GridItem,
+ HStack,
+ IconButton,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ InputRightElement,
+ Spinner,
+ Tooltip,
+} from '@chakra-ui/react';
+import {
+ ArrowPathIcon,
+ MagnifyingGlassIcon,
+ XMarkIcon,
+} from '@heroicons/react/24/outline';
+import classNames from 'classnames';
+import { useDashboard } from 'contexts/DashboardContext';
+import { useNodes } from 'contexts/NodesContext';
+import debounce from 'lodash.debounce';
+import React, { FC, useEffect, useState } from 'react';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
+import { CopiedIcon, SubscriptionLinkIcon } from './Table/Icons';
+
+const iconProps = {
+ baseStyle: {
+ w: 4,
+ h: 4,
+ },
+};
+
+const SearchIcon = chakra(MagnifyingGlassIcon, iconProps);
+const ClearIcon = chakra(XMarkIcon, iconProps);
+export const ReloadIcon = chakra(ArrowPathIcon, iconProps);
+
+export type FilterProps = {} & BoxProps;
+const setSearchField = debounce((name: string) => {
+ useNodes.getState().onFilterChange({
+ ...useNodes.getState().nodesFilters,
+ offset: 0,
+ name,
+ });
+}, 300);
+
+export const NodesFilters: FC<FilterProps> = ({ ...props }) => {
+ const { loading } = useDashboard();
+ const { nodesFilters: filters, onFilterChange, refetchNodes, onAddingNode: onAddNodes } = useNodes();
+ const { t } = useTranslation();
+ const [search, setSearch] = useState('');
+ const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ setSearch(e.target.value);
+ setSearchField(e.target.value);
+ };
+ const clear = () => {
+ setSearch('');
+ onFilterChange({
+ ...filters,
+ offset: 0,
+ name: '',
+ });
+ };
+ const { certificate } = useNodes();
+
+ const [copied, setCopied] = useState([-1, false]);
+ useEffect(() => {
+ if (copied[1]) {
+ setTimeout(() => {
+ setCopied([-1, false]);
+ }, 1000);
+ }
+ }, [copied]);
+
+ return (
+ <Grid
+ id="nodes-filters"
+ templateColumns={{
+ lg: 'repeat(3, 1fr)',
+ md: 'repeat(4, 1fr)',
+ base: 'repeat(1, 1fr)',
+ }}
+ position="sticky"
+ top={0}
+ mx="-6"
+ px="6"
+ rowGap={4}
+ gap={{
+ lg: 4,
+ base: 0,
+ }}
+ bg="var(--chakra-colors-chakra-body-bg)"
+ py={4}
+ zIndex="docked"
+ {...props}
+ >
+ <GridItem colSpan={{ base: 1, md: 2, lg: 1 }} order={{ base: 2, md: 1 }}>
+ <InputGroup>
+ <InputLeftElement pointerEvents="none" children={<SearchIcon />} />
+ <Input
+ placeholder={t('search')}
+ value={search}
+ borderColor="light-border"
+ onChange={onChange}
+ />
+ <InputRightElement>
+ {loading && <Spinner size="xs" />}
+ {filters.name && filters.name.length > 0 && (
+ <IconButton
+ onClick={clear}
+ aria-label="clear"
+ size="xs"
+ variant="ghost"
+ >
+ <ClearIcon />
+ </IconButton>
+ )}
+ </InputRightElement>
+ </InputGroup>
+ </GridItem>
+ <GridItem colSpan={2} order={{ base: 1, md: 2 }}>
+ <HStack justifyContent="flex-end" alignItems="center" h="full">
+ <IconButton
+ aria-label="refresh name"
+ disabled={loading}
+ onClick={refetchNodes}
+ size="sm"
+ variant="outline"
+ >
+ <ReloadIcon
+ className={classNames({
+ 'animate-spin': loading,
+ })}
+ />
+ </IconButton>
+ <CopyToClipboard
+ text={
+ certificate
+ }
+ onCopy={() => {
+ setCopied([0, true]);
+ }}
+ >
+ <div>
+ <Tooltip
+ label={
+ copied[0] == 0 && copied[1]
+ ? t('nodesTable.copied')
+ : t('nodesTable.copyCertificate')
+ }
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="copy certificate"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ >
+ {copied[0] == 0 && copied[1] ? (
+ <CopiedIcon />
+ ) : (
+ <SubscriptionLinkIcon />
+ )}
+ </IconButton>
+ </Tooltip>
+ </div>
+ </CopyToClipboard>
+ <Button
+ colorScheme="primary"
+ size="sm"
+ onClick={() => onAddNodes(true)}
+ px={5}
+ >
+ {t('nodesTable.addNode')}
+ </Button>
+ </HStack>
+ </GridItem>
+ </Grid>
+ );
+};
diff --git a/app/dashboard/src/components/NodesModal.tsx b/app/dashboard/src/components/NodesModal.tsx
index 795f1c9..7ddf46c 100644
--- a/app/dashboard/src/components/NodesModal.tsx
+++ b/app/dashboard/src/components/NodesModal.tsx
@@ -1,74 +1,37 @@
import {
- Accordion,
- AccordionButton,
- AccordionIcon,
- AccordionItem,
- AccordionPanel,
- Alert,
- AlertDescription,
- AlertIcon,
- Badge,
Box,
Button,
- ButtonProps,
chakra,
Checkbox,
- Collapse,
FormControl,
FormLabel,
HStack,
- IconButton,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
- ModalHeader,
ModalOverlay,
Switch,
- Text,
Tooltip,
useToast,
- VStack,
+ VStack, IconButton, Spacer
} from '@chakra-ui/react';
-import {
- EyeIcon,
- EyeSlashIcon,
- PlusIcon as HeroIconPlusIcon,
- SquaresPlusIcon,
-} from '@heroicons/react/24/outline';
import { zodResolver } from '@hookform/resolvers/zod';
import {
- FetchNodesQueryKey,
- getNodeDefaultValues,
- NodeSchema,
- NodeType,
+ getNodeCreateDefaultValues,
+ NodeCreate,
+ NodeCreateSchema,
useNodes,
- useNodesQuery,
} from 'contexts/NodesContext';
-import { FC, ReactNode, useState } from 'react';
-import { Controller, useForm, UseFormReturn } from 'react-hook-form';
+import { FC, useEffect, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
-import {
- UseMutateFunction,
- useMutation,
- useQuery,
- useQueryClient,
-} from 'react-query';
import 'slick-carousel/slick/slick-theme.css';
import 'slick-carousel/slick/slick.css';
-import { Status } from 'types/User';
-import {
- generateErrorMessage,
- generateSuccessMessage,
-} from 'utils/toastHandler';
-import { DeleteNodeModal } from './DeleteNodeModal';
-import { DeleteIcon } from './Dialog/Icons';
-import { ReloadIcon } from './UsersFilters';
-import { Icon } from './Icon';
-import { StatusBadge } from './StatusBadge';
-
-import { fetch } from 'service/http';
+import { DeleteIcon, AddIcon, EditIcon } from './Dialog/Icons';
import { Input } from './Input';
+import { DialogModalHeader } from './Dialog/ModalHeader';
+import { DevTool } from '@hookform/devtools';
const CustomInput = chakra(Input, {
baseStyle: {
@@ -79,503 +42,204 @@ const CustomInput = chakra(Input, {
},
});
-const ModalIcon = chakra(SquaresPlusIcon, {
- baseStyle: {
- w: 5,
- h: 5,
- },
-});
-
-const PlusIcon = chakra(HeroIconPlusIcon, {
- baseStyle: {
- w: 5,
- h: 5,
- strokeWidth: 2,
- },
-});
-
-type AccordionInboundType = {
- toggleAccordion: () => void;
- node: NodeType;
-};
+export const NodesDialog: FC = () => {
+ const { editingNode, isEditingNode, onEditingNode, isAddingNode, refetchNodes, updateNode, addNode, onAddingNode, onDeletingNode } = useNodes();
+ const [loading, setLoading] = useState(false);
+ const [, setError] = useState<string | null>('');
-const NodeAccordion: FC<AccordionInboundType> = ({ toggleAccordion, node }) => {
- const { updateNode, reconnectNode, setDeletingNode } = useNodes();
const { t } = useTranslation();
- const queryClient = useQueryClient();
- const toast = useToast();
- const form = useForm<NodeType>({
- defaultValues: node,
- resolver: zodResolver(NodeSchema),
- });
- const handleDeleteNode = setDeletingNode.bind(null, node);
-
- const { isLoading, mutate } = useMutation(updateNode, {
- onSuccess: () => {
- generateSuccessMessage('Node updated successfully', toast);
- queryClient.invalidateQueries(FetchNodesQueryKey);
- },
- onError: (e) => {
- generateErrorMessage(e, toast, form);
- },
- });
-
- const { isLoading: isReconnecting, mutate: reconnect } = useMutation(
- reconnectNode.bind(null, node),
- {
- onSuccess: () => {
- queryClient.invalidateQueries(FetchNodesQueryKey);
- },
- }
- );
-
- const nodeStatus: Status = isReconnecting ? 'connecting' : node.status ? node.status : 'unhealthy';
-
- return (
- <AccordionItem
- border="1px solid"
- _dark={{ borderColor: 'gray.600' }}
- _light={{ borderColor: 'gray.200' }}
- borderRadius="4px"
- p={1}
- w="full"
- >
- <AccordionButton px={2} borderRadius="3px" onClick={toggleAccordion}>
- <HStack w="full" justifyContent="space-between" pr={2}>
- <Text
- as="span"
- fontWeight="medium"
- fontSize="sm"
- flex="1"
- textAlign="left"
- color="gray.700"
- _dark={{ color: 'gray.300' }}
- >
- {node.name}
- </Text>
- <HStack>
- {node.xray_version && (
- <Badge
- colorScheme="blue"
- rounded="full"
- display="inline-flex"
- px={3}
- py={1}
- >
- <Text
- textTransform="capitalize"
- fontSize="0.7rem"
- fontWeight="medium"
- letterSpacing="tighter"
- >
- Xray {node.xray_version}
- </Text>
- </Badge>
- )}
- {node.status && <StatusBadge status={nodeStatus} compact />}
- </HStack>
- </HStack>
- <AccordionIcon />
- </AccordionButton>
- <AccordionPanel px={2} pb={2}>
- <VStack pb={3} alignItems="flex-start">
- {nodeStatus === 'unhealthy' && (
- <Alert status="error" size="xs">
- <Box>
- <HStack w="full">
- <AlertIcon w={4} />
- <Text marginInlineEnd={0}>{node.message}</Text>
- </HStack>
- <HStack justifyContent="flex-end" w="full">
- <Button
- size="sm"
- aria-label="reconnect node"
- leftIcon={<ReloadIcon />}
- onClick={() => reconnect()}
- disabled={isReconnecting}
- >
- {isReconnecting
- ? t('nodes.reconnecting')
- : t('nodes.reconnect')}
- </Button>
- </HStack>
- </Box>
- </Alert>
- )}
- </VStack>
- <NodeForm
- form={form}
- mutate={mutate}
- isLoading={isLoading}
- submitBtnText={t('nodes.editNode')}
- btnLeftAdornment={
- <Tooltip label={t('delete')} placement="top">
- <IconButton
- colorScheme="red"
- variant="ghost"
- size="sm"
- aria-label="delete node"
- onClick={handleDeleteNode}
- >
- <DeleteIcon />
- </IconButton>
- </Tooltip>
- }
- />
- </AccordionPanel>
- </AccordionItem>
- );
-};
-
-type AddNodeFormType = {
- toggleAccordion: () => void;
- resetAccordions: () => void;
-};
-
-const AddNodeForm: FC<AddNodeFormType> = ({
- toggleAccordion,
- resetAccordions,
-}) => {
const toast = useToast();
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const { addNode } = useNodes();
- const form = useForm<NodeType>({
- resolver: zodResolver(NodeSchema),
- defaultValues: {
- ...getNodeDefaultValues(),
- add_as_new_host: true,
- },
- });
- const { isLoading, mutate } = useMutation(addNode, {
- onSuccess: () => {
- generateSuccessMessage(
- t('nodes.addNodeSuccess', { name: form.getValues('name') }),
- toast
- );
- queryClient.invalidateQueries(FetchNodesQueryKey);
- form.reset();
- resetAccordions();
- },
- onError: (e) => {
- generateErrorMessage(e, toast, form);
- },
- });
- return (
- <AccordionItem
- border="1px solid"
- _dark={{ borderColor: 'gray.600' }}
- _light={{ borderColor: 'gray.200' }}
- borderRadius="4px"
- p={1}
- w="full"
- >
- <AccordionButton px={2} borderRadius="3px" onClick={toggleAccordion}>
- <Text
- as="span"
- fontWeight="medium"
- fontSize="sm"
- flex="1"
- textAlign="left"
- color="gray.700"
- _dark={{ color: 'gray.300' }}
- display="flex"
- gap={1}
- >
- <PlusIcon display={'inline-block'} />{' '}
- <span>{t('nodes.addNewMarzbanNode')}</span>
- </Text>
- </AccordionButton>
- <AccordionPanel px={2} py={4}>
- <NodeForm
- form={form}
- mutate={mutate}
- isLoading={isLoading}
- submitBtnText={t('nodes.addNode')}
- btnProps={{ variant: 'solid' }}
- addAsHost
- />
- </AccordionPanel>
- </AccordionItem>
- );
-};
-
-type NodeFormType = FC<{
- form: UseFormReturn<NodeType>;
- mutate: UseMutateFunction<unknown, unknown, any>;
- isLoading: boolean;
- submitBtnText: string;
- btnProps?: Partial<ButtonProps>;
- btnLeftAdornment?: ReactNode;
- addAsHost?: boolean;
-}>;
-
-const NodeForm: NodeFormType = ({
- form,
- mutate,
- isLoading,
- submitBtnText,
- btnProps = {},
- btnLeftAdornment,
- addAsHost = false,
-}) => {
- const { t } = useTranslation();
- const [showCertificate, setShowCertificate] = useState(false);
- const { data: nodeSettings } = useQuery({
- queryKey: 'node-settings',
- queryFn: () =>
- fetch<{
- min_node_version: string;
- certificate: string;
- }>('/node/settings'),
+ const defaultValueNode: NodeCreate = (isEditingNode === true && editingNode !== null && editingNode !== undefined) ? editingNode : getNodeCreateDefaultValues();
+ const form = useForm<NodeCreate>({
+ resolver: zodResolver(NodeCreateSchema),
+ defaultValues: defaultValueNode,
});
- function selectText(node: HTMLElement) {
- // @ts-ignore
- if (document.body.createTextRange) {
- // @ts-ignore
- const range = document.body.createTextRange();
- range.moveToElementText(node);
- range.select();
- } else if (window.getSelection) {
- const selection = window.getSelection();
- const range = document.createRange();
- range.selectNodeContents(node);
- selection!.removeAllRanges();
- selection!.addRange(range);
- } else {
- console.warn('Could not select text in node: Unsupported browser.');
- }
- }
-
- return (
- <form onSubmit={form.handleSubmit((v) => mutate(v))}>
- <VStack>
- {nodeSettings && nodeSettings.certificate && (
- <Alert status="info" alignItems="start">
- <AlertDescription
- display="flex"
- flexDirection="column"
- overflow="hidden"
- >
- <span>{t('nodes.connection-hint')}</span>
- <HStack justify="end" py={2}>
- <Button
- as="a"
- colorScheme="primary"
- size="xs"
- download="ssl_client_cert.pem"
- href={URL.createObjectURL(
- new Blob([nodeSettings.certificate], { type: 'text/plain' })
- )}
- >
- {t('nodes.download-certificate')}
- </Button>
- <Tooltip
- placement="top"
- label={t(
- !showCertificate
- ? 'nodes.show-certificate'
- : 'nodes.show-certificate'
- )}
- >
- <IconButton
- aria-label={t(
- !showCertificate
- ? 'nodes.show-certificate'
- : 'nodes.show-certificate'
- )}
- onClick={setShowCertificate.bind(null, !showCertificate)}
- colorScheme="whiteAlpha"
- color="primary"
- size="xs"
- >
- {!showCertificate ? (
- <EyeIcon width="15px" />
- ) : (
- <EyeSlashIcon width="15px" />
- )}
- </IconButton>
- </Tooltip>
- </HStack>
- <Collapse in={showCertificate} animateOpacity>
- <Text
- bg="rgba(255,255,255,.5)"
- _dark={{
- bg: 'rgba(255,255,255,.2)',
- }}
- rounded="md"
- p="2"
- lineHeight="1.2"
- fontSize="10px"
- fontFamily="Courier"
- whiteSpace="pre"
- overflow="auto"
- onClick={(e) => {
- selectText(e.target as HTMLElement);
- }}
- >
- {nodeSettings.certificate}
- </Text>
- </Collapse>
- </AlertDescription>
- </Alert>
- )}
-
- <HStack w="full">
- <FormControl>
- <CustomInput
- label={t('nodes.nodeName')}
- size="sm"
- placeholder="Marzban-S2"
- {...form.register('name')}
- error={form.formState?.errors?.name?.message}
- />
- </FormControl>
- <HStack px={1}>
- <Controller
- name="status"
- control={form.control}
- render={({ field }) => {
- return (
- <Tooltip
- key={field.value}
- placement="top"
- label={
- `${t('usersTable.status')}: ` +
- (field.value !== 'disabled' ? t('active') : t('disabled'))
- }
- textTransform="capitalize"
- >
- <Box mt="6">
- <Switch
- colorScheme="primary"
- isChecked={field.value !== 'disabled'}
- onChange={(e) => {
- if (e.target.checked) {
- field.onChange('connecting');
- } else {
- field.onChange('disabled');
- }
- }}
- />
- </Box>
- </Tooltip>
- );
- }}
- />
- </HStack>
- </HStack>
- <HStack alignItems="flex-start">
- <Box w="50%">
- <CustomInput
- label={t('nodes.nodeAddress')}
- size="sm"
- placeholder="51.20.12.13"
- {...form.register('address')}
- error={form.formState?.errors?.address?.message}
- />
- </Box>
- <Box w="25%">
- <CustomInput
- label={t('nodes.nodePort')}
- size="sm"
- placeholder="62050"
- {...form.register('port')}
- error={form.formState?.errors?.port?.message}
- />
- </Box>
- </HStack>
- {addAsHost && (
- <FormControl py={1}>
- <Checkbox {...form.register('add_as_new_host')}>
- <FormLabel m={0}>{t('nodes.addHostForEveryInbound')}</FormLabel>
- </Checkbox>
- </FormControl>
- )}
- <HStack w="full">
- {btnLeftAdornment}
- <Button
- flexGrow={1}
- type="submit"
- colorScheme="primary"
- size="sm"
- px={5}
- w="full"
- isLoading={isLoading}
- {...btnProps}
- >
- {submitBtnText}
- </Button>
- </HStack>
- </VStack>
- </form>
- );
-};
-
-export const NodesDialog: FC = () => {
- const { isEditingNodes, onEditingNodes } = useNodes();
- const { t } = useTranslation();
- const [openAccordions, setOpenAccordions] = useState<any>({});
- const { data: nodes, isLoading } = useNodesQuery();
-
+ const isOpen = isEditingNode || isAddingNode;
const onClose = () => {
- setOpenAccordions({});
- onEditingNodes(false);
+ onAddingNode(false);
+ onEditingNode(undefined);
};
-
- const toggleAccordion = (index: number | string) => {
- if (openAccordions[String(index)]) {
- delete openAccordions[String(index)];
- } else openAccordions[String(index)] = {};
-
- setOpenAccordions({ ...openAccordions });
+ useEffect(() => {
+ if (isOpen) {
+ refetchNodes();
+ form.reset(defaultValueNode);
+ }
+ }, [isOpen]);
+
+ const submit = async (values: NodeCreate) => {
+ setLoading(true);
+ const methods = { edited: updateNode, created: addNode };
+ const method = isEditingNode ? 'edited' : 'created';
+ const { name, port, id, address, usage_coefficient } = values;
+ setError(null);
+ let body: NodeCreate = {
+ id,
+ name,
+ address,
+ port,
+ status: values.status,
+ usage_coefficient,
+ add_as_new_host: values.add_as_new_host,
+ };
+
+ await methods[method](body)
+ .then(() => {
+ toast({
+ title: t(
+ isEditingNode ? 'nodeDialog.editNode' : 'nodeDialog.createNewNode',
+ { name: values.name }
+ ),
+ status: 'success',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ refetchNodes();
+ onClose();
+ })
+ .catch((err) => {
+ if (err?.response?.status === 409 || err?.response?.status === 400)
+ setError(err?.response?._data?.detail);
+ if (err?.response?.status === 422) {
+ Object.keys(err.response._data.detail).forEach((key) => {
+ setError(err?.response._data.detail[key] as string);
+ form.setError(
+ key as 'address' | 'port' | 'name' | 'status',
+ {
+ type: 'custom',
+ message: err.response._data.detail[key],
+ }
+ );
+ });
+ }
+ })
+ .finally(() => {
+ setLoading(false);
+ });
};
return (
<>
- <Modal isOpen={isEditingNodes} onClose={onClose}>
+ <Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
<ModalContent mx="3" w="fit-content" maxW="3xl">
- <ModalHeader pt={6}>
- <Icon color="primary">
- <ModalIcon color="white" />
- </Icon>
- </ModalHeader>
+ <DialogModalHeader HeaderIcon={isEditingNode ? EditIcon : AddIcon} title={isEditingNode ? t('nodeDialog.editNode') : t('nodeDialog.addNode')} />
<ModalCloseButton mt={3} />
<ModalBody w="440px" pb={6} pt={3}>
- <Text mb={3} opacity={0.8} fontSize="sm">
- {t('nodes.title')}
- </Text>
- {isLoading && 'loading...'}
-
- <Accordion
- w="full"
- allowToggle
- index={Object.keys(openAccordions).map((i) => parseInt(i))}
- >
- <VStack w="full">
- {!isLoading &&
- nodes &&
- nodes.map((node, index) => {
- return (
- <NodeAccordion
- toggleAccordion={() => toggleAccordion(index)}
- key={node.name}
- node={node}
+ <VStack>
+ {loading && 'loading...'}
+
+ <form onSubmit={form.handleSubmit((v) => submit(v))}>
+ <VStack>
+ <HStack w="full">
+ <FormControl>
+ <CustomInput
+ label={t('nodes.nodeName')}
+ size="sm"
+ placeholder="Marzn-Node-2"
+ {...form.register('name')}
+ error={form.formState?.errors?.name?.message}
/>
- );
- })}
-
- <AddNodeForm
- toggleAccordion={() => toggleAccordion((nodes || []).length)}
- resetAccordions={() => setOpenAccordions({})}
- />
- </VStack>
- </Accordion>
+ </FormControl>
+ <HStack px={1}>
+ <Controller
+ name="status"
+ control={form.control}
+ render={({ field }) => {
+ return (
+ <Tooltip
+ key={field.value}
+ placement="top"
+ label={
+ `${t('usersTable.status')}: ` +
+ (field.value !== 'disabled' ? t('active') : t('disabled'))
+ }
+ textTransform="capitalize"
+ >
+ <Box mt="6">
+ <Switch
+ colorScheme="primary"
+ isChecked={field.value !== 'disabled'}
+ onChange={(e) => {
+ if (e.target.checked) {
+ field.onChange('none');
+ } else {
+ field.onChange('disabled');
+ }
+ }}
+ />
+ </Box>
+ </Tooltip>
+ );
+ }}
+ />
+ </HStack>
+ </HStack>
+ <HStack alignItems="flex-start">
+ <Box w="65%">
+ <CustomInput
+ label={t('nodes.nodeAddress')}
+ size="sm"
+ placeholder="51.20.12.13"
+ {...form.register('address')}
+ error={form.formState?.errors?.address?.message}
+ />
+ </Box>
+ <Box w="35%">
+ <CustomInput
+ label={t('nodes.nodePort')}
+ size="sm"
+ placeholder="62050"
+ {...form.register('port')}
+ error={form.formState?.errors?.port?.message}
+ />
+ </Box>
+ </HStack>
+ {!editingNode && (
+ <FormControl py={1}>
+ <Checkbox {...form.register('add_as_new_host')}>
+ <FormLabel m={0}>{t('nodes.addHostForEveryInbound')}</FormLabel>
+ </Checkbox>
+ </FormControl>
+ )}
+ <Spacer m={2}></Spacer>
+ <HStack w="full">
+ <Tooltip label={t('delete')} placement="top">
+ <IconButton
+ colorScheme="red"
+ variant="ghost"
+ size="sm"
+ aria-label="delete node"
+ onClick={() => {
+ if (editingNode !== null) {
+ console.log('deleting')
+ onDeletingNode(editingNode);
+ onClose();
+ }
+ }}
+ >
+ <DeleteIcon />
+ </IconButton>
+ </Tooltip>
+ <Button
+ flexGrow={1}
+ type="submit"
+ colorScheme="primary"
+ size="sm"
+ px={5}
+ w="full"
+ isLoading={loading}
+ >
+ {isEditingNode ? t('nodeDialog.updateSubmit') : t('nodeDialog.addSubmit')}
+ </Button>
+ </HStack>
+ </VStack>
+ </form>
+ <DevTool control={form.control} />
+ </VStack>
</ModalBody>
</ModalContent>
</Modal>
- <DeleteNodeModal deleteCallback={() => setOpenAccordions({})} />
</>
);
-};
+}
diff --git a/app/dashboard/src/components/NodesTable/index.tsx b/app/dashboard/src/components/NodesTable/index.tsx
new file mode 100644
index 0000000..db5b08a
--- /dev/null
+++ b/app/dashboard/src/components/NodesTable/index.tsx
@@ -0,0 +1,476 @@
+
+import {
+ Accordion,
+ AccordionButton,
+ AccordionItem,
+ AccordionPanel,
+ Box,
+ chakra,
+ HStack,
+ IconButton,
+ Table,
+ TableProps,
+ Tbody,
+ Td,
+ Text,
+ Th,
+ Thead,
+ Tooltip,
+ Tr,
+ useBreakpointValue,
+ VStack,
+} from '@chakra-ui/react';
+import classNames from 'classnames';
+
+import { t } from 'i18next';
+import { FC, Fragment, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pagination } from 'components/Table/Pagination';
+import { StatusBadge } from '../StatusBadge';
+import { EmptySection } from 'components/Table/EmptySection';
+import {
+ AccordionArrowIcon,
+ EditIcon,
+} from 'components/Table/Icons';
+import { Sort } from 'components/Table/Sort';
+import { StatusSortSelect } from 'components/Table/StatusSortSelect';
+import { useNodes } from 'contexts/NodesContext';
+import { Cog6ToothIcon } from '@heroicons/react/24/outline';
+
+const iconProps = {
+ baseStyle: {
+ w: 4,
+ h: 4,
+ },
+};
+const CoreSettingsIcon = chakra(Cog6ToothIcon, iconProps);
+
+type ExpandedIndex = number | number[];
+
+type NodesTableProps = {} & TableProps;
+
+export const NodesTable: FC<NodesTableProps> = (props) => {
+ const {
+ nodesFilters: filters,
+ nodes,
+ onEditingNode: onEditingNodes,
+ onAddingNode: onCreateNodes,
+ onFilterChange,
+ } = useNodes();
+
+ const total = nodes.length;
+
+ const { t } = useTranslation();
+ const [selectedRow, setSelectedRow] = useState<ExpandedIndex | undefined>(
+ undefined
+ );
+ const marginTop = useBreakpointValue({ base: 120, lg: 72 }) || 72;
+ const [top, setTop] = useState(`${marginTop}px`);
+ const useTable = useBreakpointValue({ base: false, md: true });
+
+ useEffect(() => {
+ const calcTop = () => {
+ const el = document.querySelectorAll('#filters')[0] as HTMLElement;
+ setTop(`${el.offsetHeight}px`);
+ };
+ window.addEventListener('scroll', calcTop);
+ () => window.removeEventListener('scroll', calcTop);
+ }, []);
+
+ // TODO: Find a different mechancism to to detect ftiler
+ // const isFiltered = users.length !== total;
+
+ const handleSort = (column: string) => {
+ let newSort = filters.sort;
+ if (newSort.includes(column)) {
+ if (newSort.startsWith('-')) {
+ newSort = '-created_at';
+ } else {
+ newSort = '-' + column;
+ }
+ } else {
+ newSort = column;
+ }
+ onFilterChange({
+ sort: newSort,
+ });
+ };
+
+ const handleStatusFilter = (e: any) => {
+ onFilterChange({
+ status: e.target.value.length > 0 ? e.target.value : undefined,
+ });
+ };
+
+ const toggleAccordion = (index: number) => {
+ setSelectedRow(index === selectedRow ? undefined : index);
+ };
+
+ const StatusSortSelectNodes = () => (<StatusSortSelect filters={filters} options={['healthy', 'unhealthy', 'disabled']} handleStatusFilter={handleStatusFilter} />);
+
+ return (
+ <Box id="node-table" overflowX={{ base: 'unset', md: 'unset' }} >
+ <Accordion
+ allowMultiple
+ display={{ base: 'block', md: 'none' }}
+ index={selectedRow}
+ >
+ <Table orientation="vertical" zIndex="docked" {...props}>
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ position="sticky"
+ top={top}
+ minW="120px"
+ pl={4}
+ pr={4}
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'name')}
+ >
+ <HStack>
+ <span>{t('name')}</span>
+ <Sort sort={filters.sort} column="name" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="50px"
+ pl={0}
+ pr={0}
+ w="140px"
+ cursor={'pointer'}
+ >
+ <HStack spacing={0} position="relative">
+ <Text
+ position="absolute"
+ _dark={{
+ bg: 'gray.750',
+ }}
+ _light={{
+ bg: '#F9FAFB',
+ }}
+ userSelect="none"
+ pointerEvents="none"
+ zIndex={1}
+ w="100%"
+ >
+ {t('nodesTable.status')}
+ {filters.status ? ': ' + filters.status : ''}
+ </Text>
+ <StatusSortSelectNodes />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, 'xray_version')}
+ >
+ <HStack>
+ <span>{t('nodesTable.xrayVersion')}</span>
+ <Sort sort={filters.sort} column="xray_version" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ >
+ <span>{t('nodesTable.ipPort')}</span>
+ </Th>
+ {/* Action buttons (certificate, core settings)*/}
+ <Th
+ position="sticky"
+ top={top}
+ minW="32px"
+ w="32px"
+ p={0}
+ cursor={'pointer'}
+ ></Th>
+ </Tr>
+ </Thead>
+ <Tbody>
+ {!useTable &&
+ nodes?.map((node, i) => {
+ return (
+ <Fragment key={node.name}>
+ <Tr
+ onClick={toggleAccordion.bind(null, i)}
+ cursor="pointer"
+ >
+ <Td borderBottom={0} minW="100px" pl={4} pr={4}>
+ {node.name}
+ </Td>
+ <Td borderBottom={0} minW="50px" pl={0} pr={0}>
+ <StatusBadge
+ compact
+ showDetail={false}
+ status={node.status}
+ />
+ </Td>
+ <Td borderBottom={0} minW="100px" pr={0}>
+ {node.xray_version}
+ </Td>
+ <Td p={0} borderBottom={0} w="32px" minW="32px">
+ <AccordionArrowIcon
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ transition="transform .2s ease-out"
+ transform={
+ selectedRow === i ? 'rotate(180deg)' : '0deg'
+ }
+ />
+ </Td>
+ </Tr>
+ <Tr
+ className="collapsible"
+ onClick={toggleAccordion.bind(null, i)}
+ >
+ <Td p={0} colSpan={4}>
+ <AccordionItem border={0}>
+ <AccordionButton display="none"></AccordionButton>
+ <AccordionPanel
+ border={0}
+ cursor="pointer"
+ px={6}
+ py={3}
+ >
+ <VStack justifyContent="space-between" spacing="4">
+ <VStack
+ alignItems="flex-start"
+ w="full"
+ spacing={-1}
+ >
+ <Text
+ textTransform="capitalize"
+ fontSize="xs"
+ fontWeight="bold"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ {t('nodesTable.ipPort')}
+ </Text>
+ <Box width="full" minW="230px">
+ {node.address}:{node.port}
+ </Box>
+ </VStack>
+ <HStack w="full" justifyContent="space-between">
+ <HStack>
+ <ActionButtons />
+ <Tooltip
+ label={t('nodesDialog.editNode')}
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="Edit user"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onEditingNodes(node);
+ }}
+ >
+ <EditIcon />
+ </IconButton>
+ </Tooltip>
+ </HStack>
+ </HStack>
+ </VStack>
+ </AccordionPanel>
+ </AccordionItem>
+ </Td>
+ </Tr>
+ </Fragment>
+ );
+ })}
+ </Tbody>
+ </Table>
+ </Accordion>
+ <Table
+ orientation="vertical"
+ display={{ base: 'none', md: 'table' }}
+ {...props}
+ >
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ minW="140px"
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'name')}
+ >
+ <HStack>
+ <span>{t('name')}</span>
+ <Sort sort={filters.sort} column="name" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ width="400px"
+ minW="150px"
+ cursor={'pointer'}
+ >
+ <HStack spacing={0} position="relative">
+ <Text
+ position="absolute"
+ _dark={{
+ bg: 'gray.750',
+ }}
+ _light={{
+ bg: '#F9FAFB',
+ }}
+ userSelect="none"
+ pointerEvents="none"
+ zIndex={1}
+ w="100%"
+ >
+ {t('nodesTable.status')}
+ {filters.status ? ': ' + filters.status : ''}
+ </Text>
+ <StatusSortSelectNodes />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ width="350px"
+ minW="230px"
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'xray_version')}
+ >
+ <HStack>
+ <span>{t('nodesTable.xrayVersion')}</span>
+ <Sort sort={filters.sort} column="xray_version" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ width="350px"
+ minW="230px"
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'xray_version')}
+ >
+ <HStack>
+ <span>{t('nodesTable.ipPort')}</span>
+ <Sort sort={filters.sort} column="address" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ width="200px"
+ minW="180px"
+ />
+ </Tr>
+ </Thead>
+ <Tbody>
+ {useTable &&
+ nodes?.map((node, i) => {
+ return (
+ <Tr
+ key={node.name}
+ className={classNames('interactive', {
+ 'last-row': i === nodes.length - 1,
+ })}
+ onClick={() => onEditingNodes(node)}
+ >
+ <Td minW="140px">
+ <div className="flex-status">
+ {node.name}
+ </div>
+ </Td>
+ <Td width="400px" minW="150px">
+ <StatusBadge
+ status={node.status}
+ />
+ </Td>
+ <Td width="350px" minW="150px">
+ {node.xray_version}
+ </Td>
+ <Td width="350px" minW="150px">
+ {node.address}:{node.port}
+ </Td>
+ <Td width="200px" minW="180px">
+ <ActionButtons />
+ </Td>
+ </Tr>
+ );
+ })}
+ {nodes.length == 0 && (
+ <Tr>
+ <Td colSpan={5}>
+ <EmptySection
+ isFiltered={/* isFiltered */ false}
+ noObjectT="nodesTable.noNode"
+ noObjectMatchedT='nodesTable.noNodeMatched'
+ createObjectT="addNode"
+ onCreateObject={onCreateNodes}
+ />
+ </Td>
+ </Tr>
+ )}
+ </Tbody>
+ </Table>
+ <Pagination filters={filters} total={total} onFilterChange={onFilterChange} />
+ </Box>
+ );
+};
+
+type ActionButtonsProps = {};
+
+const ActionButtons: FC<ActionButtonsProps> = () => {
+ return (
+ <HStack
+ justifyContent="flex-end"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <Tooltip
+ label={
+ t('nodesTable.coreSettings')
+ }
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="core settings"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ >
+ <CoreSettingsIcon />
+ </IconButton>
+ </Tooltip>
+ </HStack >
+ );
+};
diff --git a/app/dashboard/src/components/Sidebar/index.tsx b/app/dashboard/src/components/Sidebar/index.tsx
index 4f2ddff..a3de888 100644
--- a/app/dashboard/src/components/Sidebar/index.tsx
+++ b/app/dashboard/src/components/Sidebar/index.tsx
@@ -85,7 +85,7 @@ export const Sidebar = () => {
<VStack pt="30px" gap="2">
<Image src="favicon/logo.svg" alt="marzneshin" boxSize="50px" />
{pages.map((item: SideBarItemProps, index: number) =>
- <SidebarItem path={item.path} name={item.name} itemIcon={item.itemIcon} index={index} />)}
+ <SidebarItem path={item.path} name={item.name} itemIcon={item.itemIcon} index={index} key={index} />)}
</VStack>
</Flex>
</aside>
diff --git a/app/dashboard/src/components/StatusBadge.tsx b/app/dashboard/src/components/StatusBadge.tsx
index 0fafbf8..b5a7c54 100644
--- a/app/dashboard/src/components/StatusBadge.tsx
+++ b/app/dashboard/src/components/StatusBadge.tsx
@@ -1,32 +1,32 @@
import { Badge, Text } from '@chakra-ui/react';
-import { statusColors } from 'constants/UserSettings';
+import { statusColors } from 'constants/Settings';
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
-import { Status as UserStatusType } from 'types/User';
+import { Status as StatusType } from 'types/User';
import { relativeExpiryDate } from 'utils/dateFormatter';
-type UserStatusProps = {
+type StatusProps = {
expiryDate?: number | null;
- status: UserStatusType;
+ status: StatusType;
compact?: boolean;
showDetail?: boolean;
extraText?: string | null;
};
-export const StatusBadge: FC<UserStatusProps> = ({
+export const StatusBadge: FC<StatusProps> = ({
expiryDate,
- status: userStatus,
+ status,
compact = false,
showDetail = true,
extraText,
}) => {
const { t } = useTranslation();
const dateInfo = relativeExpiryDate(expiryDate);
- const Icon = statusColors[userStatus].icon;
+ const Icon = statusColors[status].icon;
return (
<>
<Badge
- colorScheme={statusColors[userStatus].statusColor}
+ colorScheme={statusColors[status].statusColor}
rounded="full"
display="inline-flex"
px={3}
@@ -43,7 +43,7 @@ export const StatusBadge: FC<UserStatusProps> = ({
fontWeight="medium"
letterSpacing="tighter"
>
- {userStatus}
+ {status}
{extraText && `: ${extraText}`}
</Text>
)}
diff --git a/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx b/app/dashboard/src/components/Table/StatusSortSelect.tsx
similarity index 72%
rename from app/dashboard/src/components/UsersTable/StatusSortSelect.tsx
rename to app/dashboard/src/components/Table/StatusSortSelect.tsx
index 6e0806b..24bf606 100644
--- a/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx
+++ b/app/dashboard/src/components/Table/StatusSortSelect.tsx
@@ -1,14 +1,15 @@
import { Select } from '@chakra-ui/react'
-import { UsersFilterType } from 'types/Filter';
+import { FilterType } from 'types/Filter';
import React from 'react'
interface StatusSortSelectProps {
- filters: UsersFilterType;
+ filters: FilterType;
handleStatusFilter: (e: any) => void;
+ options: string[];
}
-export const StatusSortSelect: React.FC<StatusSortSelectProps> = ({ filters, handleStatusFilter }) => {
+export const StatusSortSelect: React.FC<StatusSortSelectProps> = ({ filters, handleStatusFilter, options }) => {
return (
<div>
<Select
@@ -28,12 +29,8 @@ export const StatusSortSelect: React.FC<StatusSortSelectProps> = ({ filters, han
onChange={handleStatusFilter}
>
<option></option>
- <option>active</option>
- <option>disabled</option>
- <option>limited</option>
- <option>expired</option>
+ {options.map((option, i) => <option key={i}>{option}</option>)}
</Select>
-
</div>
)
}
diff --git a/app/dashboard/src/components/UserDialog/PeriodicUsageReset.tsx b/app/dashboard/src/components/UserDialog/PeriodicUsageReset.tsx
index 73bbb4b..5a7d530 100644
--- a/app/dashboard/src/components/UserDialog/PeriodicUsageReset.tsx
+++ b/app/dashboard/src/components/UserDialog/PeriodicUsageReset.tsx
@@ -1,12 +1,12 @@
import { FormControl, FormLabel, Select } from '@chakra-ui/react';
import { Controller } from 'react-hook-form';
-import { resetStrategy } from 'constants/UserSettings';
+import { resetStrategy } from 'constants/Settings';
import { TFunction } from 'i18next';
interface PreiodicUsageResetProps {
- form: any;
- t: TFunction<'translation', undefined, 'translation'>;
+ form: any;
+ t: TFunction<'translation', undefined, 'translation'>;
}
export const PeriodicUsageReset = ({ form, t }: PreiodicUsageResetProps) => {
diff --git a/app/dashboard/src/components/UsersTable/UsageSlider.tsx b/app/dashboard/src/components/UsersTable/UsageSlider.tsx
index bdbcc8b..cf17e65 100644
--- a/app/dashboard/src/components/UsersTable/UsageSlider.tsx
+++ b/app/dashboard/src/components/UsersTable/UsageSlider.tsx
@@ -1,5 +1,5 @@
import { HStack, Text, SliderTrack, SliderFilledTrack, Slider, SliderProps } from '@chakra-ui/react';
-import { resetStrategy } from 'constants/UserSettings';
+import { resetStrategy } from 'constants/Settings';
import { formatBytes } from 'utils/formatByte';
import { useTranslation } from 'react-i18next';
import { FC } from 'react';
@@ -21,7 +21,7 @@ export const UsageSliderCompact: FC<UsageSliderProps> = (props) => {
{formatBytes(used)} /{' '}
{isUnlimited ? (
<Text as="span" fontFamily="system-ui">
- ∞
+ ∞
</Text>
) : (
formatBytes(total)
@@ -32,10 +32,10 @@ export const UsageSliderCompact: FC<UsageSliderProps> = (props) => {
};
type UsageSliderProps = {
- used: number;
- total: number | null;
- dataLimitResetStrategy: string | null;
- totalUsedTraffic: number;
+ used: number;
+ total: number | null;
+ dataLimitResetStrategy: string | null;
+ totalUsedTraffic: number;
} & SliderProps;
const getResetStrategy = (strategy: string): string => {
@@ -84,17 +84,17 @@ export const UsageSlider: FC<UsageSliderProps> = (props) => {
{formatBytes(used)} /{' '}
{isUnlimited ? (
<Text as="span" fontFamily="system-ui">
- ∞
+ ∞
</Text>
) : (
formatBytes(total) +
- (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset'
- ? ' ' +
- t(
- 'userDialog.resetStrategy' +
- getResetStrategy(dataLimitResetStrategy)
- )
- : '')
+ (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset'
+ ? ' ' +
+ t(
+ 'userDialog.resetStrategy' +
+ getResetStrategy(dataLimitResetStrategy)
+ )
+ : '')
)}
</Text>
<Text>
diff --git a/app/dashboard/src/components/UsersTable/index.tsx b/app/dashboard/src/components/UsersTable/index.tsx
index c3e169c..43b03b0 100644
--- a/app/dashboard/src/components/UsersTable/index.tsx
+++ b/app/dashboard/src/components/UsersTable/index.tsx
@@ -21,7 +21,7 @@ import {
} from '@chakra-ui/react';
import classNames from 'classnames';
-import { statusColors } from 'constants/UserSettings';
+import { statusColors } from 'constants/Settings';
import { t } from 'i18next';
import { FC, Fragment, useEffect, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
@@ -42,7 +42,7 @@ import {
} from 'components/Table/Icons';
import { Sort } from 'components/Table/Sort';
import { UsageSlider, UsageSliderCompact } from './UsageSlider';
-import { StatusSortSelect } from './StatusSortSelect';
+import { StatusSortSelect } from 'components/Table/StatusSortSelect';
import { useUsers } from 'contexts/UsersContext';
type ExpandedIndex = number | number[];
@@ -154,7 +154,7 @@ export const UsersTable: FC<UsersTableProps> = (props) => {
{t('usersTable.status')}
{filters.status ? ': ' + filters.status : ''}
</Text>
- <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} />
+ <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} options={['active', 'disabled', 'limited', 'expired', 'on_hold']} />
</HStack>
</Th>
<Th
@@ -365,7 +365,7 @@ export const UsersTable: FC<UsersTableProps> = (props) => {
{t('usersTable.status')}
{filters.status ? ': ' + filters.status : ''}
</Text>
- <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} />
+ <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} options={['active', 'disabled', 'limited', 'expired', 'on_hold']} />
</HStack>
</Th>
<Th
diff --git a/app/dashboard/src/constants/UserSettings.tsx b/app/dashboard/src/constants/Settings.tsx
similarity index 88%
rename from app/dashboard/src/constants/UserSettings.tsx
rename to app/dashboard/src/constants/Settings.tsx
index 7fdd1e6..f049fb2 100644
--- a/app/dashboard/src/constants/UserSettings.tsx
+++ b/app/dashboard/src/constants/Settings.tsx
@@ -1,4 +1,4 @@
-import { chakra, ChakraComponent} from '@chakra-ui/react';
+import { chakra, ChakraComponent } from '@chakra-ui/react';
import {
ClockIcon,
ExclamationCircleIcon,
@@ -62,6 +62,11 @@ export const statusColors: {
bandWidthColor: 'primary',
icon: ActiveStatusIcon,
},
+ healthy: {
+ statusColor: 'green',
+ bandWidthColor: 'primary',
+ icon: ActiveStatusIcon,
+ },
disabled: {
statusColor: 'gray',
bandWidthColor: 'gray',
@@ -82,6 +87,11 @@ export const statusColors: {
bandWidthColor: 'orange',
icon: ExpiredStatusIcon,
},
+ unhealthy: {
+ statusColor: 'red',
+ bandWidthColor: 'red',
+ icon: LimitedStatusIcon,
+ },
limited: {
statusColor: 'red',
bandWidthColor: 'red',
diff --git a/app/dashboard/src/contexts/NodesContext.ts b/app/dashboard/src/contexts/NodesContext.ts
index 6c3507d..7f0ba53 100644
--- a/app/dashboard/src/contexts/NodesContext.ts
+++ b/app/dashboard/src/contexts/NodesContext.ts
@@ -1,8 +1,10 @@
import { useQuery } from 'react-query';
import { fetch } from 'service/http';
-import { z } from 'zod';
import { create } from 'zustand';
-import { FilterUsageType } from 'types/Filter';
+import { z } from 'zod';
+import { FilterUsageType, NodesFilterType } from 'types/Filter';
+import { getNodesPerPageLimitSize } from 'utils/userPreferenceStorage';
+import { useDashboard } from './DashboardContext';
export const NodeSchema = z.object({
name: z.string().min(1),
@@ -14,85 +16,166 @@ export const NodeSchema = z.object({
xray_version: z.string().nullable().optional(),
id: z.number().nullable().optional(),
status: z
- .enum(['healthy', 'unhealthy', 'disabled'])
- .nullable()
- .optional(),
+ .enum(['healthy', 'unhealthy', 'disabled']),
message: z.string().nullable().optional(),
+ usage_coefficient: z.number().default(1),
+ add_as_new_host: z.boolean(),
+});
+
+export const NodeCreateSchema = z.object({
+ name: z.string().min(1),
+ address: z.string().min(1),
+ port: z
+ .number()
+ .min(1)
+ .or(z.string().transform((v) => parseFloat(v))),
+ id: z.number().nullable().optional(),
+ status: z
+ .enum(['none', 'disabled']),
+ usage_coefficient: z.number().default(1),
add_as_new_host: z.boolean().optional(),
});
export type NodeType = z.infer<typeof NodeSchema>;
+export type NodeCreate = z.infer<typeof NodeCreateSchema>;
export const getNodeDefaultValues = (): NodeType => ({
name: '',
address: '',
+ status: 'unhealthy',
port: 62050,
xray_version: '',
+ add_as_new_host: false,
+ usage_coefficient: 1,
+});
+
+export const getNodeCreateDefaultValues = (): NodeCreate => ({
+ name: '',
+ address: '',
+ status: 'none',
+ port: 62050,
+ add_as_new_host: false,
+ usage_coefficient: 1,
+ id: 0,
});
export const FetchNodesQueryKey = 'fetch-nodes-query-key';
+export function fetchNodes() {
+ return fetch('/nodes');
+}
+
export type NodeStore = {
nodes: NodeType[];
- deletingNode?: NodeType | null;
- isEditingNodes: boolean;
+ certificate: string,
+ deletingNode?: NodeCreate | null;
+ editingNode: NodeCreate | null;
+ isAddingNode: boolean;
isShowingNodesUsage: boolean;
+ isEditingNode: boolean;
isEditingCore: boolean;
- addNode: (node: NodeType) => Promise<unknown>;
- fetchNodes: () => Promise<NodeType[]>;
+ nodesFilters: NodesFilterType;
+ refetchNodes: () => void;
+ refetchCertificate: () => void;
fetchNodesUsage: (query: FilterUsageType) => Promise<void>;
- updateNode: (node: NodeType) => Promise<unknown>;
+ addNode: (node: NodeCreate) => Promise<unknown>;
+ deleteNode: (node: NodeCreate) => Promise<unknown>;
+ updateNode: (node: NodeCreate) => Promise<unknown>;
reconnectNode: (node: NodeType) => Promise<unknown>;
- deleteNode: () => Promise<unknown>;
- setDeletingNode: (node: NodeType | null) => void;
- onEditingNodes: (isEditingHosts: boolean) => void;
+ onDeletingNode: (node: NodeCreate | undefined) => void;
+ onEditingNode: (node: NodeType | undefined) => void;
+ onAddingNode: (isAddingNode: boolean) => void;
+ onFilterChange: (filters: Partial<NodesFilterType>) => void;
onShowingNodesUsage: (isShowingNodesUsage: boolean) => void;
};
export const useNodesQuery = () => {
return useQuery({
queryKey: FetchNodesQueryKey,
- queryFn: useNodes.getState().fetchNodes,
- refetchInterval: useNodes.getState().isEditingNodes ? 3000 : undefined,
+ queryFn: fetchNodes,
+ refetchInterval: useNodes.getState().editingNode ? 3000 : undefined,
refetchOnWindowFocus: false,
});
};
export const useNodes = create<NodeStore>((set, get) => ({
nodes: [],
- isEditingNodes: false,
+ certificate: '',
+ editingNode: null,
+ deletingNode: null,
+ isAddingNode: false,
+ isEditingNode: false,
isShowingNodesUsage: false,
isEditingCore: false,
- addNode(body) {
- return fetch('/node', { method: 'POST', body });
+ nodesFilters: {
+ name: '',
+ limit: getNodesPerPageLimitSize(),
+ sort: '-created_at',
},
- fetchNodes() {
- return fetch('/nodes');
+ onFilterChange: (filters) => {
+ set({
+ nodesFilters: {
+ ...get().nodesFilters,
+ ...filters,
+ },
+ });
+ get().refetchNodes();
+ },
+ addNode: async (body) => {
+ return fetch('/nodes', { method: 'POST', body }).then(() => { get().refetchCertificate() });
+ },
+ refetchNodes: () => {
+ useDashboard.setState({ loading: true })
+ fetchNodes().then((nodes) => {
+ useDashboard.setState({ loading: false })
+ set({ nodes });
+ })
},
- fetchNodesUsage(query: FilterUsageType) {
+ refetchCertificate: () => {
+ fetch('/nodes/settings').then((res) => {
+ set({ certificate: res.certificate });
+ })
+ },
+ fetchNodesUsage: (query: FilterUsageType) => {
return fetch('/nodes/usage', { query });
},
- updateNode(body) {
- return fetch(`/node/${body.id}`, {
+ updateNode: (body) => {
+ console.log('update: ' + body.id)
+ return fetch(`/nodes/${body.id}`, {
method: 'PUT',
body,
});
},
- setDeletingNode(node) {
- set({ deletingNode: node });
+ onDeletingNode: (node: NodeCreate | undefined) => {
+ if (node !== undefined) {
+ set({ deletingNode: node });
+ }
+ else {
+ set({ deletingNode: undefined });
+ }
},
- reconnectNode(body) {
- return fetch(`/node/${body.id}/reconnect`, {
+ reconnectNode: (body) => {
+ return fetch(`/nodes/${body.id}/reconnect`, {
method: 'POST',
});
},
deleteNode: () => {
- return fetch(`/node/${get().deletingNode?.id}`, {
+ return fetch(`/nodes/${get().deletingNode?.id}`, {
method: 'DELETE',
});
},
- onEditingNodes: (isEditingNodes: boolean) => {
- set({ isEditingNodes });
+ onEditingNode: (editingNode: NodeType | undefined) => {
+ if (editingNode !== undefined) {
+ const { status, ...rest } = editingNode;
+ set({ editingNode: { ...rest, status: (status === 'healthy' || status === 'unhealthy') ? 'none' : 'disabled' }, isEditingNode: true });
+ }
+ else {
+ set({ editingNode: undefined, isEditingNode: false });
+
+ }
+ },
+ onAddingNode: (isAddingNode: boolean) => {
+ set({ isAddingNode });
},
onShowingNodesUsage: (isShowingNodesUsage: boolean) => {
set({ isShowingNodesUsage });
diff --git a/app/dashboard/src/contexts/ServicesContext.ts b/app/dashboard/src/contexts/ServicesContext.ts
index 4e95f0f..763152c 100644
--- a/app/dashboard/src/contexts/ServicesContext.ts
+++ b/app/dashboard/src/contexts/ServicesContext.ts
@@ -75,7 +75,7 @@ export const useServices = create(
},
deleteService: async (service: Service) => {
set({ editingService: null });
- return fetch(`/service/${service.id}`, { method: 'DELETE' }).then(() => {
+ return fetch(`/services/${service.id}`, { method: 'DELETE' }).then(() => {
set({ deletingService: null });
get().refetchServices();
useUsers.getState().refetchUsers();
@@ -83,14 +83,14 @@ export const useServices = create(
});
},
createService: async (body: ServiceCreate) => {
- return fetch('/service', { method: 'POST', body }).then(() => {
+ return fetch('/services', { method: 'POST', body }).then(() => {
set({ editingService: null });
get().refetchServices();
queryClient.invalidateQueries(StatisticsQueryKey);
});
},
editService: async (body: ServiceCreate) => {
- return fetch(`/service/${body.id}`, { method: 'PUT', body }).then(
+ return fetch(`/services/${body.id}`, { method: 'PUT', body }).then(
() => {
get().onEditingService(null);
get().refetchServices();
diff --git a/app/dashboard/src/pages/Login.tsx b/app/dashboard/src/pages/Login.tsx
index a9c99fb..56ea69b 100644
--- a/app/dashboard/src/pages/Login.tsx
+++ b/app/dashboard/src/pages/Login.tsx
@@ -72,7 +72,7 @@ export const Login: FC = () => {
formData.append('password', values.password);
formData.append('grant_type', 'password');
setLoading(true);
- fetch('/admin/token', { method: 'post', body: formData })
+ fetch('/admins/token', { method: 'post', body: formData })
.then(({ access_token: token }) => {
setAuthToken(token);
navigate('/');
diff --git a/app/dashboard/src/pages/Nodes.tsx b/app/dashboard/src/pages/Nodes.tsx
index 9afae43..a3be313 100644
--- a/app/dashboard/src/pages/Nodes.tsx
+++ b/app/dashboard/src/pages/Nodes.tsx
@@ -1,15 +1,29 @@
-import { FC } from 'react';
+import { FC, useEffect } from 'react';
import { Box, VStack } from '@chakra-ui/react';
import { CoreSettingsModal } from 'components/CoreSettingsModal';
import { NodesDialog } from 'components/NodesModal';
import { NodesUsage } from 'components/NodesUsage';
+import { NodesTable } from 'components/NodesTable';
+import { NodesFilters } from 'components/NodesFilters';
+import { DeleteNodeModal } from 'components/DeleteNodeModal';
+import { useDashboard } from 'contexts/DashboardContext';
+import { useNodes } from 'contexts/NodesContext';
+import { pages } from 'constants/Pages';
export const NodesPage: FC = () => {
+ useEffect(() => {
+ useDashboard.getState().activatePage(pages.findIndex((page) => page.path === '/nodes'));
+ useNodes.getState().refetchNodes();
+ useNodes.getState().refetchCertificate();
+ }, []);
return (
<VStack justifyContent="space-between" p="6" rowGap={4}>
- <Box>
+ <Box w="full">
+ <NodesFilters />
+ <NodesTable />
<NodesDialog />
+ <DeleteNodeModal />
<NodesUsage />
<CoreSettingsModal />
</Box>
diff --git a/app/dashboard/src/types/Filter.ts b/app/dashboard/src/types/Filter.ts
index d491a8b..2abe559 100644
--- a/app/dashboard/src/types/Filter.ts
+++ b/app/dashboard/src/types/Filter.ts
@@ -21,3 +21,13 @@ export type ServicesFilterType = FilterType & {
users?: number;
inbounds?: number;
};
+
+export type NodesFilterType = FilterType & {
+ name?: string;
+ address?: string;
+ port?: number;
+ xray_version?: number;
+ message?: string | null;
+ add_as_new_host?: boolean;
+ status?: 'healthy' | 'disabled' | 'healthy';
+}
diff --git a/app/dashboard/src/utils/userPreferenceStorage.ts b/app/dashboard/src/utils/userPreferenceStorage.ts
index fb0d250..286fa95 100644
--- a/app/dashboard/src/utils/userPreferenceStorage.ts
+++ b/app/dashboard/src/utils/userPreferenceStorage.ts
@@ -1,6 +1,8 @@
const NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY = 'marzban-num-users-per-page';
-const NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY = 'marzban-num-users-per-page';
+const NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY = 'marzban-num-services-per-page';
+const NUM_NODES_PER_PAGE_LOCAL_STORAGE_KEY = 'marzban-num-nodes-per-page';
const NUM_USERS_PER_PAGE_DEFAULT = 10;
+const NUM_NODES_PER_PAGE_DEFAULT = 10;
const NUM_SERVICES_PER_PAGE_DEFAULT = 10;
export const getUsersPerPageLimitSize = () => {
@@ -25,3 +27,14 @@ export const getServicesPerPageLimitSize = () => {
export const setServicesPerPageLimitSize = (value: string) => {
return localStorage.setItem(NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY, value);
};
+
+export const getNodesPerPageLimitSize = () => {
+ const numNodesPerPage =
+ localStorage.getItem(NUM_NODES_PER_PAGE_LOCAL_STORAGE_KEY) ||
+ NUM_NODES_PER_PAGE_DEFAULT.toString(); // this catches `null` values
+ return parseInt(numNodesPerPage) || NUM_NODES_PER_PAGE_DEFAULT; // this catches NaN values
+};
+
+export const setNodesPerPageLimitSize = (value: string) => {
+ return localStorage.setItem(NUM_NODES_PER_PAGE_LOCAL_STORAGE_KEY, value);
+};
diff --git a/app/models/node.py b/app/models/node.py
index c7c8449..c7ad8be 100644
--- a/app/models/node.py
+++ b/app/models/node.py
@@ -8,6 +8,7 @@ class NodeStatus(str, Enum):
healthy = "healthy"
unhealthy = "unhealthy"
disabled = "disabled"
+ none = "none"
class NodeSettings(BaseModel):
diff --git a/app/routes/admin.py b/app/routes/admin.py
index b9012af..d63ccf0 100644
--- a/app/routes/admin.py
+++ b/app/routes/admin.py
@@ -11,7 +11,7 @@ from app.models.admin import Admin, AdminCreate, AdminInDB, AdminModify, Token
from app.utils.jwt import create_admin_token
from config import SUDOERS
-router = APIRouter(tags=["Admin"], prefix="/admin")
+router = APIRouter(tags=["Admin"], prefix="/admins")
def authenticate_env_sudo(username: str, password: str) -> bool:
@@ -103,12 +103,12 @@ def remove_admin(username: str,
return {}
-@router.get("", response_model=Admin)
+@router.get("/current", response_model=Admin)
def get_current_admin(admin: AdminDep):
return admin
-@router.get("s", response_model=List[Admin])
+@router.get("", response_model=List[Admin])
def get_admins(db: DBDep,
admin: SudoAdminDep,
offset: int = None,
diff --git a/app/routes/node.py b/app/routes/node.py
index 917dba3..44974fa 100644
--- a/app/routes/node.py
+++ b/app/routes/node.py
@@ -15,7 +15,7 @@ from app.models.node import (NodeCreate, NodeModify, NodeResponse,
NodeSettings, NodeStatus, NodesUsageResponse)
logger = logging.getLogger(__name__)
-router = APIRouter(prefix="/node", tags=['Node'], dependencies=[Depends(sudo_admin)])
+router = APIRouter(prefix="/nodes", tags=['Node'], dependencies=[Depends(sudo_admin)])
@router.get("/settings", response_model=NodeSettings)
@@ -95,7 +95,7 @@ async def node_logs(node_id: int,
await websocket.send_text(l)
-@router.get("s", response_model=List[NodeResponse])
+@router.get("", response_model=List[NodeResponse])
def get_nodes(db: DBDep,
admin: SudoAdminDep):
return crud.get_nodes(db)
@@ -149,7 +149,7 @@ async def remove_node(node_id: int,
return {}
-@router.get("s/usage", response_model=NodesUsageResponse)
+@router.get("/usage", response_model=NodesUsageResponse)
def get_usage(db: DBDep,
admin: SudoAdminDep,
start_date: StartDateDep,
diff --git a/app/routes/service.py b/app/routes/service.py
index 5054be6..bae709f 100644
--- a/app/routes/service.py
+++ b/app/routes/service.py
@@ -9,7 +9,7 @@ from app.dependencies import DBDep, sudo_admin
from app.models.service import (ServiceCreate, ServiceModify,
ServiceResponse)
-router = APIRouter(prefix="/service", dependencies=[Depends(sudo_admin)], tags=['Service'])
+router = APIRouter(prefix="/services", dependencies=[Depends(sudo_admin)], tags=['Service'])
@router.post("", response_model=ServiceResponse)
@@ -78,7 +78,7 @@ def remove_service(id: int,
return dict()
-@router.get("s", response_model=List[ServiceResponse])
+@router.get("", response_model=List[ServiceResponse])
def get_services(db: DBDep,
offset: int = None,
limit: int = None):
diff --git a/app/routes/system.py b/app/routes/system.py
index 08017a3..034b1d0 100644
--- a/app/routes/system.py
+++ b/app/routes/system.py
@@ -41,12 +41,6 @@ def get_system_stats(db: DBDep, admin: AdminDep):
)
-@router.get("/inbounds", response_model=List[Inbound])
-def get_inbounds(db: DBDep, admin: SudoAdminDep):
- inbounds = crud.get_all_inbounds(db)
- return inbounds
-
-
@router.get("/hosts", response_model=Dict[int, List[InboundHost]])
def get_hosts(db: DBDep, admin: SudoAdminDep):
hosts = crud.get_all_hosts(db)
diff --git a/app/routes/user.py b/app/routes/user.py
index ac8f629..80c2061 100644
--- a/app/routes/user.py
+++ b/app/routes/user.py
@@ -15,10 +15,10 @@ from app.models.user import (UserCreate, UserModify, UserResponse,
from app.utils import report
logger = logging.getLogger(__name__)
-router = APIRouter(tags=['User'])
+router = APIRouter(prefix="/users", tags=['User'])
-@router.post("/user", response_model=UserResponse)
+@router.post("", response_model=UserResponse)
async def add_user(new_user: UserCreate,
db: DBDep,
admin: AdminDep):
@@ -51,7 +51,7 @@ async def add_user(new_user: UserCreate,
return user
-@router.get("/user/{username}", response_model=UserResponse)
+@router.get("/{username}", response_model=UserResponse)
def get_user(db_user: UserDep):
"""
Get users information
@@ -59,7 +59,7 @@ def get_user(db_user: UserDep):
return db_user
-@router.put("/user/{username}", response_model=UserResponse)
+@router.put("/{username}", response_model=UserResponse)
async def modify_user(db_user: UserDep,
modifications: UserModify,
db: DBDep,
@@ -112,7 +112,7 @@ async def modify_user(db_user: UserDep,
return user
-@router.delete("/user/{username}")
+@router.delete("/{username}")
async def remove_user(db_user: UserDep,
db: DBDep,
admin: AdminDep):
@@ -133,7 +133,7 @@ async def remove_user(db_user: UserDep,
return {}
-@router.post("/user/{username}/reset", response_model=UserResponse)
+@router.post("/{username}/reset", response_model=UserResponse)
async def reset_user_data_usage(db_user: UserDep,
db: DBDep,
admin: AdminDep):
@@ -156,7 +156,7 @@ async def reset_user_data_usage(db_user: UserDep,
return user
-@router.post("/user/{username}/revoke_sub", response_model=UserResponse)
+@router.post("/{username}/revoke_sub", response_model=UserResponse)
async def revoke_user_subscription(db_user: UserDep,
db: DBDep,
admin: AdminDep):
@@ -180,7 +180,7 @@ async def revoke_user_subscription(db_user: UserDep,
return user
-@router.get("/users", response_model=UsersResponse)
+@router.get("", response_model=UsersResponse)
def get_users(db: DBDep,
admin: AdminDep,
offset: int = None,
@@ -215,7 +215,7 @@ def get_users(db: DBDep,
return {"users": users, "total": count}
-@router.post("/users/reset")
+@router.post("/reset")
async def reset_users_data_usage(db: DBDep,
admin: SudoAdminDep):
"""
@@ -231,7 +231,7 @@ async def reset_users_data_usage(db: DBDep,
return {}
-@router.get("/user/{username}/usage", response_model=UserUsagesResponse)
+@router.get("/{username}/usage", response_model=UserUsagesResponse)
def get_user_usage(db: DBDep,
db_user: UserDep,
start_date: StartDateDep,
@@ -244,7 +244,7 @@ def get_user_usage(db: DBDep,
return {"usages": usages, "username": db_user.username}
-@router.put("/user/{username}/set-owner", response_model=UserResponse)
+@router.put("/{username}/set-owner", response_model=UserResponse)
def set_owner(username: str,
admin_username: str,
db: DBDep,
@@ -265,7 +265,7 @@ def set_owner(username: str,
return user
-@router.delete("/users/expired")
+@router.delete("/expired")
def delete_expired(passed_time: int,
db: DBDep,
admin: AdminDep):