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() { - {pages.map((route: PageRoute) => - } loader={fetchAdminLoader} /> + {pages.map((route: PageRoute, i) => + } loader={fetchAdminLoader} key={i} /> )} 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 = ({ - 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 ( @@ -85,7 +81,7 @@ export const DeleteNodeModal: FC = ({ w="full" colorScheme="red" onClick={() => onDelete()} - leftIcon={isLoading ? : undefined} + leftIcon={loading ? : undefined} > {t('delete')} 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 = () => { onEditingHosts, } = useHosts(); const { - onEditingNodes, onShowingNodesUsage, } = useNodes(); const { t } = useTranslation(); @@ -150,14 +147,6 @@ export const Header: FC = () => { > {t('header.hostSettings')} - } - onClick={onEditingNodes.bind(null, true)} - > - {t('header.nodeSettings')} - { + useNodes.getState().onFilterChange({ + ...useNodes.getState().nodesFilters, + offset: 0, + name, + }); +}, 300); + +export const NodesFilters: FC = ({ ...props }) => { + const { loading } = useDashboard(); + const { nodesFilters: filters, onFilterChange, refetchNodes, onAddingNode: onAddNodes } = useNodes(); + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const onChange = (e: React.ChangeEvent) => { + 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 ( + + + + } /> + + + {loading && } + {filters.name && filters.name.length > 0 && ( + + + + )} + + + + + + + + + { + setCopied([0, true]); + }} + > +
+ + + {copied[0] == 0 && copied[1] ? ( + + ) : ( + + )} + + +
+
+ +
+
+
+ ); +}; 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(''); -const NodeAccordion: FC = ({ toggleAccordion, node }) => { - const { updateNode, reconnectNode, setDeletingNode } = useNodes(); const { t } = useTranslation(); - const queryClient = useQueryClient(); - const toast = useToast(); - const form = useForm({ - 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 ( - - - - - {node.name} - - - {node.xray_version && ( - - - Xray {node.xray_version} - - - )} - {node.status && } - - - - - - - {nodeStatus === 'unhealthy' && ( - - - - - {node.message} - - - - - - - )} - - - - - - - } - /> - - - ); -}; - -type AddNodeFormType = { - toggleAccordion: () => void; - resetAccordions: () => void; -}; - -const AddNodeForm: FC = ({ - toggleAccordion, - resetAccordions, -}) => { const toast = useToast(); - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const { addNode } = useNodes(); - const form = useForm({ - 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 ( - - - - {' '} - {t('nodes.addNewMarzbanNode')} - - - - - - - ); -}; - -type NodeFormType = FC<{ - form: UseFormReturn; - mutate: UseMutateFunction; - isLoading: boolean; - submitBtnText: string; - btnProps?: Partial; - 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({ + 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 ( -
mutate(v))}> - - {nodeSettings && nodeSettings.certificate && ( - - - {t('nodes.connection-hint')} - - - - - {!showCertificate ? ( - - ) : ( - - )} - - - - - { - selectText(e.target as HTMLElement); - }} - > - {nodeSettings.certificate} - - - - - )} - - - - - - - { - return ( - - - { - if (e.target.checked) { - field.onChange('connecting'); - } else { - field.onChange('disabled'); - } - }} - /> - - - ); - }} - /> - - - - - - - - - - - {addAsHost && ( - - - {t('nodes.addHostForEveryInbound')} - - - )} - - {btnLeftAdornment} - - - -
- ); -}; - -export const NodesDialog: FC = () => { - const { isEditingNodes, onEditingNodes } = useNodes(); - const { t } = useTranslation(); - const [openAccordions, setOpenAccordions] = useState({}); - 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 ( <> - + - - - - - + - - {t('nodes.title')} - - {isLoading && 'loading...'} - - parseInt(i))} - > - - {!isLoading && - nodes && - nodes.map((node, index) => { - return ( - toggleAccordion(index)} - key={node.name} - node={node} + + {loading && 'loading...'} + +
submit(v))}> + + + + - ); - })} - - toggleAccordion((nodes || []).length)} - resetAccordions={() => setOpenAccordions({})} - /> - - + + + { + return ( + + + { + if (e.target.checked) { + field.onChange('none'); + } else { + field.onChange('disabled'); + } + }} + /> + + + ); + }} + /> + + + + + + + + + + + {!editingNode && ( + + + {t('nodes.addHostForEveryInbound')} + + + )} + + + + { + if (editingNode !== null) { + console.log('deleting') + onDeletingNode(editingNode); + onClose(); + } + }} + > + + + + + + +
+ +
- 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 = (props) => { + const { + nodesFilters: filters, + nodes, + onEditingNode: onEditingNodes, + onAddingNode: onCreateNodes, + onFilterChange, + } = useNodes(); + + const total = nodes.length; + + const { t } = useTranslation(); + const [selectedRow, setSelectedRow] = useState( + 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 = () => (); + + return ( + + + + + + + + + + {/* Action buttons (certificate, core settings)*/} + + + + + {!useTable && + nodes?.map((node, i) => { + return ( + + + + + + + + + + + + ); + })} + +
+ + {t('name')} + + + + + + {t('nodesTable.status')} + {filters.status ? ': ' + filters.status : ''} + + + + + + {t('nodesTable.xrayVersion')} + + + + {t('nodesTable.ipPort')} +
+ {node.name} + + + + {node.xray_version} + + +
+ + + + + + + {t('nodesTable.ipPort')} + + + {node.address}:{node.port} + + + + + + + { + e.stopPropagation(); + onEditingNodes(node); + }} + > + + + + + + + + +
+
+ + + + + + + + + + + {useTable && + nodes?.map((node, i) => { + return ( + onEditingNodes(node)} + > + + + + + + + ); + })} + {nodes.length == 0 && ( + + + + )} + +
+ + {t('name')} + + + + + + {t('nodesTable.status')} + {filters.status ? ': ' + filters.status : ''} + + + + + + {t('nodesTable.xrayVersion')} + + + + + {t('nodesTable.ipPort')} + + + +
+
+ {node.name} +
+
+ + + {node.xray_version} + + {node.address}:{node.port} + + +
+ +
+ +
+ ); +}; + +type ActionButtonsProps = {}; + +const ActionButtons: FC = () => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + + + + ); +}; 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 = () => { marzneshin {pages.map((item: SideBarItemProps, index: number) => - )} + )} 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 = ({ +export const StatusBadge: FC = ({ 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 ( <> = ({ fontWeight="medium" letterSpacing="tighter" > - {userStatus} + {status} {extraText && `: ${extraText}`} )} 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 = ({ filters, handleStatusFilter }) => { +export const StatusSortSelect: React.FC = ({ filters, handleStatusFilter, options }) => { return (
-
) } 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 = (props) => { {formatBytes(used)} /{' '} {isUnlimited ? ( - ∞ + ∞ ) : ( formatBytes(total) @@ -32,10 +32,10 @@ export const UsageSliderCompact: FC = (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 = (props) => { {formatBytes(used)} /{' '} {isUnlimited ? ( - ∞ + ∞ ) : ( formatBytes(total) + - (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset' - ? ' ' + - t( - 'userDialog.resetStrategy' + - getResetStrategy(dataLimitResetStrategy) - ) - : '') + (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset' + ? ' ' + + t( + 'userDialog.resetStrategy' + + getResetStrategy(dataLimitResetStrategy) + ) + : '') )} 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 = (props) => { {t('usersTable.status')} {filters.status ? ': ' + filters.status : ''} - + = (props) => { {t('usersTable.status')} {filters.status ? ': ' + filters.status : ''} - + 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; +export type NodeCreate = z.infer; 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; - fetchNodes: () => Promise; + nodesFilters: NodesFilterType; + refetchNodes: () => void; + refetchCertificate: () => void; fetchNodesUsage: (query: FilterUsageType) => Promise; - updateNode: (node: NodeType) => Promise; + addNode: (node: NodeCreate) => Promise; + deleteNode: (node: NodeCreate) => Promise; + updateNode: (node: NodeCreate) => Promise; reconnectNode: (node: NodeType) => Promise; - deleteNode: () => Promise; - 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) => 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((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 ( - + + + + 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):