diff --git a/app/dashboard/src/components/CoreSettingsModal.tsx b/app/dashboard/src/components/CoreSettingsModal.tsx index 3307c39..beac875 100644 --- a/app/dashboard/src/components/CoreSettingsModal.tsx +++ b/app/dashboard/src/components/CoreSettingsModal.tsx @@ -28,7 +28,7 @@ import { import { joinPaths } from '@remix-run/router'; import classNames from 'classnames'; import { useCoreSettings } from 'contexts/CoreSettingsContext'; -import { useDashboard } from 'contexts/DashboardContext'; +import { useNodes } from 'contexts/NodesContext'; import debounce from 'lodash.debounce'; import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -102,7 +102,7 @@ const getWebsocketUrl = () => { let logsTmp: string[] = []; const CoreSettingModalContent: FC = () => { - const { isEditingCore } = useDashboard(); + const { isEditingCore } = useNodes(); const { fetchCoreSettings, updateConfig, @@ -314,8 +314,8 @@ const CoreSettingModalContent: FC = () => { ); }; export const CoreSettingsModal: FC = () => { - const { isEditingCore } = useDashboard(); - const onClose = useDashboard.setState.bind(null, { isEditingCore: false }); + const { isEditingCore } = useNodes(); + const onClose = useNodes.setState.bind(null, { isEditingCore: false }); const { t } = useTranslation(); return ( diff --git a/app/dashboard/src/components/DeleteUserModal.tsx b/app/dashboard/src/components/DeleteUserModal.tsx index fc661c3..12e6014 100644 --- a/app/dashboard/src/components/DeleteUserModal.tsx +++ b/app/dashboard/src/components/DeleteUserModal.tsx @@ -12,18 +12,18 @@ import { Text, useToast, } from '@chakra-ui/react'; -import { useDashboard } from 'contexts/DashboardContext'; import { FC, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Icon } from './Icon'; +import { useUsers } from 'contexts/UsersContext'; export type DeleteUserModalProps = { - deleteCallback?: () => void; + deleteCallback?: () => void; }; export const DeleteUserModal: FC = () => { const [loading, setLoading] = useState(false); - const { deletingUser: user, onDeletingUser, deleteUser } = useDashboard(); + const { deletingUser: user, onDeletingUser, deleteUser } = useUsers(); const { t } = useTranslation(); const toast = useToast(); const onClose = () => { diff --git a/app/dashboard/src/components/Footer.tsx b/app/dashboard/src/components/Footer.tsx index e56037c..6ee4317 100644 --- a/app/dashboard/src/components/Footer.tsx +++ b/app/dashboard/src/components/Footer.tsx @@ -18,7 +18,7 @@ export const Footer: FC = (props) => { Marzneshin {version ? ` (v${version}), ` : ', '} - Made with ❤️ in{' '} + Made in{' '} khodedawosh diff --git a/app/dashboard/src/components/Header.tsx b/app/dashboard/src/components/Header.tsx index 4fd7c42..6d1e0b8 100644 --- a/app/dashboard/src/components/Header.tsx +++ b/app/dashboard/src/components/Header.tsx @@ -25,6 +25,9 @@ import { import { pages } from 'constants/Pages'; import { DONATION_URL, REPO_URL } from 'constants/Project'; import { useDashboard } from 'contexts/DashboardContext'; +import { useHosts } from 'contexts/HostsContext'; +import { useNodes } from 'contexts/NodesContext'; +import { useUsers } from 'contexts/UsersContext'; import differenceInDays from 'date-fns/differenceInDays'; import isValid from 'date-fns/isValid'; import { FC, ReactNode, useState } from 'react'; @@ -83,12 +86,18 @@ export const shouldShowDonation = (): boolean => { export const Header: FC = () => { const { - onEditingHosts, + activePage, + } = useDashboard(); + const { onResetAllUsage, + } = useUsers(); + const { + onEditingHosts, + } = useHosts(); + const { onEditingNodes, onShowingNodesUsage, - activePage, - } = useDashboard(); + } = useNodes(); const { t } = useTranslation(); const { colorMode, toggleColorMode } = useColorMode(); const [showDonationNotif, setShowDonationNotif] = useState( @@ -192,7 +201,7 @@ export const Header: FC = () => { variant="outline" aria-label="core settings" onClick={() => { - useDashboard.setState({ isEditingCore: true }); + useNodes.setState({ isEditingCore: true }); }} > diff --git a/app/dashboard/src/components/HostsDialog.tsx b/app/dashboard/src/components/HostsDialog.tsx index f43751b..48c226e 100644 --- a/app/dashboard/src/components/HostsDialog.tsx +++ b/app/dashboard/src/components/HostsDialog.tsx @@ -42,6 +42,8 @@ import { proxyHostSecurity, } from 'constants/Proxies'; import { useHosts } from 'contexts/HostsContext'; +import { useInbounds } from 'contexts/InboundsContext'; +import { useUsers } from 'contexts/UsersContext'; import { FC, useEffect, useState } from 'react'; import { FormProvider, @@ -53,7 +55,6 @@ import { Trans, useTranslation } from 'react-i18next'; import 'slick-carousel/slick/slick-theme.css'; import 'slick-carousel/slick/slick.css'; import { z } from 'zod'; -import { useDashboard } from '../contexts/DashboardContext'; import { DeleteIcon } from './Dialog/Icons'; import { Icon } from './Icon'; import { Input as CustomInput } from './Input'; @@ -138,7 +139,7 @@ const AccordionInbound: FC = ({ isOpen, toggleAccordion, }) => { - const { inbounds } = useDashboard(); + const { inbounds } = useInbounds(); const inbound = [...inbounds.values()] .flat() .filter((inbound) => inbound.tag === hostKey)[0]; @@ -772,8 +773,8 @@ const AccordionInbound: FC = ({ }; export const HostsDialog: FC = () => { - const { isEditingHosts, onEditingHosts, refetchUsers } = useDashboard(); - const { isLoading, hosts, fetchHosts, isPostLoading, setHosts } = useHosts(); + const { refetchUsers } = useUsers(); + const { isEditingHosts, onEditingHosts, isLoading, hosts, fetchHosts, isPostLoading, setHosts } = useHosts(); const toast = useToast(); const { t } = useTranslation(); const [openAccordions, setOpenAccordions] = useState({}); diff --git a/app/dashboard/src/components/NodesModal.tsx b/app/dashboard/src/components/NodesModal.tsx index c591843..795f1c9 100644 --- a/app/dashboard/src/components/NodesModal.tsx +++ b/app/dashboard/src/components/NodesModal.tsx @@ -61,7 +61,6 @@ import { generateErrorMessage, generateSuccessMessage, } from 'utils/toastHandler'; -import { useDashboard } from '../contexts/DashboardContext'; import { DeleteNodeModal } from './DeleteNodeModal'; import { DeleteIcon } from './Dialog/Icons'; import { ReloadIcon } from './UsersFilters'; @@ -130,11 +129,7 @@ const NodeAccordion: FC = ({ toggleAccordion, node }) => { } ); - const nodeStatus: Status = isReconnecting - ? 'connecting' - : node.status - ? node.status - : 'error'; + const nodeStatus: Status = isReconnecting ? 'connecting' : node.status ? node.status : 'unhealthy'; return ( = ({ toggleAccordion, node }) => { - {nodeStatus === 'error' && ( + {nodeStatus === 'unhealthy' && ( @@ -489,15 +484,6 @@ const NodeForm: NodeFormType = ({ error={form.formState?.errors?.port?.message} /> - - - {addAsHost && ( @@ -527,7 +513,7 @@ const NodeForm: NodeFormType = ({ }; export const NodesDialog: FC = () => { - const { isEditingNodes, onEditingNodes } = useDashboard(); + const { isEditingNodes, onEditingNodes } = useNodes(); const { t } = useTranslation(); const [openAccordions, setOpenAccordions] = useState({}); const { data: nodes, isLoading } = useNodesQuery(); diff --git a/app/dashboard/src/components/NodesUsage.tsx b/app/dashboard/src/components/NodesUsage.tsx index 46ec958..c4fa983 100644 --- a/app/dashboard/src/components/NodesUsage.tsx +++ b/app/dashboard/src/components/NodesUsage.tsx @@ -15,7 +15,7 @@ import { useColorMode, } from '@chakra-ui/react'; import { ChartPieIcon } from '@heroicons/react/24/outline'; -import { FilterUsageType, useDashboard } from 'contexts/DashboardContext'; +import { FilterUsageType } from 'types/Filter'; import { useNodes } from 'contexts/NodesContext'; import dayjs from 'dayjs'; import { FC, Suspense, useEffect, useState } from 'react'; @@ -34,7 +34,7 @@ const UsageIcon = chakra(ChartPieIcon, { export type NodesUsageProps = {}; export const NodesUsage: FC = () => { - const { isShowingNodesUsage, onShowingNodesUsage } = useDashboard(); + const { isShowingNodesUsage, onShowingNodesUsage } = useNodes(); const { fetchNodesUsage } = useNodes(); const { t } = useTranslation(); // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/app/dashboard/src/components/QRCodeDialog.tsx b/app/dashboard/src/components/QRCodeDialog.tsx index 826832e..1e0ee41 100644 --- a/app/dashboard/src/components/QRCodeDialog.tsx +++ b/app/dashboard/src/components/QRCodeDialog.tsx @@ -23,8 +23,8 @@ import { useTranslation } from 'react-i18next'; import Slider from 'react-slick'; import 'slick-carousel/slick/slick-theme.css'; import 'slick-carousel/slick/slick.css'; -import { useDashboard } from '../contexts/DashboardContext'; import { Icon } from './Icon'; +import { useUsers } from 'contexts/UsersContext'; const QRCode = chakra(QRCodeCanvas); const NextIcon = chakra(ChevronRightIcon, { @@ -55,7 +55,7 @@ const QRIcon = chakra(QrCodeIcon, { }); export const QRCodeDialog: FC = () => { - const { QRcodeLinks, setQRCode, setSubLink, subscribeUrl } = useDashboard(); + const { QRcodeLinks, setQRCode, setSubLink, subscribeUrl } = useUsers(); const isOpen = QRcodeLinks !== null; const [index, setIndex] = useState(0); const { t } = useTranslation(); diff --git a/app/dashboard/src/components/ResetAllUsageModal.tsx b/app/dashboard/src/components/ResetAllUsageModal.tsx index bdf48eb..6ffd39a 100644 --- a/app/dashboard/src/components/ResetAllUsageModal.tsx +++ b/app/dashboard/src/components/ResetAllUsageModal.tsx @@ -15,8 +15,8 @@ import { import { FC, useState } from 'react'; import { DocumentMinusIcon } from '@heroicons/react/24/outline'; import { Icon } from './Icon'; -import { useDashboard } from 'contexts/DashboardContext'; import { useTranslation } from 'react-i18next'; +import { useUsers } from 'contexts/UsersContext'; export const ResetIcon = chakra(DocumentMinusIcon, { baseStyle: { @@ -29,7 +29,7 @@ export type DeleteUserModalProps = {}; export const ResetAllUsageModal: FC = () => { const [loading, setLoading] = useState(false); - const { isResetingAllUsage, onResetAllUsage, resetAllUsage } = useDashboard(); + const { isResetingAllUsage, onResetAllUsage, resetAllUsage } = useUsers(); const { t } = useTranslation(); const toast = useToast(); const onClose = () => { diff --git a/app/dashboard/src/components/ResetUserUsageModal.tsx b/app/dashboard/src/components/ResetUserUsageModal.tsx index 5a3cd39..1ff5bb1 100644 --- a/app/dashboard/src/components/ResetUserUsageModal.tsx +++ b/app/dashboard/src/components/ResetUserUsageModal.tsx @@ -15,8 +15,8 @@ import { import { FC, useState } from 'react'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; import { Icon } from './Icon'; -import { useDashboard } from 'contexts/DashboardContext'; import { useTranslation, Trans } from 'react-i18next'; +import { useUsers } from 'contexts/UsersContext'; export const ResetIcon = chakra(ArrowPathIcon, { baseStyle: { @@ -29,11 +29,11 @@ export type DeleteUserModalProps = {}; export const ResetUserUsageModal: FC = () => { const [loading, setLoading] = useState(false); - const { resetUsageUser: user, resetDataUsage } = useDashboard(); + const { resetUsageUser: user, resetDataUsage } = useUsers(); const { t } = useTranslation(); const toast = useToast(); const onClose = () => { - useDashboard.setState({ resetUsageUser: null }); + useUsers.setState({ resetUsageUser: null }); }; const onReset = () => { if (user) { diff --git a/app/dashboard/src/components/RevokeSubscriptionModal.tsx b/app/dashboard/src/components/RevokeSubscriptionModal.tsx index bd9abbe..3d4c1b4 100644 --- a/app/dashboard/src/components/RevokeSubscriptionModal.tsx +++ b/app/dashboard/src/components/RevokeSubscriptionModal.tsx @@ -13,7 +13,7 @@ import { useToast, } from '@chakra-ui/react'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; -import { useDashboard } from 'contexts/DashboardContext'; +import { useUsers } from 'contexts/UsersContext'; import { FC, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Icon } from './Icon'; @@ -29,11 +29,11 @@ export type RevokeSubscriptionModalProps = {}; export const RevokeSubscriptionModal: FC = () => { const [loading, setLoading] = useState(false); - const { revokeSubscriptionUser: user, revokeSubscription } = useDashboard(); + const { revokeSubscriptionUser: user, revokeSubscription } = useUsers(); const { t } = useTranslation(); const toast = useToast(); const onClose = () => { - useDashboard.setState({ revokeSubscriptionUser: null }); + useUsers.setState({ revokeSubscriptionUser: null }); }; const onReset = () => { if (user) { diff --git a/app/dashboard/src/components/ServiceDialog/InboundsField.tsx b/app/dashboard/src/components/ServiceDialog/InboundsField.tsx index 2ce782e..fcb5942 100644 --- a/app/dashboard/src/components/ServiceDialog/InboundsField.tsx +++ b/app/dashboard/src/components/ServiceDialog/InboundsField.tsx @@ -1,14 +1,14 @@ import { FormControl, FormLabel, FormErrorMessage, Center, Spinner, Text, Spacer, Flex } from '@chakra-ui/react'; -import { Inbounds, InboundType } from 'contexts/DashboardContext'; +import { Inbounds, InboundType } from 'types/Inbounds'; import { TFunction } from 'i18next'; import { Controller } from 'react-hook-form'; import { InboundsSelectList } from './InboundsSelectList'; interface InboundsFieldProps { - form: any; - inbounds: Inbounds; - t: TFunction<'translation', undefined, 'translation'>; + form: any; + inbounds: Inbounds; + t: TFunction<'translation', undefined, 'translation'>; } export const InboundsField = ({ form, t, inbounds }: InboundsFieldProps) => { diff --git a/app/dashboard/src/components/ServiceDialog/InboundsSelectList.tsx b/app/dashboard/src/components/ServiceDialog/InboundsSelectList.tsx index 26dc370..9aadb0f 100644 --- a/app/dashboard/src/components/ServiceDialog/InboundsSelectList.tsx +++ b/app/dashboard/src/components/ServiceDialog/InboundsSelectList.tsx @@ -5,15 +5,15 @@ import { Stack, } from '@chakra-ui/react'; import { ListCheckBox } from 'components/ListCheckBox'; -import { Inbounds, InboundType } from 'contexts/DashboardContext'; +import { Inbounds, InboundType } from 'types/Inbounds'; import { forwardRef, ReactNode } from 'react'; import { useFormContext, } from 'react-hook-form'; interface InboundsSelectListProps { - list: Inbounds; - renderCheckboxContent: (inbound: InboundType) => ReactNode; + list: Inbounds; + renderCheckboxContent: (inbound: InboundType) => ReactNode; } export const InboundsSelectList = forwardRef( diff --git a/app/dashboard/src/components/ServiceDialog/index.tsx b/app/dashboard/src/components/ServiceDialog/index.tsx index a1fb125..54cef44 100644 --- a/app/dashboard/src/components/ServiceDialog/index.tsx +++ b/app/dashboard/src/components/ServiceDialog/index.tsx @@ -17,7 +17,7 @@ import { IconButton, } from '@chakra-ui/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { InboundType, useDashboard } from 'contexts/DashboardContext'; +import { InboundType } from 'types/Inbounds'; import { FC, useEffect, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -29,6 +29,8 @@ import { schema, FormType } from './FormSchema'; import { InboundsField } from './InboundsField'; import { NameField } from './NameField'; import { Service, ServiceCreate } from 'types/Service'; +import { useServices } from 'contexts/ServicesContext'; +import { useInbounds } from 'contexts/InboundsContext'; const formatService = (service: Service): FormType => { const inbounds: number[] = service.inbounds.map((inbound: number | InboundType): number => { @@ -46,14 +48,16 @@ export const ServiceDialog: FC = () => { const { editingService, isCreatingNewService, - inbounds, onCreateService, editService, - refetchInbounds, onEditingService, createService, onDeletingService, - } = useDashboard(); + } = useServices(); + const { + inbounds, + refetchInbounds, + } = useInbounds(); const isEditing = !!editingService; const isOpen = isCreatingNewService || isEditing; const [loading, setLoading] = useState(false); diff --git a/app/dashboard/src/components/ServicesFilters.tsx b/app/dashboard/src/components/ServicesFilters.tsx index 67a6513..6cfd7f8 100644 --- a/app/dashboard/src/components/ServicesFilters.tsx +++ b/app/dashboard/src/components/ServicesFilters.tsx @@ -19,6 +19,7 @@ import { } from '@heroicons/react/24/outline'; import classNames from 'classnames'; import { useDashboard } from 'contexts/DashboardContext'; +import { useServices } from 'contexts/ServicesContext'; import debounce from 'lodash.debounce'; import React, { FC, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -35,17 +36,18 @@ const ClearIcon = chakra(XMarkIcon, iconProps); export const ReloadIcon = chakra(ArrowPathIcon, iconProps); export type FilterProps = {} & BoxProps; -const setSearchField = debounce((username: string) => { - useDashboard.getState().onFilterChange({ - ...useDashboard.getState().usersFilters, +const setSearchField = debounce((name: string) => { + useServices.getState().onFilterChange({ + ...useServices.getState().servicesFilters, offset: 0, - username, + name, }); }, 300); export const ServicesFilters: FC = ({ ...props }) => { - const { loading, servicesFilters: filters, onFilterChange, refetchUsers, onCreateService } = - useDashboard(); + const { loading } = + useDashboard(); + const { servicesFilters: filters, refetchServices, onFilterChange, onCreateService } = useServices(); const { t } = useTranslation(); const [search, setSearch] = useState(''); const onChange = (e: React.ChangeEvent) => { @@ -57,7 +59,7 @@ export const ServicesFilters: FC = ({ ...props }) => { onFilterChange({ ...filters, offset: 0, - username: '', + name: '', }); }; return ( @@ -91,7 +93,6 @@ export const ServicesFilters: FC = ({ ...props }) => { borderColor="light-border" onChange={onChange} /> - {loading && } {filters.name && filters.name.length > 0 && ( @@ -110,9 +111,9 @@ export const ServicesFilters: FC = ({ ...props }) => { diff --git a/app/dashboard/src/components/ServicesTable.tsx b/app/dashboard/src/components/ServicesTable/index.tsx similarity index 64% rename from app/dashboard/src/components/ServicesTable.tsx rename to app/dashboard/src/components/ServicesTable/index.tsx index 9b897ab..1bb658e 100644 --- a/app/dashboard/src/components/ServicesTable.tsx +++ b/app/dashboard/src/components/ServicesTable/index.tsx @@ -1,42 +1,25 @@ import { Box, - Button, - chakra, HStack, Table, TableProps, Tbody, Td, - Text, Th, Thead, Tr, useBreakpointValue, } from '@chakra-ui/react'; - -import { ReactComponent as AddFileIcon } from 'assets/add_file.svg'; import classNames from 'classnames'; -import { useDashboard } from 'contexts/DashboardContext'; -import { t } from 'i18next'; import { FC, Fragment, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pagination } from './Pagination'; -import { SortType } from './UsersTable'; -import { SortIcon } from './UsersTable/Icons'; - -const EmptySectionIcon = chakra(AddFileIcon); +import { EmptySection } from 'components/Table/EmptySection'; +import { Pagination } from 'components/Table/Pagination'; +import { Sort } from 'components/Table/Sort'; +import { useServices } from 'contexts/ServicesContext'; -export const Sort: FC = ({ sort, column }) => { - if (sort.includes(column)) - return ( - - ); - return null; -}; type ServicesTableProps = {} & TableProps; export const ServicesTable: FC = (props) => { @@ -44,8 +27,9 @@ export const ServicesTable: FC = (props) => { servicesFilters: filters, services, onEditingService, + onCreateService, onFilterChange, - } = useDashboard(); + } = useServices(); const { t } = useTranslation(); const marginTop = useBreakpointValue({ base: 120, lg: 72 }) || 72; @@ -121,11 +105,10 @@ export const ServicesTable: FC = (props) => { - - {services ? services.map((service, i) => { + {services.length !== 0 ? services.map((service, i) => { return ( = (props) => { }) : - + } - - - ); -}; - -type EmptySectionProps = { - isFiltered: boolean; -}; - -const EmptySection: FC = ({ isFiltered }) => { - const { onCreateService } = useDashboard(); - return ( - - - - {isFiltered ? t('servicesTable.noServiceMatched') : t('servicesTable.noService')} - - {!isFiltered && ( - - )} + ); }; diff --git a/app/dashboard/src/components/UsersTable/EmptySection.tsx b/app/dashboard/src/components/Table/EmptySection.tsx similarity index 64% rename from app/dashboard/src/components/UsersTable/EmptySection.tsx rename to app/dashboard/src/components/Table/EmptySection.tsx index 4ddd36f..c724355 100644 --- a/app/dashboard/src/components/UsersTable/EmptySection.tsx +++ b/app/dashboard/src/components/Table/EmptySection.tsx @@ -1,18 +1,30 @@ + import { Box, Text, Button, chakra } from '@chakra-ui/react'; -import { useDashboard } from 'contexts/DashboardContext'; import { FC } from 'react'; import { ReactComponent as AddFileIcon } from 'assets/add_file.svg'; -import { useTranslation } from 'react-i18next'; +import { t } from 'i18next'; type EmptySectionProps = { isFiltered: boolean; + noObjectMatchedT: string; + noObjectT: string; + createObjectT: string; + onCreateObject: (isOpen: boolean) => void; }; const EmptySectionIcon = chakra(AddFileIcon); -export const EmptySection: FC = ({ isFiltered }) => { - const { onCreateUser } = useDashboard(); - const { t } = useTranslation(); +/** + * Component to provide visual feedback for the result of table filtering + * and table emptiness + * + * @param noObjectMatchedT - i18n token for "No where Matched" + * @param noObjectT - i18n token for "No where found" + * @param createObjectT - i18n token for "Create " Button + * @param onCreateObject - Create object event handler on click + * + */ +export const EmptySection: FC = ({ isFiltered, noObjectT, noObjectMatchedT, createObjectT, onCreateObject }) => { return ( = ({ isFiltered }) => { }} /> - {isFiltered ? t('usersTable.noUserMatched') : t('usersTable.noUser')} + {isFiltered ? t(noObjectMatchedT) : t(noObjectT)} {!isFiltered && ( )} diff --git a/app/dashboard/src/components/UsersTable/Icons.ts b/app/dashboard/src/components/Table/Icons.ts similarity index 99% rename from app/dashboard/src/components/UsersTable/Icons.ts rename to app/dashboard/src/components/Table/Icons.ts index dc827a6..bab7d0d 100644 --- a/app/dashboard/src/components/UsersTable/Icons.ts +++ b/app/dashboard/src/components/Table/Icons.ts @@ -1,3 +1,4 @@ + import { chakra } from '@chakra-ui/react'; import { CheckIcon, diff --git a/app/dashboard/src/components/Pagination.tsx b/app/dashboard/src/components/Table/Pagination.tsx similarity index 93% rename from app/dashboard/src/components/Pagination.tsx rename to app/dashboard/src/components/Table/Pagination.tsx index 88dfcfa..995a5f8 100644 --- a/app/dashboard/src/components/Pagination.tsx +++ b/app/dashboard/src/components/Table/Pagination.tsx @@ -11,9 +11,9 @@ import { ArrowLongLeftIcon, ArrowLongRightIcon, } from '@heroicons/react/24/outline'; -import { useDashboard } from 'contexts/DashboardContext'; import { ChangeEvent, FC } from 'react'; import { useTranslation } from 'react-i18next'; +import { FilterType } from 'types/Filter'; import { setUsersPerPageLimitSize } from 'utils/userPreferenceStorage'; const PrevIcon = chakra(ArrowLongLeftIcon, { @@ -75,12 +75,13 @@ function generatePageItems(total: number, current: number, width: number) { return items; } -export const Pagination: FC = () => { - const { - usersFilters: filters, - onFilterChange, - users: { total }, - } = useDashboard(); +type PaginationProps = { + total: number; + onFilterChange: (filters: Partial) => void; + filters: FilterType; +} + +export const Pagination: FC = ({ filters, total, onFilterChange }) => { const { limit: perPage, offset } = filters; const page = (offset || 0) / (perPage || 1); diff --git a/app/dashboard/src/components/Table/Sort.tsx b/app/dashboard/src/components/Table/Sort.tsx new file mode 100644 index 0000000..98849e3 --- /dev/null +++ b/app/dashboard/src/components/Table/Sort.tsx @@ -0,0 +1,16 @@ +import { SortIcon } from './Icons'; +import { FC } from 'react'; + +type SortType = { + sort: string; + column: string; +}; + +export const Sort: FC = ({ sort, column }) => { + if (sort.includes(column)) + return ( + + ); + return null; +}; diff --git a/app/dashboard/src/components/UsageFilter.tsx b/app/dashboard/src/components/UsageFilter.tsx index 0de573f..d37c683 100644 --- a/app/dashboard/src/components/UsageFilter.tsx +++ b/app/dashboard/src/components/UsageFilter.tsx @@ -20,7 +20,7 @@ import { } from '@chakra-ui/react'; import { CalendarIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { ApexOptions } from 'apexcharts'; -import { FilterUsageType } from 'contexts/DashboardContext'; +import { FilterUsageType } from 'types/Filter'; import dayjs, { ManipulateType } from 'dayjs'; import { FC, useRef, useState } from 'react'; import ReactDatePicker from 'react-datepicker'; diff --git a/app/dashboard/src/components/UserDialog/index.tsx b/app/dashboard/src/components/UserDialog/index.tsx index 26b1438..eb98f20 100644 --- a/app/dashboard/src/components/UserDialog/index.tsx +++ b/app/dashboard/src/components/UserDialog/index.tsx @@ -21,7 +21,7 @@ import { HStack, } from '@chakra-ui/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { FilterUsageType, useDashboard } from 'contexts/DashboardContext'; +import { FilterUsageType } from 'types/Filter'; import dayjs from 'dayjs'; import { FC, useEffect, useState } from 'react'; import ReactApexChart from 'react-apexcharts'; @@ -46,6 +46,8 @@ import { PeriodicUsageReset as PeriodicUsageReset } from './PeriodicUsageReset'; import { ExpireDateField } from './ExpireDateField'; import { NoteField } from './NoteField'; import { Service } from 'types/Service'; +import { useUsers } from 'contexts/UsersContext'; +import { useServices } from 'contexts/ServicesContext'; const formatUser = (user: User): FormType => { const services: number[] = user.services.map((service: number | Service): number => { @@ -66,15 +68,14 @@ export const UserDialog: FC = () => { const { editingUser, isCreatingNewUser, - services, onCreateUser, editUser, fetchUserUsage, - refetchServices, onEditingUser, createUser, onDeletingUser, - } = useDashboard(); + } = useUsers(); + const { refetchServices, services } = useServices(); const isEditing = !!editingUser; const isOpen = isCreatingNewUser || isEditing; const [loading, setLoading] = useState(false); @@ -198,11 +199,11 @@ export const UserDialog: FC = () => { }; const handleResetUsage = () => { - useDashboard.setState({ resetUsageUser: editingUser }); + useUsers.setState({ resetUsageUser: editingUser }); }; const handleRevokeSubscription = () => { - useDashboard.setState({ revokeSubscriptionUser: editingUser }); + useUsers.setState({ revokeSubscriptionUser: editingUser }); }; const disabled = loading; diff --git a/app/dashboard/src/components/UsersFilters.tsx b/app/dashboard/src/components/UsersFilters.tsx index d90827d..59f7585 100644 --- a/app/dashboard/src/components/UsersFilters.tsx +++ b/app/dashboard/src/components/UsersFilters.tsx @@ -19,6 +19,7 @@ import { } from '@heroicons/react/24/outline'; import classNames from 'classnames'; import { useDashboard } from 'contexts/DashboardContext'; +import { useUsers } from 'contexts/UsersContext'; import debounce from 'lodash.debounce'; import React, { FC, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,16 +37,17 @@ export const ReloadIcon = chakra(ArrowPathIcon, iconProps); export type FilterProps = {} & BoxProps; const setSearchField = debounce((username: string) => { - useDashboard.getState().onFilterChange({ - ...useDashboard.getState().usersFilters, + useUsers.getState().onFilterChange({ + ...useUsers.getState().usersFilters, offset: 0, username, }); }, 300); export const UsersFilters: FC = ({ ...props }) => { - const { loading, usersFilters: filters, onFilterChange, refetchUsers, onCreateUser } = + const { loading, } = useDashboard(); + const { usersFilters: filters, onFilterChange, refetchUsers, onCreateUser } = useUsers(); const { t } = useTranslation(); const [search, setSearch] = useState(''); const onChange = (e: React.ChangeEvent) => { @@ -91,7 +93,6 @@ export const UsersFilters: FC = ({ ...props }) => { borderColor="light-border" onChange={onChange} /> - {loading && } {filters.username && filters.username.length > 0 && ( diff --git a/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx b/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx index 95f90c4..6e0806b 100644 --- a/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx +++ b/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx @@ -1,11 +1,11 @@ import { Select } from '@chakra-ui/react' -import { UsersFilterType } from 'contexts/DashboardContext' +import { UsersFilterType } from 'types/Filter'; import React from 'react' interface StatusSortSelectProps { - filters: UsersFilterType; - handleStatusFilter: (e: any) => void; + filters: UsersFilterType; + handleStatusFilter: (e: any) => void; } export const StatusSortSelect: React.FC = ({ filters, handleStatusFilter }) => { diff --git a/app/dashboard/src/components/UsersTable/index.tsx b/app/dashboard/src/components/UsersTable/index.tsx index 60f41af..c3e169c 100644 --- a/app/dashboard/src/components/UsersTable/index.tsx +++ b/app/dashboard/src/components/UsersTable/index.tsx @@ -22,7 +22,6 @@ import { import classNames from 'classnames'; import { statusColors } from 'constants/UserSettings'; -import { useDashboard } from 'contexts/DashboardContext'; import { t } from 'i18next'; import { FC, Fragment, useEffect, useState } from 'react'; import CopyToClipboard from 'react-copy-to-clipboard'; @@ -30,41 +29,35 @@ import { useTranslation } from 'react-i18next'; import { User } from 'types/User'; import { OnlineBadge } from '../OnlineBadge'; import { OnlineStatus } from '../OnlineStatus'; -import { Pagination } from '../Pagination'; +import { Pagination } from 'components/Table/Pagination'; import { StatusBadge } from '../StatusBadge'; -import { EmptySection } from './EmptySection'; -import { AccordionArrowIcon, SortIcon, EditIcon, CopiedIcon, CopyIcon, QRIcon, SubscriptionLinkIcon } from 'components/UsersTable/Icons'; +import { EmptySection } from 'components/Table/EmptySection'; +import { + AccordionArrowIcon, + EditIcon, + CopiedIcon, + CopyIcon, + QRIcon, + SubscriptionLinkIcon +} from 'components/Table/Icons'; +import { Sort } from 'components/Table/Sort'; import { UsageSlider, UsageSliderCompact } from './UsageSlider'; import { StatusSortSelect } from './StatusSortSelect'; +import { useUsers } from 'contexts/UsersContext'; type ExpandedIndex = number | number[]; - -export type SortType = { - sort: string; - column: string; -}; - -export const Sort: FC = ({ sort, column }) => { - if (sort.includes(column)) - return ( - - ); - return null; -}; - type UsersTableProps = {} & TableProps; export const UsersTable: FC = (props) => { const { usersFilters: filters, users: { users }, - users: totalUsers, + users: { total }, onEditingUser, + onCreateUser, onFilterChange, - } = useDashboard(); + } = useUsers(); const { t } = useTranslation(); const [selectedRow, setSelectedRow] = useState( @@ -83,7 +76,7 @@ export const UsersTable: FC = (props) => { () => window.removeEventListener('scroll', calcTop); }, []); - const isFiltered = users.length !== totalUsers.total; + const isFiltered = users.length !== total; const handleSort = (column: string) => { let newSort = filters.sort; @@ -189,143 +182,143 @@ export const UsersTable: FC = (props) => { {!useTable && - users?.map((user, i) => { - return ( - - - -
- - {user.username} -
- - - - - - - - - - - - { + return ( + + + +
+ + {user.username} +
+ + + + + + + + + + + + + + + + + + + + {t('usersTable.dataUsage')} + + + + + + + + + + + + + - - - - - - - - {t('usersTable.dataUsage')} - - - - - - - - - - - - - - { - e.stopPropagation(); - onEditingUser(user); - }} - > - - - - - - - - - - -
- ); - })} + { + e.stopPropagation(); + onEditingUser(user); + }} + > + + + + + + + + + + +
+ ); + })} @@ -398,63 +391,69 @@ export const UsersTable: FC = (props) => { {useTable && - users?.map((user, i) => { - return ( - onEditingUser(user)} - > - -
- - {user.username} - -
- - - - - - - - - - - - ); - })} + users?.map((user, i) => { + return ( + onEditingUser(user)} + > + +
+ + {user.username} + +
+ + + + + + + + + + + + ); + })} {users.length == 0 && ( - + )} - + ); }; type ActionButtonsProps = { - user: User; + user: User; }; const ActionButtons: FC = ({ user }) => { - const { setQRCode, setSubLink } = useDashboard(); + const { setQRCode, setSubLink } = useUsers(); const proxyLinks = user.links.join('\r\n'); diff --git a/app/dashboard/src/contexts/AdminContext.tsx b/app/dashboard/src/contexts/AdminContext.ts similarity index 100% rename from app/dashboard/src/contexts/AdminContext.tsx rename to app/dashboard/src/contexts/AdminContext.ts diff --git a/app/dashboard/src/contexts/CoreSettingsContext.tsx b/app/dashboard/src/contexts/CoreSettingsContext.ts similarity index 100% rename from app/dashboard/src/contexts/CoreSettingsContext.tsx rename to app/dashboard/src/contexts/CoreSettingsContext.ts diff --git a/app/dashboard/src/contexts/DashboardContext.ts b/app/dashboard/src/contexts/DashboardContext.ts new file mode 100644 index 0000000..5cd3e07 --- /dev/null +++ b/app/dashboard/src/contexts/DashboardContext.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +type PageId = number; + +type DashboardStateType = { + version: string | null; + loading: boolean; + activePage: number; + activatePage: (pageId: PageId) => void; +}; + +export const useDashboard = create( + subscribeWithSelector((set,) => ({ + version: null, + loading: true, + activePage: 0, + activatePage: (pageId: number) => { + set({ activePage: pageId }); + }, + })) +); diff --git a/app/dashboard/src/contexts/DashboardContext.tsx b/app/dashboard/src/contexts/DashboardContext.tsx deleted file mode 100644 index 37d82c0..0000000 --- a/app/dashboard/src/contexts/DashboardContext.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { StatisticsQueryKey } from 'components/Statistics'; -import { fetch } from 'service/http'; -import { User, UserCreate } from 'types/User'; -import { Service, ServiceCreate } from 'types/Service'; -import { queryClient } from 'utils/react-query'; -import { getServicesPerPageLimitSize, getUsersPerPageLimitSize } from 'utils/userPreferenceStorage'; -import { create } from 'zustand'; -import { subscribeWithSelector } from 'zustand/middleware'; - -export type UsersFilterType = { - username?: string; - limit?: number; - offset?: number; - sort: string; - status?: 'active' | 'disabled' | 'limited' | 'expired' | 'on_hold'; -}; - -export type ServicesFilterType = { - name?: string; - users?: number; - inbounds?: number; - limit?: number; - offset?: number; - sort: string; -}; - -export type ProtocolType = 'vmess' | 'vless' | 'trojan' | 'shadowsocks'; - -export type FilterUsageType = { - start?: string; - end?: string; -}; - -export type InboundType = { - id: number; - tag: string; - protocol: ProtocolType; - network: string; - tls: string; - port?: number; -}; - -export type Inbounds = InboundType[]; -type PageId = number; - -type DashboardStateType = { - isCreatingNewUser: boolean; - editingUser: User | null | undefined; - deletingUser: User | null; - isCreatingNewService: boolean; - editingService: Service | null | undefined; - deletingService: Service | null; - version: string | null; - users: { - users: User[]; - total: number; - }; - inbounds: Inbounds; - services: Service[]; - loading: boolean; - usersFilters: UsersFilterType; - servicesFilters: ServicesFilterType; - subscribeUrl: string | null; - QRcodeLinks: string[] | null; - isEditingHosts: boolean; - isEditingNodes: boolean; - isShowingNodesUsage: boolean; - isResetingAllUsage: boolean; - resetUsageUser: User | null; - revokeSubscriptionUser: User | null; - isEditingCore: boolean; - activePage: number; - activatePage: (pageId: PageId) => void; - onCreateUser: (isOpen: boolean) => void; - onCreateService: (isOpen: boolean) => void; - onEditingUser: (user: User | null) => void; - onEditingService: (service: Service | null) => void; - onDeletingUser: (user: User | null) => void; - onDeletingService: (service: Service | null) => void; - onResetAllUsage: (isResetingAllUsage: boolean) => void; - refetchUsers: () => void; - refetchInbounds: () => void; - refetchServices: () => void; - resetAllUsage: () => Promise; - onFilterChange: (filters: Partial) => void; - deleteUser: (user: User) => Promise; - createUser: (user: UserCreate) => Promise; - editUser: (user: UserCreate) => Promise; - deleteService: (service: Service) => Promise; - createService: (service: ServiceCreate) => Promise; - editService: (service: ServiceCreate) => Promise; - fetchUserUsage: (user: User, query: FilterUsageType) => Promise; - setQRCode: (links: string[] | null) => void; - setSubLink: (subscribeURL: string | null) => void; - onEditingHosts: (isEditingHosts: boolean) => void; - onEditingNodes: (isEditingHosts: boolean) => void; - onShowingNodesUsage: (isShowingNodesUsage: boolean) => void; - resetDataUsage: (user: User) => Promise; - revokeSubscription: (user: User) => Promise; -}; - -export const fetchUsers = async (query: UsersFilterType): Promise => { - for (const key in query) { - if (!query[key as keyof UsersFilterType]) delete query[key as keyof UsersFilterType]; - } - useDashboard.setState({ loading: true }); - return fetch('/users', { query }) - .then((users) => { - for (let i = 0; i < users.users.length; i++) { - users.users[i].service = users.users[i].service_ids; - } - useDashboard.setState({ users }); - return users; - }) - .finally(() => { - useDashboard.setState({ loading: false }); - }); -}; - -export const fetchServices = async (query: UsersFilterType): Promise => { - for (const key in query) { - if (!query[key as keyof UsersFilterType]) delete query[key as keyof UsersFilterType]; - } - useDashboard.setState({ loading: true }); - return fetch('/services') - .then((services) => { - useDashboard.setState({ services }); - return services; - }) - .finally(() => { - useDashboard.setState({ loading: false }); - }); -}; - -export const fetchInbounds = async () => { - return fetch('/inbounds') - .then((inbounds: Inbounds) => { - useDashboard.setState({ inbounds }); - }) - .finally(() => { - useDashboard.setState({ loading: false }); - }); -}; - -export const useDashboard = create( - subscribeWithSelector((set, get) => ({ - version: null, - editingUser: null, - editingService: null, - deletingUser: null, - deletingService: null, - isCreatingNewUser: false, - isCreatingNewService: false, - QRcodeLinks: null, - subscribeUrl: null, - users: { - users: [], - total: 0, - }, - loading: true, - services: [], - isResetingAllUsage: false, - isEditingHosts: false, - isEditingNodes: false, - isShowingNodesUsage: false, - resetUsageUser: null, - revokeSubscriptionUser: null, - servicesFilters: { - name: '', - limit: getServicesPerPageLimitSize(), - sort: '-created_at', - }, - usersFilters: { - username: '', - limit: getUsersPerPageLimitSize(), - sort: '-created_at', - }, - inbounds: [], - isEditingCore: false, - activePage: 0, - activatePage: (pageId: number) => { - set({ activePage: pageId }); - }, - refetchServices: () => { - fetchServices(get().servicesFilters); - }, - refetchUsers: () => { - fetchUsers(get().usersFilters); - }, - refetchInbounds: () => { - fetchInbounds(); - }, - resetAllUsage: async () => { - return fetch('/users/reset', { method: 'POST' }).then(() => { - get().onResetAllUsage(false); - get().refetchUsers(); - }); - }, - onResetAllUsage: (isResetingAllUsage) => set({ isResetingAllUsage }), - onCreateUser: (isCreatingNewUser) => set({ isCreatingNewUser }), - onCreateService: (isCreatingNewService) => set({ isCreatingNewService }), - onEditingService: (editingService) => { - set({ editingService }); - }, - onEditingUser: (editingUser) => { - set({ editingUser }); - }, - onDeletingUser: (deletingUser) => { - set({ deletingUser }); - }, - onDeletingService: (deletingService) => { - set({ deletingService }); - }, - onFilterChange: (filters) => { - set({ - usersFilters: { - ...get().usersFilters, - ...filters, - }, - }); - get().refetchUsers(); - }, - setQRCode: (QRcodeLinks) => { - set({ QRcodeLinks }); - }, - deleteUser: async (user: User) => { - set({ editingUser: null }); - return fetch(`/user/${user.username}`, { method: 'DELETE' }).then(() => { - set({ deletingUser: null }); - get().refetchUsers(); - queryClient.invalidateQueries(StatisticsQueryKey); - }); - }, - createUser: async (body: UserCreate) => { - return fetch('/user', { method: 'POST', body }).then(() => { - set({ editingUser: null }); - get().refetchUsers(); - get().refetchServices(); - queryClient.invalidateQueries(StatisticsQueryKey); - }); - }, - editUser: async (body: UserCreate) => { - return fetch(`/user/${body.username}`, { method: 'PUT', body }).then( - () => { - get().onEditingUser(null); - get().refetchUsers(); - } - ); - }, - deleteService: async (service: Service) => { - set({ editingUser: null }); - return fetch(`/service/${service.id}`, { method: 'DELETE' }).then(() => { - set({ deletingUser: null }); - get().refetchUsers(); - queryClient.invalidateQueries(StatisticsQueryKey); - }); - }, - createService: async (body: ServiceCreate) => { - return fetch('/service', { method: 'POST', body }).then(() => { - set({ editingService: null }); - get().refetchServices(); - get().refetchServices(); - queryClient.invalidateQueries(StatisticsQueryKey); - }); - }, - editService: async (body: ServiceCreate) => { - return fetch(`/service/${body.id}`, { method: 'PUT', body }).then( - () => { - get().onEditingUser(null); - get().refetchUsers(); - } - ); - }, - fetchUserUsage: (body: User, query: FilterUsageType) => { - for (const key in query) { - if (!query[key as keyof FilterUsageType]) - delete query[key as keyof FilterUsageType]; - } - return fetch(`/user/${body.username}/usage`, { method: 'GET', query }); - }, - onEditingHosts: (isEditingHosts: boolean) => { - set({ isEditingHosts }); - }, - onEditingNodes: (isEditingNodes: boolean) => { - set({ isEditingNodes }); - }, - onShowingNodesUsage: (isShowingNodesUsage: boolean) => { - set({ isShowingNodesUsage }); - }, - setSubLink: (subscribeUrl) => { - set({ subscribeUrl }); - }, - resetDataUsage: async (user) => { - return fetch(`/user/${user.username}/reset`, { method: 'POST' }).then( - () => { - set({ resetUsageUser: null }); - get().refetchUsers(); - } - ); - }, - revokeSubscription: async (user) => { - return fetch(`/user/${user.username}/revoke_sub`, { - method: 'POST', - }).then((user) => { - set({ revokeSubscriptionUser: null, editingUser: user }); - get().refetchUsers(); - }); - }, - })) -); diff --git a/app/dashboard/src/contexts/HostsContext.tsx b/app/dashboard/src/contexts/HostsContext.ts similarity index 82% rename from app/dashboard/src/contexts/HostsContext.tsx rename to app/dashboard/src/contexts/HostsContext.ts index e5847b6..5c911ff 100644 --- a/app/dashboard/src/contexts/HostsContext.tsx +++ b/app/dashboard/src/contexts/HostsContext.ts @@ -19,11 +19,17 @@ type HostsStore = { hosts: HostsSchema; fetchHosts: () => void; setHosts: (hosts: HostsSchema) => Promise; + onEditingHosts: (isEditingHosts: boolean) => void; + isEditingHosts: boolean; }; export const useHosts = create((set) => ({ isLoading: false, isPostLoading: false, hosts: {}, + isEditingHosts: false, + onEditingHosts: (isEditingHosts: boolean) => { + set({ isEditingHosts }); + }, fetchHosts: () => { set({ isLoading: true }); fetch('/hosts') @@ -36,4 +42,4 @@ export const useHosts = create((set) => ({ set({ isPostLoading: false }); }); }, -})); \ No newline at end of file +})); diff --git a/app/dashboard/src/contexts/InboundsContext.ts b/app/dashboard/src/contexts/InboundsContext.ts new file mode 100644 index 0000000..580d7bd --- /dev/null +++ b/app/dashboard/src/contexts/InboundsContext.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { useDashboard } from './DashboardContext'; +import { fetch } from 'service/http'; +import { Inbounds } from 'types/Inbounds'; + + +export const fetchInbounds = async () => { + useDashboard.setState({ loading: true }); + return fetch('/inbounds') + .then((inbounds: Inbounds) => { + useInbounds.setState({ inbounds }); + }) + .finally(() => { + useDashboard.setState({ loading: false }); + }); +}; + + +type InboundsStateType = { + inbounds: Inbounds; + refetchInbounds: () => void; +}; + +export const useInbounds = create( + subscribeWithSelector(() => ({ + inbounds: [], + refetchInbounds: () => { + fetchInbounds(); + }, + })) +); diff --git a/app/dashboard/src/contexts/NodesContext.tsx b/app/dashboard/src/contexts/NodesContext.ts similarity index 76% rename from app/dashboard/src/contexts/NodesContext.tsx rename to app/dashboard/src/contexts/NodesContext.ts index c86aca3..6c3507d 100644 --- a/app/dashboard/src/contexts/NodesContext.tsx +++ b/app/dashboard/src/contexts/NodesContext.ts @@ -2,7 +2,7 @@ import { useQuery } from 'react-query'; import { fetch } from 'service/http'; import { z } from 'zod'; import { create } from 'zustand'; -import { FilterUsageType, useDashboard } from './DashboardContext'; +import { FilterUsageType } from 'types/Filter'; export const NodeSchema = z.object({ name: z.string().min(1), @@ -11,14 +11,10 @@ export const NodeSchema = z.object({ .number() .min(1) .or(z.string().transform((v) => parseFloat(v))), - api_port: z - .number() - .min(1) - .or(z.string().transform((v) => parseFloat(v))), xray_version: z.string().nullable().optional(), id: z.number().nullable().optional(), status: z - .enum(['connected', 'connecting', 'error', 'disabled']) + .enum(['healthy', 'unhealthy', 'disabled']) .nullable() .optional(), message: z.string().nullable().optional(), @@ -31,7 +27,6 @@ export const getNodeDefaultValues = (): NodeType => ({ name: '', address: '', port: 62050, - api_port: 62051, xray_version: '', }); @@ -39,28 +34,35 @@ export const FetchNodesQueryKey = 'fetch-nodes-query-key'; export type NodeStore = { nodes: NodeType[]; + deletingNode?: NodeType | null; + isEditingNodes: boolean; + isShowingNodesUsage: boolean; + isEditingCore: boolean; addNode: (node: NodeType) => Promise; fetchNodes: () => Promise; fetchNodesUsage: (query: FilterUsageType) => Promise; updateNode: (node: NodeType) => Promise; reconnectNode: (node: NodeType) => Promise; - deletingNode?: NodeType | null; deleteNode: () => Promise; setDeletingNode: (node: NodeType | null) => void; + onEditingNodes: (isEditingHosts: boolean) => void; + onShowingNodesUsage: (isShowingNodesUsage: boolean) => void; }; export const useNodesQuery = () => { - const { isEditingNodes } = useDashboard(); return useQuery({ queryKey: FetchNodesQueryKey, queryFn: useNodes.getState().fetchNodes, - refetchInterval: isEditingNodes ? 3000 : undefined, + refetchInterval: useNodes.getState().isEditingNodes ? 3000 : undefined, refetchOnWindowFocus: false, }); }; export const useNodes = create((set, get) => ({ nodes: [], + isEditingNodes: false, + isShowingNodesUsage: false, + isEditingCore: false, addNode(body) { return fetch('/node', { method: 'POST', body }); }, @@ -89,4 +91,10 @@ export const useNodes = create((set, get) => ({ method: 'DELETE', }); }, + onEditingNodes: (isEditingNodes: boolean) => { + set({ isEditingNodes }); + }, + onShowingNodesUsage: (isShowingNodesUsage: boolean) => { + set({ isShowingNodesUsage }); + }, })); diff --git a/app/dashboard/src/contexts/ServicesContext.ts b/app/dashboard/src/contexts/ServicesContext.ts new file mode 100644 index 0000000..4e95f0f --- /dev/null +++ b/app/dashboard/src/contexts/ServicesContext.ts @@ -0,0 +1,101 @@ + +import { queryClient } from 'utils/react-query'; +import { StatisticsQueryKey } from 'components/Statistics'; +import { Service, ServiceCreate } from 'types/Service'; +import { getServicesPerPageLimitSize } from 'utils/userPreferenceStorage'; +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { useDashboard } from 'contexts/DashboardContext'; +import { fetch } from 'service/http'; +import { ServicesFilterType } from 'types/Filter'; +import { useUsers } from './UsersContext'; + +type ServicesStateType = { + services: Service[], + isCreatingNewService: boolean; + editingService: Service | null | undefined; + deletingService: Service | null; + servicesFilters: ServicesFilterType; + onCreateService: (isOpen: boolean) => void; + onEditingService: (service: Service | null) => void; + onDeletingService: (service: Service | null) => void; + refetchServices: () => void; + deleteService: (service: Service) => Promise; + createService: (service: ServiceCreate) => Promise; + editService: (service: ServiceCreate) => Promise; + onFilterChange: (filters: Partial) => void; +}; + +export const fetchServices = async (query: ServicesFilterType): Promise => { + for (const key in query) { + if (!query[key as keyof ServicesFilterType]) delete query[key as keyof ServicesFilterType]; + } + + useDashboard.setState({ loading: true }); + + return fetch('/services') + .then((services) => { + useServices.setState({ services }); + return services; + }) + .finally(() => { + useDashboard.setState({ loading: false }); + }); +}; + +export const useServices = create( + subscribeWithSelector((set, get) => ({ + editingService: null, + deletingService: null, + isCreatingNewService: false, + services: [], + servicesFilters: { + name: '', + limit: getServicesPerPageLimitSize(), + sort: '-created_at', + }, + onFilterChange: (filters) => { + set({ + servicesFilters: { + ...get().servicesFilters, + ...filters, + }, + }); + get().refetchServices(); + }, + refetchServices: () => { + fetchServices(get().servicesFilters); + }, + onCreateService: (isCreatingNewService) => set({ isCreatingNewService }), + onEditingService: (editingService) => { + set({ editingService }); + }, + onDeletingService: (deletingService) => { + set({ deletingService }); + }, + deleteService: async (service: Service) => { + set({ editingService: null }); + return fetch(`/service/${service.id}`, { method: 'DELETE' }).then(() => { + set({ deletingService: null }); + get().refetchServices(); + useUsers.getState().refetchUsers(); + queryClient.invalidateQueries(StatisticsQueryKey); + }); + }, + createService: async (body: ServiceCreate) => { + return fetch('/service', { 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( + () => { + get().onEditingService(null); + get().refetchServices(); + } + ); + }, + })) +); diff --git a/app/dashboard/src/contexts/UsersContext.ts b/app/dashboard/src/contexts/UsersContext.ts new file mode 100644 index 0000000..45d51a7 --- /dev/null +++ b/app/dashboard/src/contexts/UsersContext.ts @@ -0,0 +1,160 @@ + +import { User, UserCreate } from 'types/User'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { create } from 'zustand'; +import { getUsersPerPageLimitSize } from 'utils/userPreferenceStorage'; +import { fetch } from 'service/http'; +import { useDashboard } from 'contexts/DashboardContext'; +import { StatisticsQueryKey } from 'components/Statistics'; +import { queryClient } from 'utils/react-query'; +import { useServices } from './ServicesContext'; +import { UsersFilterType, FilterUsageType } from 'types/Filter'; + +type UsersStateType = { + isCreatingNewUser: boolean; + editingUser: User | null | undefined; + deletingUser: User | null; + users: { + users: User[]; + total: number; + }; + usersFilters: UsersFilterType; + resetUsageUser: User | null; + revokeSubscriptionUser: User | null; + isResetingAllUsage: boolean; + subscribeUrl: string | null; + QRcodeLinks: string[] | null; + setQRCode: (links: string[] | null) => void; + setSubLink: (subscribeURL: string | null) => void; + onResetAllUsage: (isResetingAllUsage: boolean) => void; + resetAllUsage: () => Promise; + resetDataUsage: (user: User) => Promise; + revokeSubscription: (user: User) => Promise; + onCreateUser: (isOpen: boolean) => void; + onEditingUser: (user: User | null) => void; + onDeletingUser: (user: User | null) => void; + refetchUsers: () => void; + onFilterChange: (filters: Partial) => void; + deleteUser: (user: User) => Promise; + createUser: (user: UserCreate) => Promise; + editUser: (user: UserCreate) => Promise; + fetchUserUsage: (user: User, query: FilterUsageType) => Promise; +} + +export const fetchUsers = async (query: UsersFilterType): Promise => { + for (const key in query) { + if (!query[key as keyof UsersFilterType]) delete query[key as keyof UsersFilterType]; + } + useDashboard.setState({ loading: true }); + return fetch('/users', { query }) + .then((users) => { + for (let i = 0; i < users.users.length; i++) { + users.users[i].service = users.users[i].service_ids; + } + useUsers.setState({ users }); + return users; + }) + .finally(() => { + useDashboard.setState({ loading: false }); + }); +}; + +export const useUsers = create(subscribeWithSelector((set, get) => ({ + editingUser: null, + deletingUser: null, + isCreatingNewUser: false, + users: { + users: [], + total: 0, + }, + resetUsageUser: null, + revokeSubscriptionUser: null, + isResetingAllUsage: false, + usersFilters: { + username: '', + limit: getUsersPerPageLimitSize(), + sort: '-created_at', + }, + QRcodeLinks: null, + subscribeUrl: null, + setQRCode: (QRcodeLinks) => { + set({ QRcodeLinks }); + }, + setSubLink: (subscribeUrl) => { + set({ subscribeUrl }); + }, + refetchUsers: () => { + fetchUsers(get().usersFilters); + }, + resetAllUsage: async () => { + return fetch('/users/reset', { method: 'POST' }).then(() => { + get().onResetAllUsage(false); + get().refetchUsers(); + }); + }, + onResetAllUsage: (isResetingAllUsage) => set({ isResetingAllUsage }), + onCreateUser: (isCreatingNewUser) => set({ isCreatingNewUser }), + onEditingUser: (editingUser) => { + set({ editingUser }); + }, + onDeletingUser: (deletingUser) => { + set({ deletingUser }); + }, + onFilterChange: (filters) => { + set({ + usersFilters: { + ...get().usersFilters, + ...filters, + }, + }); + get().refetchUsers(); + }, + deleteUser: async (user: User) => { + set({ editingUser: null }); + return fetch(`/user/${user.username}`, { method: 'DELETE' }).then(() => { + set({ deletingUser: null }); + get().refetchUsers(); + queryClient.invalidateQueries(StatisticsQueryKey); + }); + }, + createUser: async (body: UserCreate) => { + return fetch('/user', { method: 'POST', body }).then(() => { + set({ editingUser: null }); + get().refetchUsers(); + useServices.getState().refetchServices(); + queryClient.invalidateQueries(StatisticsQueryKey); + }); + }, + editUser: async (body: UserCreate) => { + return fetch(`/user/${body.username}`, { method: 'PUT', body }).then( + () => { + get().onEditingUser(null); + get().refetchUsers(); + } + ); + }, + + fetchUserUsage: (body: User, query: FilterUsageType) => { + for (const key in query) { + if (!query[key as keyof FilterUsageType]) + delete query[key as keyof FilterUsageType]; + } + return fetch(`/user/${body.username}/usage`, { method: 'GET', query }); + }, + resetDataUsage: async (user) => { + return fetch(`/user/${user.username}/reset`, { method: 'POST' }).then( + () => { + set({ resetUsageUser: null }); + get().refetchUsers(); + } + ); + }, + revokeSubscription: async (user) => { + return fetch(`/user/${user.username}/revoke_sub`, { + method: 'POST', + }).then((user) => { + set({ revokeSubscriptionUser: null, editingUser: user }); + get().refetchUsers(); + }); + }, +}))); diff --git a/app/dashboard/src/pages/Dashboard.tsx b/app/dashboard/src/pages/Dashboard.tsx index cdd471e..9df076e 100644 --- a/app/dashboard/src/pages/Dashboard.tsx +++ b/app/dashboard/src/pages/Dashboard.tsx @@ -1,12 +1,8 @@ import { Box, VStack } from '@chakra-ui/react'; -import { useDashboard } from 'contexts/DashboardContext'; -import { FC, useEffect } from 'react'; +import { FC } from 'react'; import { Statistics } from '../components/Statistics'; export const Dashboard: FC = () => { - useEffect(() => { - useDashboard.getState().refetchUsers(); - }, []); return ( diff --git a/app/dashboard/src/pages/Services.tsx b/app/dashboard/src/pages/Services.tsx index 67b7e92..281b3aa 100644 --- a/app/dashboard/src/pages/Services.tsx +++ b/app/dashboard/src/pages/Services.tsx @@ -2,15 +2,16 @@ import { FC, useEffect } from 'react'; import { Box, VStack } from '@chakra-ui/react'; import { ServicesFilters } from 'components/ServicesFilters'; import { ServicesTable } from 'components/ServicesTable'; -import { useDashboard } from 'contexts/DashboardContext'; import { pages } from 'constants/Pages'; import { ServiceDialog } from 'components/ServiceDialog'; +import { useServices } from 'contexts/ServicesContext'; +import { useDashboard } from 'contexts/DashboardContext'; export const ServicesPage: FC = () => { useEffect(() => { useDashboard.getState().activatePage(pages.findIndex((page) => page.path === '/services')); - useDashboard.getState().refetchServices(); + useServices.getState().refetchServices(); }, []); return ( diff --git a/app/dashboard/src/pages/Users.tsx b/app/dashboard/src/pages/Users.tsx index 0d51ada..3a47727 100644 --- a/app/dashboard/src/pages/Users.tsx +++ b/app/dashboard/src/pages/Users.tsx @@ -11,12 +11,13 @@ import { QRCodeDialog } from 'components/QRCodeDialog'; import { ResetUserUsageModal } from 'components/ResetUserUsageModal'; import { RevokeSubscriptionModal } from 'components/RevokeSubscriptionModal'; import { pages } from 'constants/Pages'; +import { useUsers } from 'contexts/UsersContext'; export const UsersPage = () => { useEffect(() => { useDashboard.getState().activatePage(pages.findIndex((page) => page.path === '/users')); - useDashboard.getState().refetchUsers(); + useUsers.getState().refetchUsers(); }, []); return ( diff --git a/app/dashboard/src/types/Filter.ts b/app/dashboard/src/types/Filter.ts new file mode 100644 index 0000000..d491a8b --- /dev/null +++ b/app/dashboard/src/types/Filter.ts @@ -0,0 +1,23 @@ + + +export type FilterType = { + limit?: number; + offset?: number; + sort: string; +} + +export type UsersFilterType = FilterType & { + username?: string; + status?: 'active' | 'disabled' | 'limited' | 'expired' | 'on_hold'; +}; + +export type FilterUsageType = { + start?: string; + end?: string; +}; + +export type ServicesFilterType = FilterType & { + name?: string; + users?: number; + inbounds?: number; +}; diff --git a/app/dashboard/src/types/Inbounds.ts b/app/dashboard/src/types/Inbounds.ts new file mode 100644 index 0000000..fa39bf1 --- /dev/null +++ b/app/dashboard/src/types/Inbounds.ts @@ -0,0 +1,12 @@ +import { ProtocolType } from './Protocol'; + +export type InboundType = { + id: number; + tag: string; + protocol: ProtocolType; + network: string; + tls: string; + port?: number; +}; + +export type Inbounds = InboundType[]; diff --git a/app/dashboard/src/types/Protocol.ts b/app/dashboard/src/types/Protocol.ts new file mode 100644 index 0000000..d01bfe8 --- /dev/null +++ b/app/dashboard/src/types/Protocol.ts @@ -0,0 +1,3 @@ + +export type ProtocolType = 'vmess' | 'vless' | 'trojan' | 'shadowsocks'; + diff --git a/app/dashboard/src/types/User.ts b/app/dashboard/src/types/User.ts index 9778712..a31a45e 100644 --- a/app/dashboard/src/types/User.ts +++ b/app/dashboard/src/types/User.ts @@ -6,6 +6,8 @@ export type Status = | 'on_hold' | 'error' | 'connecting' + | 'healthy' + | 'unhealthy' | 'connected'; export type ProxyKeys = ('vmess' | 'vless' | 'trojan' | 'shadowsocks')[]; export type ProxyType = {