diff --git a/app/dashboard/public/locales/en.json b/app/dashboard/public/locales/en.json
@@ -75,6 +75,16 @@
"copyConfigs": "Copy Configs",
"total": "Total"
},
+ "hostsTable": {
+ "status": "status",
+ "editHost": "Edit host",
+ "createHosts": "Create host",
+ "address": "Address",
+ "noHostMatched": "It seems there is no host matched with what you are looking for",
+ "noHost": "There is no host in the system",
+ "port": "Port",
+ "total": "Total"
+ },
"userDialog": {
"dataLimit": "Data Limit",
"periodicUsageReset": "Periodic Usage Reset",
@@ -175,8 +185,9 @@
"port": "Port",
"path": "Path",
"sni": "SNI",
- "advancedOptions": "Advanced options",
- "addHost": "Add host",
+ "createSubmit": "Submit",
+ "editSubmit": "Submit",
+ "createHost": "Create host",
"savedSuccess": "Hosts saved successfully",
"loading": "loading...",
"apply": "Apply",
diff --git a/app/dashboard/src/components/footer/index.tsx b/app/dashboard/src/components/footer/index.tsx
@@ -1,16 +1,14 @@
import { BoxProps, HStack, Link, Text } from '@chakra-ui/react';
-import { ORGANIZATION_URL, REPO_URL } from 'constants/Project';
+import { ORGANIZATION_URL, CO_CONTRIBUTOR, REPO_URL } from 'constants/Project';
import { useDashboard } from 'stores';
import { FC } from 'react';
export const Footer: FC<BoxProps> = (props) => {
const { version } = useDashboard();
return (
- <HStack w="full" position="relative" {...props}>
+ <HStack w="full" position="fixed" justifyContent="center" {...props}>
<Text
display="inline-block"
- flexGrow={1}
- textAlign="center"
color="gray.500"
fontSize="xs"
>
@@ -18,10 +16,14 @@ export const Footer: FC<BoxProps> = (props) => {
Marzneshin
</Link>
{version ? ` (v${version}), ` : ', '}
- Made in{' '}
+ Made by{' '}
<Link color="blue.400" href={ORGANIZATION_URL}>
khodedawsh
</Link>
+ {' & '}
+ <Link color="blue.400" href={CO_CONTRIBUTOR}>
+ mardin.cc
+ </Link>
</Text>
</HStack>
);
diff --git a/app/dashboard/src/components/inbounds-card/index.tsx b/app/dashboard/src/components/inbounds-card/index.tsx
@@ -22,7 +22,7 @@ const InboundsIcon = chakra(ServerStackIcon, IconBaseStyle);
export const BadgeTag: FC<PropsWithChildren> = ({ children }) => {
return (
- <Text fontSize="sm" color="primary.700" _dark={{ color: 'gray.100' }} bg="gray.100" pl="3px" pr="3px" border="1px" borderRadius="md" borderColor="primary.700" fontWeight="light">
+ <Text fontSize="sm" color="primary.700" _dark={{ color: 'gray.100', bg: 'primary.700' }} bg="gray.100" pl="3px" pr="3px" border="1px" borderRadius="md" borderColor="primary.700" fontWeight="light">
{children}
</Text>
)
diff --git a/app/dashboard/src/components/table/pagination.tsx b/app/dashboard/src/components/table/pagination.tsx
@@ -14,7 +14,7 @@ import {
import { ChangeEvent, FC } from 'react';
import { useTranslation } from 'react-i18next';
import { FilterType } from 'types';
-import { setUsersPerPageLimitSize } from 'utils/userPreferenceStorage';
+import { PageSizeManager } from 'utils/userPreferenceStorage';
const PrevIcon = chakra(ArrowLongLeftIcon, {
baseStyle: {
@@ -43,35 +43,41 @@ const MINIMAL_PAGE_ITEM_COUNT = 5;
* - Always include first and last page
* - Add ellipsis if needed
*/
-function generatePageItems(total: number, current: number, width: number) {
- if (width < MINIMAL_PAGE_ITEM_COUNT) {
- throw new Error(
- `Must allow at least ${MINIMAL_PAGE_ITEM_COUNT} page items`
- );
- }
- if (width % 2 === 0) {
- throw new Error('Must allow odd number of page items');
+interface PageItem {
+ value: string | number;
+ isPlaceholder?: boolean;
+}
+
+function validateWidth(width: number): void {
+ if (width < MINIMAL_PAGE_ITEM_COUNT || width % 2 === 0) {
+ throw new Error(`Must allow at least ${MINIMAL_PAGE_ITEM_COUNT} odd page items`);
}
+}
+
+function generatePageItems(total: number, current: number, width: number): PageItem[] {
+ validateWidth(width);
+
if (total < width) {
- return [...new Array(total).keys()];
+ return Array.from({ length: total }, (_, index) => ({ value: index }));
}
- const left = Math.max(
- 0,
- Math.min(total - width, current - Math.floor(width / 2))
- );
- const items: (string | number)[] = new Array(width);
- for (let i = 0; i < width; i += 1) {
- items[i] = i + left;
+
+ const left = Math.max(0, Math.min(total - width, current - Math.floor(width / 2)));
+ const right = Math.min(left + width, total);
+
+ const items: PageItem[] = [];
+
+ if (left > 0) {
+ items.push({ value: 0 }, { value: 'prev-more', isPlaceholder: true });
}
- // replace non-ending items with placeholders
- if (items[0] > 0) {
- items[0] = 0;
- items[1] = 'prev-more';
+
+ for (let i = left; i < right; i++) {
+ items.push({ value: i });
}
- if (items[items.length - 1] < total - 1) {
- items[items.length - 1] = total - 1;
- items[items.length - 2] = 'next-more';
+
+ if (right < total) {
+ items.push({ value: 'next-more', isPlaceholder: true }, { value: total - 1 });
}
+
return items;
}
@@ -79,9 +85,17 @@ type PaginationProps = {
total: number;
onFilterChange: (filters: Partial<FilterType>) => void;
filters: FilterType;
+ pageSizeManager: PageSizeManager;
+ pageListsNumbers?: number[];
}
-export const Pagination: FC<PaginationProps> = ({ filters, total, onFilterChange }) => {
+export const Pagination: FC<PaginationProps> = ({
+ filters,
+ total,
+ onFilterChange,
+ pageSizeManager,
+ pageListsNumbers = [10, 20, 30],
+}) => {
const { limit: perPage, offset } = filters;
const page = (offset || 0) / (perPage || 1);
@@ -100,7 +114,7 @@ export const Pagination: FC<PaginationProps> = ({ filters, total, onFilterChange
...filters,
limit: parseInt(e.target.value),
});
- setUsersPerPageLimitSize(e.target.value);
+ pageSizeManager.setPageSize(e.target.value);
};
const { t } = useTranslation();
@@ -124,9 +138,9 @@ export const Pagination: FC<PaginationProps> = ({ filters, total, onFilterChange
size="sm"
rounded="md"
>
- <option>10</option>
- <option>20</option>
- <option>30</option>
+ {pageListsNumbers.map((list, i) => {
+ return (<option key={i}>{list}</option>)
+ })}
</Select>
<Text whiteSpace={'nowrap'} fontSize="sm">
{t('itemsPerPage')}
@@ -142,20 +156,19 @@ export const Pagination: FC<PaginationProps> = ({ filters, total, onFilterChange
>
{t('previous')}
</Button>
- {pages.map((pageIndex) => {
- if (typeof pageIndex === 'string')
- return <Button key={pageIndex}>...</Button>;
- return (
- <Button
- key={pageIndex}
- variant={(pageIndex as number) === page ? 'solid' : 'outline'}
- onClick={changePage.bind(null, pageIndex)}
- >
- {(pageIndex as number) + 1}
- </Button>
- );
- })}
-
+ {pages.map((pageItem) => (
+ <Button
+ key={String(pageItem.value)}
+ variant={String(pageItem.value) === String(page) ? 'solid' : 'outline'}
+ onClick={() =>
+ typeof pageItem.value === 'number' && changePage(pageItem.value)
+ }
+ >
+ {String(pageItem.value) === 'prev-more' || String(pageItem.value) === 'next-more'
+ ? '...'
+ : (pageItem.value as number) + 1}
+ </Button>
+ ))}
<Button
rightIcon={<NextIcon />}
onClick={changePage.bind(null, page + 1)}
diff --git a/app/dashboard/src/constants/Project.ts b/app/dashboard/src/constants/Project.ts
@@ -1,3 +1,4 @@
export const REPO_URL = 'https://github.com/khodedawsh/Marzneshin';
export const ORGANIZATION_URL = 'https://github.com/khodedawosh';
+export const CO_CONTRIBUTOR = 'https://mardin.cc/';
export const DONATION_URL = 'https://github.com/khodedawosh/Marzneshin#donation';
diff --git a/app/dashboard/src/layouts/console-layout.tsx b/app/dashboard/src/layouts/console-layout.tsx
@@ -24,10 +24,10 @@ export const ConsoleLayout = () => {
<Suspense>
<Outlet />
</Suspense>
- <Footer />
</Box>
</Box>
</Panel>
+ <Footer bottom={3} />
</PanelGroup>
);
};
diff --git a/app/dashboard/src/pages/hosts/container.tsx b/app/dashboard/src/pages/hosts/container.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
+import { InboundsHostsFilters } from './filters'
+import { InboundsSidebar } from './inbounds-sidebar'
+import { HostsTable } from './table'
+
+export const InboundsHostsManager = () => {
+ return (
+ <>
+ <InboundsHostsFilters />
+ <PanelGroup direction="horizontal">
+ <Panel id="sidebar" minSize={20} order={1}>
+ <InboundsSidebar />
+ </Panel>
+ <PanelResizeHandle />
+ <Panel minSize={60} order={2}>
+ <HostsTable />
+ </Panel>
+ </PanelGroup>
+ </>
+ )
+}
diff --git a/app/dashboard/src/pages/hosts/dialog/index.tsx b/app/dashboard/src/pages/hosts/dialog/index.tsx
@@ -103,7 +103,7 @@ export const HostsDialog: FC = () => {
.then(() => {
toast({
title: t(
- isEditingHost ? 'hostDialog.editHost' : 'hostDialog.createNewHost',
+ isEditingHost ? 'hostsDialog.editHost' : 'hostsDialog.createNewHost',
{ hoshtName: values.host }
),
status: 'success',
@@ -138,342 +138,340 @@ export const HostsDialog: FC = () => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
- <ModalContent mx="3" w="fit-content" maxW="3xl">
- <DialogModalHeader HeaderIcon={isEditingHost ? EditIcon : AddIcon} title={isEditingHost ? t('nodeDialog.editNode') : t('nodeDialog.addNode')} />
+ <ModalContent mx="3" w="lg" >
+ <DialogModalHeader HeaderIcon={isEditingHost ? EditIcon : AddIcon} title={isEditingHost ? t('hostsDialog.editHost') : t('hostsDialog.createHost')} />
<ModalCloseButton mt={3} />
- <ModalBody w="440px" pb={6} pt={3}>
- <VStack>
- {loading && 'loading...'}
- <form onSubmit={form.handleSubmit((v) => submit(v))}>
- <VStack>
- <HStack w="100%" alignItems="flex-start">
- <FormControl
- position="relative"
- zIndex={10}
- >
- <InputGroup>
- <Input
- {...form.register('remark')}
- size="sm"
- borderRadius="4px"
- placeholder="Remark"
- />
- <InputRightElement>
- <RemarkFieldPopover />
- </InputRightElement>
- </InputGroup>
- </FormControl>
- </HStack>
- <FormControl>
+ <ModalBody pb={4} pt={3}>
+ {loading && 'loading...'}
+ <form onSubmit={form.handleSubmit((v) => submit(v))}>
+ <VStack>
+ <HStack w="100%" alignItems="flex-start">
+ <FormControl
+ position="relative"
+ zIndex={10}
+ >
<InputGroup>
<Input
+ {...form.register('remark')}
size="sm"
borderRadius="4px"
- placeholder="Address (e.g. example.com)"
- {...form.register('address')}
+ placeholder="Remark"
/>
<InputRightElement>
<RemarkFieldPopover />
</InputRightElement>
</InputGroup>
</FormControl>
- <FormControl>
- <FormLabel
- display="flex"
- pb={1}
- alignItems="center"
- justifyContent="space-between"
- gap={1}
- m="0"
- >
- <span>{t('hostsDialog.port')}</span>
- <Popover isLazy placement="right">
- <PopoverTrigger>
- <InfoIcon />
- </PopoverTrigger>
- <Portal>
- <PopoverContent p={2}>
- <PopoverArrow />
- <PopoverCloseButton />
- <Text fontSize="xs" pr={5}>
- {t('hostsDialog.port.info')}
- </Text>
- </PopoverContent>
- </Portal>
- </Popover>
- </FormLabel>
- <Input
- size="sm"
- borderRadius="4px"
- placeholder={String(selectedInbound?.port || '8080')}
- type="number"
- {...form.register('port')}
- />
- </FormControl>
- <FormControl>
- <FormLabel
- display="flex"
- pb={1}
- alignItems="center"
- gap={1}
- justifyContent="space-between"
- m="0"
- >
- <span>{t('hostsDialog.sni')}</span>
- <Popover isLazy placement="right">
- <PopoverTrigger>
- <InfoIcon />
- </PopoverTrigger>
- <Portal>
- <PopoverContent p={2}>
- <PopoverArrow />
- <PopoverCloseButton />
- <Text fontSize="xs" pr={5}>
- {t('hostsDialog.sni.info')}
- </Text>
- <Text fontSize="xs" mt="2">
- <Trans
- i18nKey="hostsDialog.host.wildcard"
- components={{
- badge: <Badge />,
- }}
- />
- </Text>
- <Text fontSize="xs">
- <Trans
- i18nKey="hostsDialog.host.multiHost"
- components={{
- badge: <Badge />,
- }}
- />
- </Text>
- </PopoverContent>
- </Portal>
- </Popover>
- </FormLabel>
+ </HStack>
+ <FormControl>
+ <InputGroup>
<Input
size="sm"
borderRadius="4px"
- placeholder="SNI (e.g. example.com)"
- {...form.register('sni')}
+ placeholder="Address (e.g. example.com)"
+ {...form.register('address')}
/>
- </FormControl>
- <FormControl>
- <FormLabel
- display="flex"
- pb={1}
- alignItems="center"
- gap={1}
- justifyContent="space-between"
- m="0"
- >
- <span>{t('hostsDialog.host')}</span>
+ <InputRightElement>
+ <RemarkFieldPopover />
+ </InputRightElement>
+ </InputGroup>
+ </FormControl>
+ <FormControl>
+ <FormLabel
+ display="flex"
+ pb={1}
+ alignItems="center"
+ justifyContent="space-between"
+ gap={1}
+ m="0"
+ >
+ <span>{t('hostsDialog.port')}</span>
+ <Popover isLazy placement="right">
+ <PopoverTrigger>
+ <InfoIcon />
+ </PopoverTrigger>
+ <Portal>
+ <PopoverContent p={2}>
+ <PopoverArrow />
+ <PopoverCloseButton />
+ <Text fontSize="xs" pr={5}>
+ {t('hostsDialog.port.info')}
+ </Text>
+ </PopoverContent>
+ </Portal>
+ </Popover>
+ </FormLabel>
+ <Input
+ size="sm"
+ borderRadius="4px"
+ placeholder={String(selectedInbound?.port || '8080')}
+ type="number"
+ {...form.register('port')}
+ />
+ </FormControl>
+ <FormControl>
+ <FormLabel
+ display="flex"
+ pb={1}
+ alignItems="center"
+ gap={1}
+ justifyContent="space-between"
+ m="0"
+ >
+ <span>{t('hostsDialog.sni')}</span>
+ <Popover isLazy placement="right">
+ <PopoverTrigger>
+ <InfoIcon />
+ </PopoverTrigger>
+ <Portal>
+ <PopoverContent p={2}>
+ <PopoverArrow />
+ <PopoverCloseButton />
+ <Text fontSize="xs" pr={5}>
+ {t('hostsDialog.sni.info')}
+ </Text>
+ <Text fontSize="xs" mt="2">
+ <Trans
+ i18nKey="hostsDialog.host.wildcard"
+ components={{
+ badge: <Badge />,
+ }}
+ />
+ </Text>
+ <Text fontSize="xs">
+ <Trans
+ i18nKey="hostsDialog.host.multiHost"
+ components={{
+ badge: <Badge />,
+ }}
+ />
+ </Text>
+ </PopoverContent>
+ </Portal>
+ </Popover>
+ </FormLabel>
+ <Input
+ size="sm"
+ borderRadius="4px"
+ placeholder="SNI (e.g. example.com)"
+ {...form.register('sni')}
+ />
+ </FormControl>
+ <FormControl>
+ <FormLabel
+ display="flex"
+ pb={1}
+ alignItems="center"
+ gap={1}
+ justifyContent="space-between"
+ m="0"
+ >
+ <span>{t('hostsDialog.host')}</span>
- <Popover isLazy placement="right">
- <PopoverTrigger>
- <InfoIcon />
- </PopoverTrigger>
- <Portal>
- <PopoverContent p={2}>
- <PopoverArrow />
- <PopoverCloseButton />
- <Text fontSize="xs" pr={5}>
- {t('hostsDialog.host.info')}
- </Text>
- <Text fontSize="xs" mt="2">
- <Trans
- i18nKey="hostsDialog.host.wildcard"
- components={{
- badge: <Badge />,
- }}
- />
- </Text>
- <Text fontSize="xs">
- <Trans
- i18nKey="hostsDialog.host.multiHost"
- components={{
- badge: <Badge />,
- }}
- />
- </Text>
- </PopoverContent>
- </Portal>
- </Popover>
- </FormLabel>
- <Input
- size="sm"
- borderRadius="4px"
- placeholder="Host (e.g. example.com)"
- {...form.register('host')}
- />
- </FormControl>
- <FormControl>
- <FormLabel
- display="flex"
- pb={1}
- alignItems="center"
- gap={1}
- justifyContent="space-between"
- m="0"
- >
- <span>{t('hostsDialog.path')}</span>
- <Popover isLazy placement="right">
- <PopoverTrigger>
- <InfoIcon />
- </PopoverTrigger>
- <Portal>
- <PopoverContent p={2}>
- <PopoverArrow />
- <PopoverCloseButton />
- <Text fontSize="xs" pr={5}>
- {t('hostsDialog.path.info')}
- </Text>
- </PopoverContent>
- </Portal>
- </Popover>
- </FormLabel>
- <Input
- size="sm"
- borderRadius="4px"
- placeholder="path (e.g. /vless)"
- {...form.register('path')}
- />
- </FormControl>
- <FormControl height="66px">
- <FormLabel
- display="flex"
- pb={1}
- alignItems="center"
- gap={1}
- justifyContent="space-between"
- m="0"
- >
- <span>{t('hostsDialog.security')}</span>
- <Popover isLazy placement="right">
- <PopoverTrigger>
- <InfoIcon />
- </PopoverTrigger>
- <Portal>
- <PopoverContent p={2}>
- <PopoverArrow />
- <PopoverCloseButton />
- <Text fontSize="xs" pr={5}>
- {t('hostsDialog.security.info')}
- </Text>
- </PopoverContent>
- </Portal>
- </Popover>
- </FormLabel>
- <Select size="sm" {...form.register('security')} >
- {proxyHostSecurity.map((s) => {
- return (
- <option key={s.value} value={s.value}>
- {s.title}
- </option>
- );
- })}
- </Select>
- </FormControl>
- {/* TODO: Add ALPN and fingerprint fields smooth entrance and
+ <Popover isLazy placement="right">
+ <PopoverTrigger>
+ <InfoIcon />
+ </PopoverTrigger>
+ <Portal>
+ <PopoverContent p={2}>
+ <PopoverArrow />
+ <PopoverCloseButton />
+ <Text fontSize="xs" pr={5}>
+ {t('hostsDialog.host.info')}
+ </Text>
+ <Text fontSize="xs" mt="2">
+ <Trans
+ i18nKey="hostsDialog.host.wildcard"
+ components={{
+ badge: <Badge />,
+ }}
+ />
+ </Text>
+ <Text fontSize="xs">
+ <Trans
+ i18nKey="hostsDialog.host.multiHost"
+ components={{
+ badge: <Badge />,
+ }}
+ />
+ </Text>
+ </PopoverContent>
+ </Portal>
+ </Popover>
+ </FormLabel>
+ <Input
+ size="sm"
+ borderRadius="4px"
+ placeholder="Host (e.g. example.com)"
+ {...form.register('host')}
+ />
+ </FormControl>
+ <FormControl>
+ <FormLabel
+ display="flex"
+ pb={1}
+ alignItems="center"
+ gap={1}
+ justifyContent="space-between"
+ m="0"
+ >
+ <span>{t('hostsDialog.path')}</span>
+ <Popover isLazy placement="right">
+ <PopoverTrigger>
+ <InfoIcon />
+ </PopoverTrigger>
+ <Portal>
+ <PopoverContent p={2}>
+ <PopoverArrow />
+ <PopoverCloseButton />
+ <Text fontSize="xs" pr={5}>
+ {t('hostsDialog.path.info')}
+ </Text>
+ </PopoverContent>
+ </Portal>
+ </Popover>
+ </FormLabel>
+ <Input
+ size="sm"
+ borderRadius="4px"
+ placeholder="path (e.g. /vless)"
+ {...form.register('path')}
+ />
+ </FormControl>
+ <FormControl height="66px">
+ <FormLabel
+ display="flex"
+ pb={1}
+ alignItems="center"
+ gap={1}
+ justifyContent="space-between"
+ m="0"
+ >
+ <span>{t('hostsDialog.security')}</span>
+ <Popover isLazy placement="right">
+ <PopoverTrigger>
+ <InfoIcon />
+ </PopoverTrigger>
+ <Portal>
+ <PopoverContent p={2}>
+ <PopoverArrow />
+ <PopoverCloseButton />
+ <Text fontSize="xs" pr={5}>
+ {t('hostsDialog.security.info')}
+ </Text>
+ </PopoverContent>
+ </Portal>
+ </Popover>
+ </FormLabel>
+ <Select size="sm" {...form.register('security')} >
+ {proxyHostSecurity.map((s) => {
+ return (
+ <option key={s.value} value={s.value}>
+ {s.title}
+ </option>
+ );
+ })}
+ </Select>
+ </FormControl>
+ {/* TODO: Add ALPN and fingerprint fields smooth entrance and
* pushing down animation when security field is TLS
* - Insertion and pushing down the below element
* - Slowly increasing the opacity
* Related issue: #95
*/}
- {form.getValues().security === 'tls' && (
- <>
- <FormControl height="66px">
- <FormLabel
- display="flex"
- pb={1}
- alignItems="center"
- gap={1}
- justifyContent="space-between"
- m="0"
- >
- <span>{t('hostsDialog.alpn')}</span>
- </FormLabel>
- <Select
- size="sm"
- {...form.register('alpn')}
- >
- {proxyALPN.map((s) => {
- return (
- <option key={s.value} value={s.value}>
- {s.title}
- </option>
- );
- })}
- </Select>
- </FormControl>
- <FormControl height="66px">
- <FormLabel
- display="flex"
- pb={1}
- alignItems="center"
- gap={1}
- justifyContent="space-between"
- m="0"
- >
- <span>{t('hostsDialog.fingerprint')}</span>
- </FormLabel>
- <Select
- size="sm"
- {...form.register('fingerprint')}
- >
- {proxyFingerprint.map((s) => {
- return (
- <option key={s.value} value={s.value}>
- {s.title}
- </option>
- );
- })}
- </Select>
- </FormControl>
- </>
- )}
- <Spacer m={2}></Spacer>
- <HStack w="full">
- <Tooltip label={t('delete')} placement="top">
- <IconButton
- colorScheme="red"
- variant="ghost"
+ {form.getValues().security === 'tls' && (
+ <>
+ <FormControl height="66px">
+ <FormLabel
+ display="flex"
+ pb={1}
+ alignItems="center"
+ gap={1}
+ justifyContent="space-between"
+ m="0"
+ >
+ <span>{t('hostsDialog.alpn')}</span>
+ </FormLabel>
+ <Select
size="sm"
- aria-label="delete node"
- onClick={() => {
- if (selectedHost !== null) {
- onDeletingHost(true, selectedHost);
- onClose();
- }
- }}
+ {...form.register('alpn')}
+ >
+ {proxyALPN.map((s) => {
+ return (
+ <option key={s.value} value={s.value}>
+ {s.title}
+ </option>
+ );
+ })}
+ </Select>
+ </FormControl>
+ <FormControl height="66px">
+ <FormLabel
+ display="flex"
+ pb={1}
+ alignItems="center"
+ gap={1}
+ justifyContent="space-between"
+ m="0"
>
- <DeleteIcon />
- </IconButton>
- </Tooltip>
- <Button
- flexGrow={1}
- type="submit"
- colorScheme="primary"
+ <span>{t('hostsDialog.fingerprint')}</span>
+ </FormLabel>
+ <Select
+ size="sm"
+ {...form.register('fingerprint')}
+ >
+ {proxyFingerprint.map((s) => {
+ return (
+ <option key={s.value} value={s.value}>
+ {s.title}
+ </option>
+ );
+ })}
+ </Select>
+ </FormControl>
+ </>
+ )}
+ <Spacer m={2}></Spacer>
+ <HStack w="full">
+ <Tooltip label={t('delete')} placement="top">
+ <IconButton
+ colorScheme="red"
+ variant="ghost"
size="sm"
- px={5}
- w="full"
- isLoading={loading}
- >
- {isEditingHost ? t('hostDialog.editSubmit') : t('hostDialog.createSubmit')}
- </Button>
- </HStack>
- {error && (
- <Alert
- status="error"
- display={{ base: 'none', md: 'flex' }}
+ aria-label="delete host"
+ onClick={() => {
+ if (selectedHost !== null) {
+ onDeletingHost(true, selectedHost);
+ onClose();
+ }
+ }}
>
- <AlertIcon />
- {error}
- </Alert>
- )}
- </VStack>
- </form>
- </VStack>
+ <DeleteIcon />
+ </IconButton>
+ </Tooltip>
+ <Button
+ flexGrow={1}
+ type="submit"
+ colorScheme="primary"
+ size="sm"
+ px={5}
+ w="full"
+ isLoading={loading}
+ >
+ {isEditingHost ? t('hostsDialog.editSubmit') : t('hostsDialog.createSubmit')}
+ </Button>
+ </HStack>
+ {error && (
+ <Alert
+ status="error"
+ display={{ base: 'none', md: 'flex' }}
+ >
+ <AlertIcon />
+ {error}
+ </Alert>
+ )}
+ </VStack>
+ </form>
</ModalBody>
</ModalContent>
</Modal>
diff --git a/app/dashboard/src/pages/hosts/filters.tsx b/app/dashboard/src/pages/hosts/filters.tsx
@@ -0,0 +1,137 @@
+import {
+ BoxProps,
+ Button,
+ chakra,
+ Grid,
+ GridItem,
+ HStack,
+ IconButton,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ InputRightElement,
+ Spinner,
+} from '@chakra-ui/react';
+import {
+ ArrowPathIcon,
+ MagnifyingGlassIcon,
+ XMarkIcon,
+} from '@heroicons/react/24/outline';
+import classNames from 'classnames';
+import { useDashboard, useInbounds } from 'stores';
+import debounce from 'lodash.debounce';
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const iconProps = {
+ baseStyle: {
+ w: 4,
+ h: 4,
+ },
+};
+
+const SearchIcon = chakra(MagnifyingGlassIcon, iconProps);
+const ClearIcon = chakra(XMarkIcon, iconProps);
+export const ReloadIcon = chakra(ArrowPathIcon, iconProps);
+
+export type FilterProps = {} & BoxProps;
+const setSearchField = debounce((tag: string) => {
+ useInbounds.getState().onInboundsFilterChange({
+ ...useInbounds.getState().inboundsFilters,
+ offset: 0,
+ tag,
+ });
+}, 300);
+
+export const InboundsHostsFilters: FC<FilterProps> = ({ ...props }) => {
+ const { loading } = useDashboard();
+ const { inboundsFilters: filters, onInboundsFilterChange, refetchHosts, onCreatingHost } = useInbounds();
+ const { t } = useTranslation();
+ const [search, setSearch] = useState('');
+ const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ setSearch(e.target.value);
+ setSearchField(e.target.value);
+ };
+ const clear = () => {
+ setSearch('');
+ onInboundsFilterChange({
+ ...filters,
+ offset: 0,
+ tag: '',
+ });
+ };
+
+ return (
+ <Grid
+ id="hosts-filters"
+ templateColumns={{
+ lg: 'repeat(3, 1fr)',
+ md: 'repeat(4, 1fr)',
+ base: 'repeat(1, 1fr)',
+ }}
+ position="sticky"
+ top={0}
+ mx="-6"
+ px="6"
+ rowGap={4}
+ gap={{
+ lg: 4,
+ base: 0,
+ }}
+ bg="var(--chakra-colors-chakra-body-bg)"
+ py={4}
+ zIndex="docked"
+ {...props}
+ >
+ <GridItem colSpan={{ base: 1, md: 2, lg: 1 }} order={{ base: 2, md: 1 }}>
+ <InputGroup>
+ <InputLeftElement pointerEvents="none" children={<SearchIcon />} />
+ <Input
+ placeholder={t('search')}
+ value={search}
+ borderColor="light-border"
+ onChange={onChange}
+ />
+ <InputRightElement>
+ {loading && <Spinner size="xs" />}
+ {filters.tag && filters.tag.length > 0 && (
+ <IconButton
+ onClick={clear}
+ aria-label="clear"
+ size="xs"
+ variant="ghost"
+ >
+ <ClearIcon />
+ </IconButton>
+ )}
+ </InputRightElement>
+ </InputGroup>
+ </GridItem>
+ <GridItem colSpan={2} order={{ base: 1, md: 2 }}>
+ <HStack justifyContent="flex-end" alignItems="center" h="full">
+ <IconButton
+ aria-label="refresh name"
+ disabled={loading}
+ onClick={refetchHosts}
+ size="sm"
+ variant="outline"
+ >
+ <ReloadIcon
+ className={classNames({
+ 'animate-spin': loading,
+ })}
+ />
+ </IconButton>
+ <Button
+ colorScheme="primary"
+ size="sm"
+ onClick={() => onCreatingHost(true)}
+ px={5}
+ >
+ {t('hostsTable.createHosts')}
+ </Button>
+ </HStack>
+ </GridItem>
+ </Grid>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/inbounds-sidebar/index.tsx b/app/dashboard/src/pages/hosts/inbounds-sidebar/index.tsx
@@ -0,0 +1,61 @@
+import { Table, Thead, Tbody, Th, Td, Tr, HStack } from '@chakra-ui/react';
+import classNames from 'classnames';
+import { InboundCard } from 'components/inbounds-card';
+import { handleSort } from 'components/table';
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useInbounds } from 'stores';
+
+export const InboundsSidebar = () => {
+ const {
+ inbounds,
+ onInboundsFilterChange,
+ inboundsFilters: filters,
+ selectedInbound,
+ selectInbound,
+ refetchInbounds
+ } = useInbounds();
+ const { t } = useTranslation();
+ useEffect(() => {
+ inbounds.length === 0 && refetchInbounds();
+ }, [inbounds.length])
+ return (
+ <Table orientation="vertical" zIndex="docked" pr="3" >
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ minW="120px"
+ pl={4}
+ pr={4}
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, filters, 'tag', onInboundsFilterChange)}
+ >
+ <HStack>
+ <span>{t('inbounds')}</span>
+ </HStack>
+ </Th>
+ </Tr>
+ </Thead>
+ <Tbody>
+ {inbounds.map((inbound, i) => {
+ const active = inbounds[i] == selectedInbound
+ return (
+ <Tr key={i}
+ className={classNames('interactive')}
+ bg={active ? 'gray.300' : undefined}
+ _dark={{ bg: active ? 'gray.700' : undefined }}
+ onClick={() => { selectInbound(inbound) }}>
+ <Td>
+ <InboundCard
+
+ tag={inbound.tag}
+ nodeName={inbound.node.name}
+ protocol={inbound.protocol}
+ />
+ </Td>
+ </Tr>);
+ })}
+ </Tbody>
+ </Table>
+ )
+}
diff --git a/app/dashboard/src/pages/hosts/index.tsx b/app/dashboard/src/pages/hosts/index.tsx
@@ -1,11 +1,13 @@
import { HostsDialog } from './dialog';
import Page from 'components/page';
import { pages } from 'stores';
+import { InboundsHostsManager } from './container';
const HostsPage = () => {
return (
<Page page={pages.hosts}>
<HostsDialog />
+ <InboundsHostsManager />
</Page>
)
}
diff --git a/app/dashboard/src/pages/hosts/table/index.tsx b/app/dashboard/src/pages/hosts/table/index.tsx
@@ -0,0 +1,303 @@
+import {
+ Accordion,
+ AccordionButton,
+ AccordionItem,
+ AccordionPanel,
+ Box,
+ HStack,
+ IconButton,
+ Table,
+ TableProps,
+ Tbody,
+ Td,
+ Text,
+ Th,
+ Thead,
+ Tooltip,
+ Tr,
+ useBreakpointValue,
+ VStack,
+} from '@chakra-ui/react';
+import classNames from 'classnames';
+import { FC, Fragment, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ EmptySection,
+ AccordionArrowIcon,
+ EditIcon,
+ Sort,
+ handleSort,
+} from 'components/table';
+import { useInbounds, fetchInboundHosts } from 'stores';
+import { useQuery } from 'react-query';
+
+type ExpandedIndex = number | number[];
+
+type NodesTableProps = {} & TableProps;
+
+export const HostsTable: FC<NodesTableProps> = (props) => {
+ const {
+ hostsFilters: filters,
+ selectedInbound,
+ onEditingHost,
+ onCreatingHost,
+ onHostsFilterChange,
+ refetchHosts,
+ } = useInbounds();
+ const { data: hosts } = useQuery('hosts', () => {
+ if (selectedInbound !== null) {
+ return fetchInboundHosts(selectedInbound.id);
+ }
+ });
+
+ useEffect(() => { refetchHosts() }, [selectedInbound]);
+
+ const { t } = useTranslation();
+ const [selectedRow, setSelectedRow] = useState<ExpandedIndex | undefined>(
+ undefined
+ );
+
+ const useTable = useBreakpointValue({ base: false, md: true });
+
+ // TODO: Find a different mechancism to to detect ftiler
+ // const isFiltered = users.length !== total;
+ const toggleAccordion = (index: number) => {
+ setSelectedRow(index === selectedRow ? undefined : index);
+ };
+
+ return (
+ <Box id="hosts-table" borderTopStartRadius="0" overflowX={{ base: 'unset', md: 'unset' }} >
+ <Accordion
+ allowMultiple
+ display={{ base: 'block', md: 'none' }}
+ index={selectedRow}
+ >
+ <Table orientation="vertical" zIndex="docked" {...props}>
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ minW="220px"
+ pl={4}
+ pr={4}
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, filters, 'remark', onHostsFilterChange)}
+ >
+ <HStack>
+ <span>{t('remark')}</span>
+ <Sort sort={filters.sort} column="remark" />
+ </HStack>
+ </Th>
+ <Th
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, filters, 'address', onHostsFilterChange)}
+ >
+ <HStack>
+ <span>{t('hostsTable.address')}</span>
+ <Sort sort={filters.sort} column="address" />
+ </HStack>
+ </Th>
+ <Th
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, filters, 'port', onHostsFilterChange)}
+ >
+ <HStack>
+ <span>{t('hostsTable.port')}</span>
+ <Sort sort={filters.sort} column="port" />
+ </HStack>
+ </Th>
+ {/* Action buttons (certificate, core settings)*/}
+ </Tr>
+ </Thead>
+ <Tbody>
+ {!useTable && hosts?.map((host, i) => {
+ return (
+ <Fragment key={i}>
+ <Tr
+ onClick={toggleAccordion.bind(null, i)}
+ cursor="pointer"
+ >
+ <Td borderBottom={0} minW="100px" pl={4} pr={4}>
+ {host.remark}
+ </Td>
+ <Td borderBottom={0} minW="100px" pr={0}>
+ {host.address}
+ </Td>
+ <Td p={0} borderBottom={0} w="32px" minW="32px">
+ <AccordionArrowIcon
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ transition="transform .2s ease-out"
+ transform={
+ selectedRow === i ? 'rotate(180deg)' : '0deg'
+ }
+ />
+ </Td>
+ </Tr>
+ <Tr
+ className="collapsible"
+ onClick={toggleAccordion.bind(null, i)}
+ >
+ <Td p={0} colSpan={4}>
+ <AccordionItem border={0}>
+ <AccordionButton display="none"></AccordionButton>
+ <AccordionPanel
+ border={0}
+ cursor="pointer"
+ px={6}
+ py={3}
+ >
+ <VStack justifyContent="space-between" spacing="4">
+ <VStack
+ alignItems="flex-start"
+ w="full"
+ spacing={-1}
+ >
+ <Text
+ textTransform="capitalize"
+ fontSize="xs"
+ fontWeight="bold"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ {t('nodesTable.ipPort')}
+ </Text>
+ <Box width="full" minW="230px">
+ {host.port}
+ </Box>
+ </VStack>
+ <HStack w="full" justifyContent="space-between">
+ <HStack>
+ <Tooltip
+ label={t('nodesDialog.editNode')}
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="Edit user"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onEditingHost(true, host);
+ }}
+ >
+ <EditIcon />
+ </IconButton>
+ </Tooltip>
+ </HStack>
+ </HStack>
+ </VStack>
+ </AccordionPanel>
+ </AccordionItem>
+ </Td>
+ </Tr>
+ </Fragment>
+ );
+ })}
+ </Tbody>
+ </Table>
+ </Accordion>
+ <Table
+ orientation="vertical"
+ display={{ base: 'none', md: 'table' }}
+ {...props}
+ >
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ minW="120px"
+ pl={4}
+ pr={4}
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, filters, 'remark', onHostsFilterChange)}
+ >
+ <HStack>
+ <span>{t('remark')}</span>
+ <Sort sort={filters.sort} column="remark" />
+ </HStack>
+ </Th>
+ <Th
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, filters, 'address', onHostsFilterChange)}
+ >
+ <HStack>
+ <span>{t('hostsTable.address')}</span>
+ <Sort sort={filters.sort} column="address" />
+ </HStack>
+ </Th>
+ <Th
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, filters, 'port', onHostsFilterChange)}
+ >
+ <HStack>
+ <span>{t('hostsTable.port')}</span>
+ <Sort sort={filters.sort} column="port" />
+ </HStack>
+ </Th>
+ </Tr>
+ </Thead>
+ <Tbody overflowY="auto">
+ {useTable && hosts?.map((host, i) => {
+ return (
+ <Tr
+ key={i}
+ className={classNames('interactive', {
+ 'last-row': i === hosts.length - 1,
+ })}
+ onClick={() => onEditingHost(true, host)}
+ >
+ <Td minW="140px">
+ <div className="flex-status">
+ {host.remark}
+ </div>
+ </Td>
+ <Td width="400px" minW="150px">
+ {host.address}
+ </Td>
+ <Td width="350px" minW="150px">
+ {host.port}
+ </Td>
+ </Tr>
+ );
+ })}
+ {(hosts?.length === 0 || hosts === undefined) && (
+ <Tr>
+ <Td colSpan={5}>
+ <EmptySection
+ isFiltered={/* isFiltered */ false}
+ noObjectT="hostsTable.noHost"
+ noObjectMatchedT='hostsTable.noHostMatched'
+ createObjectT="addNode"
+ onCreateObject={onCreatingHost}
+ />
+ </Td>
+ </Tr>
+ )}
+ </Tbody>
+ </Table>
+ {/* TODO:
+ * <Pagination filters={filters} total={hosts === undefined ? 0 : hosts.length} onHostsFilterChange={onFilterChange} pageSizeManager={pageSizeManagers.hosts} />
+ */}
+ </Box>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/dialog/data-limit-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/data-limit-field.tsx
@@ -0,0 +1,39 @@
+import { FormControl, FormLabel } from '@chakra-ui/react';
+import { Input } from 'components/input';
+import { TFunction } from 'i18next';
+import { Controller } from 'react-hook-form';
+
+
+interface DataLimitFieldProps {
+ form: any;
+ disabled: boolean;
+ t: TFunction<'translation', undefined, 'translation'>;
+}
+
+export const DataLimitField = ({ form, disabled, t }: DataLimitFieldProps) => {
+ return (
+ <FormControl mb={'10px'}>
+ <FormLabel>{t('userDialog.dataLimit')}</FormLabel>
+ <Controller
+ control={form.control}
+ name="data_limit"
+ render={({ field }) => {
+ return (
+ <Input
+ endAdornment="GB"
+ type="number"
+ size="sm"
+ borderRadius="6px"
+ onChange={field.onChange}
+ disabled={disabled}
+ error={
+ form.formState.errors.data_limit?.message
+ }
+ value={field.value ? String(field.value) : ''}
+ />
+ );
+ }}
+ />
+ </FormControl>
+ )
+}
diff --git a/app/dashboard/src/pages/hosts/users/dialog/default-values.ts b/app/dashboard/src/pages/hosts/users/dialog/default-values.ts
@@ -0,0 +1,15 @@
+
+import { FormType } from './form-schema';
+
+export const getDefaultValues = (): FormType => {
+
+ return {
+ services: [],
+ data_limit: 0,
+ expire: null,
+ username: '',
+ data_limit_reset_strategy: 'no_reset',
+ status: 'active',
+ note: '',
+ };
+};
diff --git a/app/dashboard/src/pages/hosts/users/dialog/expire-date-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/expire-date-field.tsx
@@ -0,0 +1,91 @@
+import {
+ FormControl,
+ FormHelperText,
+ FormLabel,
+} from '@chakra-ui/react';
+import dayjs from 'dayjs';
+import { i18n, TFunction } from 'i18next';
+import ReactDatePicker from 'react-datepicker';
+import { Controller } from 'react-hook-form';
+import { relativeExpiryDate } from 'utils/dateFormatter';
+import { Input } from 'components/input';
+
+interface ExpireDateFieldProps {
+ form: any;
+ disabled: boolean;
+ t: TFunction<'translation', undefined, 'translation'>;
+ i18n: i18n;
+}
+
+export const ExpireDateField = ({ form, t, i18n, disabled }: ExpireDateFieldProps) => {
+ return (
+ <FormControl mb={'10px'}>
+ <FormLabel>{t('userDialog.expiryDate')}</FormLabel>
+ <Controller
+ name="expire"
+ control={form.control}
+ render={({ field }) => {
+ function createDateAsUTC(num: number) {
+ return dayjs(
+ dayjs(num * 1000).utc()
+ // .format("MMMM D, YYYY") // exception with: dayjs.locale(lng);
+ ).toDate();
+ }
+ const { status, time } = relativeExpiryDate(
+ field.value
+ );
+ return (
+ <>
+ <ReactDatePicker
+ locale={i18n.language.toLocaleLowerCase()}
+ dateFormat={t('dateFormat')}
+ minDate={new Date()}
+ selected={
+ field.value
+ ? createDateAsUTC(field.value)
+ : undefined
+ }
+ onChange={(date: Date) => {
+ field.onChange({
+ target: {
+ value: date
+ ? dayjs(
+ dayjs(date)
+ .set('hour', 23)
+ .set('minute', 59)
+ .set('second', 59)
+ )
+ .utc()
+ .valueOf() / 1000
+ : 0,
+ name: 'expire',
+ },
+ });
+ }}
+ customInput={
+ <Input
+ size="sm"
+ type="text"
+ borderRadius="6px"
+ clearable
+ disabled={disabled}
+ error={
+ form.formState.errors.expire?.message
+ }
+ />
+ }
+ />
+ {field.value ? (
+ <FormHelperText>
+ {t(status, { time: time })}
+ </FormHelperText>
+ ) : (
+ ''
+ )}
+ </>
+ );
+ }}
+ />
+ </FormControl>
+ )
+}
diff --git a/app/dashboard/src/pages/hosts/users/dialog/form-schema.ts b/app/dashboard/src/pages/hosts/users/dialog/form-schema.ts
@@ -0,0 +1,22 @@
+
+import { z } from 'zod';
+import { UserCreate } from 'types';
+
+export type FormType = Pick<UserCreate, keyof UserCreate>;
+export const schema = z.object({
+ username: z.string().min(1, { message: 'Username is required' }),
+ note: z.string().nullable(),
+ data_limit: z
+ .string()
+ .min(0, 'The minimum number is 0')
+ .or(z.number())
+ .nullable()
+ .transform((str) => {
+ if (str) return Number((parseFloat(String(str)) * 1073741824).toFixed(5));
+ return 0;
+ }),
+ expire: z.number().nullable(),
+ data_limit_reset_strategy: z.string(),
+ status: z.string(),
+ services: z.array(z.number()).nonempty({ message: 'At least one service is required' }),
+});
diff --git a/app/dashboard/src/pages/hosts/users/dialog/icons.ts b/app/dashboard/src/pages/hosts/users/dialog/icons.ts
@@ -0,0 +1,11 @@
+
+import { chakra } from '@chakra-ui/react';
+import {
+ ChartPieIcon,
+} from '@heroicons/react/24/outline';
+export const UserUsageIcon = chakra(ChartPieIcon, {
+ baseStyle: {
+ w: 5,
+ h: 5,
+ },
+});
diff --git a/app/dashboard/src/pages/hosts/users/dialog/index.tsx b/app/dashboard/src/pages/hosts/users/dialog/index.tsx
@@ -0,0 +1,363 @@
+import {
+ Alert,
+ Button,
+ Tooltip,
+ IconButton,
+ Spinner,
+ AlertIcon,
+ Box,
+ Collapse,
+ Flex,
+ Grid,
+ GridItem,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ Modal,
+ VStack,
+ useColorMode,
+ useToast,
+ ModalOverlay,
+ HStack,
+} from '@chakra-ui/react';
+import { zodResolver } from '@hookform/resolvers/zod';
+import dayjs from 'dayjs';
+import { FC, useEffect, useState } from 'react';
+import ReactApexChart from 'react-apexcharts';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import {
+ User,
+ FilterUsageType,
+ UserCreate,
+ Service
+} from 'types';
+import { UsageFilter, createUsageConfig } from 'components/usage-filter';
+import {
+ AddIcon,
+ EditIcon,
+ DialogModalHeader,
+ DialogModalFooter,
+ DeleteIcon
+} from 'components/dialog';
+import { UserUsageIcon } from './icons';
+import { getDefaultValues } from './default-values';
+import { schema, FormType } from './form-schema';
+import { ServicesField } from './services-field';
+import { UsernameField } from './username-field';
+import { DataLimitField } from './data-limit-field';
+import { PeriodicUsageReset } from './periodic-usage-reset';
+import { ExpireDateField } from './expire-date-field';
+import { NoteField } from './note-field';
+import { useUsers, useServices } from 'stores';
+
+const formatUser = (user: User): FormType => {
+ const services: number[] = user.services.map((service: number | Service): number => {
+ return (typeof service !== 'number') ? service.id : service;
+ });
+ return {
+ ...user,
+ services,
+ data_limit: user.data_limit
+ ? Number((user.data_limit / 1073741824).toFixed(5))
+ : user.data_limit,
+ };
+};
+
+export type UserDialogProps = {};
+
+export const UserDialog: FC<UserDialogProps> = () => {
+ const {
+ editingUser,
+ isCreatingNewUser,
+ onCreateUser,
+ editUser,
+ fetchUserUsage,
+ onEditingUser,
+ createUser,
+ onDeletingUser,
+ } = useUsers();
+ const { refetchServices, services } = useServices();
+ const isEditing = !!editingUser;
+ const isOpen = isCreatingNewUser || isEditing;
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>('');
+ const toast = useToast();
+ const { t, i18n } = useTranslation();
+
+ const { colorMode } = useColorMode();
+
+ const [usageVisible, setUsageVisible] = useState(false);
+ const handleUsageToggle = () => {
+ setUsageVisible((current) => !current);
+ };
+
+ const form = useForm<FormType>({
+ defaultValues: getDefaultValues(),
+ resolver: zodResolver(schema),
+ });
+
+ const [dataLimit] = useWatch({
+ control: form.control,
+ name: ['data_limit'],
+ });
+
+ const usageTitle = t('userDialog.total');
+ const [usage, setUsage] = useState(createUsageConfig(colorMode, usageTitle));
+ const [usageFilter, setUsageFilter] = useState('1m');
+ const fetchUsageWithFilter = (query: FilterUsageType) => {
+ fetchUserUsage(editingUser!, query).then((data: any) => {
+ const labels = [];
+ const series = [];
+ for (const key in data.usages) {
+ series.push(data.usages[key].used_traffic);
+ labels.push(data.usages[key].node_name);
+ }
+ setUsage(createUsageConfig(colorMode, usageTitle, series, labels));
+ });
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ refetchServices();
+ }
+ }, [isOpen]);
+
+
+ useEffect(() => {
+ if (editingUser) {
+ form.reset(formatUser(editingUser));
+
+ fetchUsageWithFilter({
+ start: dayjs().utc().subtract(30, 'day').format('YYYY-MM-DDTHH:00:00'),
+ });
+ }
+ }, [editingUser]);
+
+ const submit = async (values: FormType) => {
+ setLoading(true);
+ const methods = { edited: editUser, created: createUser };
+ const method = isEditing ? 'edited' : 'created';
+ setError(null);
+ const { services, username, ...rest } = values;
+
+ let body: UserCreate = {
+ ...rest,
+ username,
+ services,
+ data_limit: values.data_limit,
+ data_limit_reset_strategy:
+ values.data_limit && values.data_limit > 0
+ ? values.data_limit_reset_strategy
+ : 'no_reset',
+ status:
+ values.status === 'active' || values.status === 'disabled'
+ ? values.status
+ : 'active',
+ };
+
+ await methods[method](body)
+ .then(() => {
+ toast({
+ title: t(
+ isEditing ? 'userDialog.userEdited' : 'userDialog.userCreated',
+ { username: values.username }
+ ),
+ status: 'success',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ 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 'services' | 'username' | 'data_limit' | 'expire',
+ {
+ type: 'custom',
+ message: err.response._data.detail[key],
+ }
+ );
+ });
+ }
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ };
+
+ const onClose = () => {
+ form.reset(getDefaultValues());
+ onCreateUser(false);
+ onEditingUser(null);
+ setError(null);
+ setUsageVisible(false);
+ setUsageFilter('1m');
+ };
+
+ const handleResetUsage = () => {
+ useUsers.setState({ resetUsageUser: editingUser });
+ };
+
+ const handleRevokeSubscription = () => {
+ useUsers.setState({ revokeSubscriptionUser: editingUser });
+ };
+
+ const disabled = loading;
+
+ return (
+ <Modal isOpen={isOpen} onClose={onClose} size="2xl">
+ <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
+ <FormProvider {...form} formState={form.formState}>
+ <ModalContent mx="3">
+ <form onSubmit={form.handleSubmit(submit)}>
+ <DialogModalHeader HeaderIcon={isEditing ? EditIcon : AddIcon} title={isEditing ? t('userDialog.editUserTitle') : t('createNewUser')} />
+ <ModalCloseButton mt={3} disabled={disabled} />
+ <ModalBody>
+ <Grid
+ templateColumns={{
+ base: 'repeat(1, 1fr)',
+ md: 'repeat(2, 1fr)',
+ }}
+ gap={3}
+ >
+ <GridItem>
+ <VStack justifyContent="space-between">
+ <Flex
+ flexDirection="column"
+ gridAutoRows="min-content"
+ w="full"
+ >
+ <UsernameField form={form} disabled={disabled} isEditing={isEditing} t={t} />
+ <DataLimitField form={form} disabled={disabled} t={t} />
+
+ <Collapse
+ in={!!(dataLimit && dataLimit > 0)}
+ animateOpacity
+ style={{ width: '100%' }}
+ >
+ <PeriodicUsageReset form={form} t={t} />
+ </Collapse>
+ <ExpireDateField form={form} t={t} i18n={i18n} disabled={disabled} />
+ <NoteField form={form} t={t} />
+ </Flex>
+ {error && (
+ <Alert
+ status="error"
+ display={{ base: 'none', md: 'flex' }}
+ >
+ <AlertIcon />
+ {error}
+ </Alert>
+ )}
+ </VStack>
+ </GridItem>
+
+ <GridItem>
+ <ServicesField t={t} services={services} form={form} />
+ </GridItem>
+
+ {isEditing && usageVisible && (
+ <GridItem pt={6} colSpan={{ base: 1, md: 2 }}>
+ <VStack gap={4}>
+ <UsageFilter
+ defaultValue={usageFilter}
+ onChange={(filter, query) => {
+ setUsageFilter(filter);
+ fetchUsageWithFilter(query);
+ }}
+ />
+ <Box
+ width={{ base: '100%', md: '70%' }}
+ justifySelf="center"
+ >
+ <ReactApexChart
+ options={usage.options}
+ series={usage.series}
+ type="donut"
+ />
+ </Box>
+ </VStack>
+ </GridItem>
+ )}
+ </Grid>
+
+ {error && (
+ <Alert
+ mt="3"
+ status="error"
+ display={{ base: 'flex', md: 'none' }}
+ >
+ <AlertIcon />
+ {error}
+ </Alert>
+ )}
+ </ModalBody>
+ <DialogModalFooter>
+ <HStack
+ justifyContent="flex-start"
+ w={{
+ base: 'full',
+ sm: 'unset',
+ }}
+ >
+ {isEditing && editingUser !== null && (
+ <>
+ <Tooltip label={t('delete')} placement="top">
+ <IconButton
+ aria-label="Delete"
+ size="sm"
+ onClick={() => {
+ onDeletingUser(editingUser);
+ onClose();
+ }}
+ >
+ <DeleteIcon />
+ </IconButton>
+ </Tooltip>
+ <Tooltip label={t('userDialog.usage')} placement="top">
+ <IconButton
+ aria-label="usage"
+ size="sm"
+ onClick={handleUsageToggle}
+ >
+ <UserUsageIcon />
+ </IconButton>
+ </Tooltip>
+ <Button onClick={handleResetUsage} size="sm">
+ {t('userDialog.resetUsage')}
+ </Button>
+ <Button onClick={handleRevokeSubscription} size="sm">
+ {t('userDialog.revokeSubscription')}
+ </Button>
+ </>
+ )}
+ </HStack>
+ <HStack
+ w="full"
+ maxW={{ md: '50%', base: 'full' }}
+ justify="end"
+ >
+ <Button
+ type="submit"
+ size="sm"
+ px="8"
+ colorScheme="primary"
+ leftIcon={loading ? <Spinner size="xs" /> : undefined}
+ disabled={false}
+ >
+ {isEditing ? t('userDialog.editUser') : t('createUser')}
+ </Button>
+ </HStack>
+ </DialogModalFooter>
+ </form>
+ </ModalContent>
+ </FormProvider>
+ </Modal >
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/dialog/note-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/note-field.tsx
@@ -0,0 +1,22 @@
+import { FormControl, FormLabel, Textarea, FormErrorMessage } from '@chakra-ui/react';
+import { TFunction } from 'i18next';
+
+interface NoteFieldProps {
+ form: any;
+ t: TFunction<'translation', undefined, 'translation'>;
+}
+
+export const NoteField = ({ t, form }: NoteFieldProps) => {
+ return (
+ <FormControl
+ mb={'10px'}
+ isInvalid={!!form.formState.errors.note}
+ >
+ <FormLabel>{t('userDialog.note')}</FormLabel>
+ <Textarea {...form.register('note')} />
+ <FormErrorMessage>
+ {form.formState.errors?.note?.message}
+ </FormErrorMessage>
+ </FormControl>
+ )
+}
diff --git a/app/dashboard/src/pages/hosts/users/dialog/periodic-usage-reset.tsx b/app/dashboard/src/pages/hosts/users/dialog/periodic-usage-reset.tsx
@@ -0,0 +1,39 @@
+import { FormControl, FormLabel, Select } from '@chakra-ui/react';
+import { Controller } from 'react-hook-form';
+import { resetStrategy } from 'constants/Settings';
+import { TFunction } from 'i18next';
+
+
+interface PreiodicUsageResetProps {
+ form: any;
+ t: TFunction<'translation', undefined, 'translation'>;
+}
+
+export const PeriodicUsageReset = ({ form, t }: PreiodicUsageResetProps) => {
+ return (
+ <FormControl height="66px">
+ <FormLabel>
+ {t('userDialog.periodicUsageReset')}
+ </FormLabel>
+ <Controller
+ control={form.control}
+ name="data_limit_reset_strategy"
+ render={({ field }) => {
+ return (
+ <Select size="sm" {...field}>
+ {resetStrategy.map((s) => {
+ return (
+ <option key={s.value} value={s.value}>
+ {t(
+ 'userDialog.resetStrategy' + s.title
+ )}
+ </option>
+ );
+ })}
+ </Select>
+ );
+ }}
+ />
+ </FormControl>
+ )
+}
diff --git a/app/dashboard/src/pages/hosts/users/dialog/services-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/services-field.tsx
@@ -0,0 +1,60 @@
+import { FormControl, FormLabel, FormErrorMessage, Center, Spinner, Text, Flex, chakra } from '@chakra-ui/react';
+import { UsersIcon, ServerStackIcon } from '@heroicons/react/24/outline';
+import { IconBaseStyle } from 'constants/IconBaseStyle';
+import { TFunction } from 'i18next';
+import { Controller } from 'react-hook-form';
+import { Service } from 'types';
+import { SystemPropertyStatus } from 'components/system-property-status';
+import { ServicesSelectList } from './services-select-list';
+
+const UsersServiceIcon = chakra(UsersIcon, IconBaseStyle);
+const InboundsServiceIcon = chakra(ServerStackIcon, IconBaseStyle);
+
+interface ServicesFieldProps {
+ form: any;
+ services: Service[];
+ t: TFunction<'translation', undefined, 'translation'>;
+}
+
+export const ServicesField = ({ form, t, services }: ServicesFieldProps) => {
+ return services.length === 0 ? (
+ <Center><Spinner /></Center>
+ ) : (
+ <FormControl
+ isInvalid={
+ !!form.formState.errors.services?.message
+ }
+ >
+ <FormLabel>{t('userDialog.services')}</FormLabel>
+ <Controller
+ control={form.control}
+ name="services"
+ render={() => {
+ return (
+ <ServicesSelectList
+ list={services}
+ renderCheckboxContent={(service) => (
+ <Flex alignItems="center" justifyContent="space-between" flexDir="row" w="full">
+ <Text fontSize="sm" color="gray.700" _dark={{ color: 'gray.100' }} fontWeight="medium">
+ {service.name}
+ </Text>
+ <Flex flexDirection="row" alignItems="center">
+ <SystemPropertyStatus value={service.users.length} StatusIcon={UsersServiceIcon} />
+ <Text color="primary.600" _dark={{ color: 'gray.100' }} mx="8px" fontSize="sm">/</Text>
+ <SystemPropertyStatus value={service.inbounds.length} StatusIcon={InboundsServiceIcon} />
+ </Flex>
+ </Flex>
+ )}
+ />
+ );
+ }}
+ />
+ <FormErrorMessage>
+ {t(
+ form.formState.errors.services
+ ?.message as string
+ )}
+ </FormErrorMessage>
+ </FormControl>
+ );
+}
diff --git a/app/dashboard/src/pages/hosts/users/dialog/services-select-list.tsx b/app/dashboard/src/pages/hosts/users/dialog/services-select-list.tsx
@@ -0,0 +1,55 @@
+
+import {
+ Box,
+ Flex,
+ Stack,
+} from '@chakra-ui/react';
+import { ListCheckBox } from 'components/list-check-box';
+import { forwardRef, ReactNode } from 'react';
+import {
+ useFormContext,
+} from 'react-hook-form';
+import { Service } from 'types';
+
+interface ServicesSelectListProps {
+ list: Service[];
+ renderCheckboxContent: (service: Service) => ReactNode;
+}
+
+export const ServicesSelectList = forwardRef<HTMLDivElement, ServicesSelectListProps>(
+ function({ list, renderCheckboxContent }, ref) {
+ const form = useFormContext();
+
+ const handleServiceToggle = (serviceId: number) => {
+ const currentServices = form.getValues('services');
+ const isSelected = currentServices.includes(serviceId);
+
+ let updatedServices;
+
+ if (isSelected) {
+ updatedServices = currentServices.filter((id: number) => id !== serviceId);
+ } else {
+ updatedServices = [...currentServices, serviceId];
+ }
+
+ form.setValue('services', updatedServices, { shouldValidate: true });
+ };
+
+ return (
+ <Stack spacing={3} ref={ref}>
+ {list.map((service: Service) => (
+ <Box key={service.id}>
+ <Flex align="center">
+ <ListCheckBox
+ isChecked={form.getValues('services').includes(service.id)}
+ onChange={() => handleServiceToggle(service.id)}
+ value={String(service.id)}
+ renderContent={() => renderCheckboxContent(service)}
+ />
+ </Flex>
+ </Box>
+ ))}
+ </Stack>
+ );
+ }
+);
diff --git a/app/dashboard/src/pages/hosts/users/dialog/username-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/username-field.tsx
@@ -0,0 +1,63 @@
+import { HStack, Input, Tooltip, Box, Switch, FormControl, FormLabel } from '@chakra-ui/react';
+import { TFunction } from 'i18next';
+import { Controller } from 'react-hook-form';
+
+interface UsernameFieldProps {
+ form: any;
+ isEditing: boolean;
+ t: TFunction<'translation', undefined, 'translation'>;
+ disabled: boolean;
+}
+
+export const UsernameField = ({ form, isEditing, t, disabled }: UsernameFieldProps) => {
+ return (
+ <FormControl mb={'10px'}>
+ <FormLabel>{t('username')}</FormLabel>
+ <HStack>
+ <Input
+ size="sm"
+ type="text"
+ borderRadius="6px"
+ error={form.formState.errors.username?.message}
+ disabled={disabled || isEditing}
+ {...form.register('username')}
+ />
+ {isEditing && (
+ <HStack px={1}>
+ <Controller
+ name="status"
+ control={form.control}
+ render={({ field }) => {
+ return (
+ <Tooltip
+ placement="top"
+ label={'status: ' + field.value}
+ textTransform="capitalize"
+ >
+ <Box>
+ <Switch
+ colorScheme="primary"
+ disabled={
+ field.value !== 'active' &&
+ field.value !== 'disabled'
+ }
+ isChecked={field.value === 'active'}
+ onChange={(e) => {
+ if (e.target.checked) {
+ field.onChange('active');
+ } else {
+ field.onChange('disabled');
+ }
+ }}
+ />
+ </Box>
+ </Tooltip>
+ );
+ }}
+ />
+ </HStack>
+ )}
+ </HStack>
+ </FormControl>
+ )
+}
diff --git a/app/dashboard/src/pages/hosts/users/filters.tsx b/app/dashboard/src/pages/hosts/users/filters.tsx
@@ -0,0 +1,144 @@
+import {
+ BoxProps,
+ Button,
+ chakra,
+ Grid,
+ GridItem,
+ HStack,
+ IconButton,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ InputRightElement,
+ Spinner,
+} from '@chakra-ui/react';
+import {
+ ArrowPathIcon,
+ MagnifyingGlassIcon,
+ XMarkIcon,
+} from '@heroicons/react/24/outline';
+import classNames from 'classnames';
+import { useDashboard, useUsers } from 'stores';
+import debounce from 'lodash.debounce';
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const iconProps = {
+ baseStyle: {
+ w: 4,
+ h: 4,
+ },
+};
+
+const SearchIcon = chakra(MagnifyingGlassIcon, iconProps);
+const ClearIcon = chakra(XMarkIcon, iconProps);
+export const ReloadIcon = chakra(ArrowPathIcon, iconProps);
+
+export type FilterProps = {} & BoxProps;
+const setSearchField = debounce((username: string) => {
+ useUsers.getState().onFilterChange({
+ ...useUsers.getState().usersFilters,
+ offset: 0,
+ username,
+ });
+}, 300);
+
+export const UsersFilters: FC<FilterProps> = ({ ...props }) => {
+ const { loading, } = useDashboard();
+ const { usersFilters: filters, onFilterChange, refetchUsers, onCreateUser, onResetAllUsage } = useUsers();
+ const { t } = useTranslation();
+ const [search, setSearch] = useState('');
+ const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ setSearch(e.target.value);
+ setSearchField(e.target.value);
+ };
+ const clear = () => {
+ setSearch('');
+ onFilterChange({
+ ...filters,
+ offset: 0,
+ username: '',
+ });
+ };
+ return (
+ <Grid
+ id="users-filters"
+ templateColumns={{
+ lg: 'repeat(3, 1fr)',
+ md: 'repeat(4, 1fr)',
+ base: 'repeat(1, 1fr)',
+ }}
+ position="sticky"
+ top={0}
+ mx="-6"
+ px="6"
+ rowGap={4}
+ gap={{
+ lg: 4,
+ base: 0,
+ }}
+ bg="var(--chakra-colors-chakra-body-bg)"
+ py={4}
+ zIndex="docked"
+ {...props}
+ >
+ <GridItem colSpan={{ base: 1, md: 2, lg: 1 }} order={{ base: 2, md: 1 }}>
+ <InputGroup>
+ <InputLeftElement pointerEvents="none" children={<SearchIcon />} />
+ <Input
+ placeholder={t('search')}
+ value={search}
+ borderColor="light-border"
+ onChange={onChange}
+ />
+ <InputRightElement>
+ {loading && <Spinner size="xs" />}
+ {filters.username && filters.username.length > 0 && (
+ <IconButton
+ onClick={clear}
+ aria-label="clear"
+ size="xs"
+ variant="ghost"
+ >
+ <ClearIcon />
+ </IconButton>
+ )}
+ </InputRightElement>
+ </InputGroup>
+ </GridItem>
+ <GridItem colSpan={2} order={{ base: 1, md: 2 }}>
+ <HStack justifyContent="flex-end" alignItems="center" h="full">
+ <IconButton
+ aria-label="refresh users"
+ disabled={loading}
+ onClick={refetchUsers}
+ size="sm"
+ variant="outline"
+ >
+ <ReloadIcon
+ className={classNames({
+ 'animate-spin': loading,
+ })}
+ />
+ </IconButton>
+ <Button
+ colorScheme="red"
+ size="sm"
+ onClick={() => onResetAllUsage(true)}
+ px={5}
+ >
+ {t('resetAllUsage')}
+ </Button>
+ <Button
+ colorScheme="primary"
+ size="sm"
+ onClick={() => onCreateUser(true)}
+ px={5}
+ >
+ {t('createUser')}
+ </Button>
+ </HStack>
+ </GridItem>
+ </Grid>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/index.tsx b/app/dashboard/src/pages/hosts/users/index.tsx
@@ -0,0 +1,29 @@
+import { UserDialog } from './dialog';
+import { UsersTable } from './table';
+import { UsersFilters } from './filters';
+import {
+ ResetAllUsageModal,
+ DeleteUserModal,
+ QRCodeDialog,
+ ResetUserUsageModal,
+ RevokeSubscriptionModal
+} from './modals';
+import { pages } from 'stores';
+import Page from 'components/page';
+
+const UsersPage = () => {
+ return (
+ <Page page={pages.users}>
+ <UsersFilters />
+ <UsersTable />
+ <UserDialog />
+ <QRCodeDialog />
+ <DeleteUserModal />
+ <RevokeSubscriptionModal />
+ <ResetAllUsageModal />
+ <ResetUserUsageModal />
+ </Page>
+ )
+}
+
+export default UsersPage;
diff --git a/app/dashboard/src/pages/hosts/users/modals/delete-modal.tsx b/app/dashboard/src/pages/hosts/users/modals/delete-modal.tsx
@@ -0,0 +1,49 @@
+import {
+ useToast,
+} from '@chakra-ui/react';
+import { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useUsers } from 'stores';
+import { DeleteModal } from 'components/delete-modal';
+
+type DeleteUserModalProps = {
+ deleteCallback?: () => void;
+};
+
+export const DeleteUserModal: FC<DeleteUserModalProps> = () => {
+ const [loading, setLoading] = useState(false);
+ const { deletingUser, onDeletingUser, deleteUser } = useUsers();
+ const { t } = useTranslation();
+ const toast = useToast();
+ const onClose = () => {
+ onDeletingUser(null);
+ };
+ const onDelete = () => {
+ if (deletingUser) {
+ setLoading(true);
+ deleteUser(deletingUser)
+ .then(() => {
+ toast({
+ title: t('deleteUser.deleteSuccess', { username: deletingUser.username }),
+ status: 'success',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .then(onClose)
+ .finally(setLoading.bind(null, false));
+ }
+ };
+ return (
+ <DeleteModal
+ onDelete={onDelete}
+ loading={loading}
+ isOpen={!!deletingUser}
+ onClose={onClose}
+ deletingObjectName={deletingUser?.username}
+ deleteObjectPromptT={'deleteUser.prompt'}
+ deleteObjectTitleT={'deleteUser.title'}
+ />
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/modals/index.tsx b/app/dashboard/src/pages/hosts/users/modals/index.tsx
@@ -0,0 +1,5 @@
+export * from './reset-all-usage-modal';
+export * from './delete-modal';
+export * from './qrcode-modals';
+export * from './reset-user-usage-modal';
+export * from './revoke-subscription-modal';
diff --git a/app/dashboard/src/pages/hosts/users/modals/qrcode-modals.tsx b/app/dashboard/src/pages/hosts/users/modals/qrcode-modals.tsx
@@ -0,0 +1,175 @@
+import {
+ Box,
+ chakra,
+ HStack,
+ IconButton,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+ ModalOverlay,
+ Text,
+ VStack,
+} from '@chakra-ui/react';
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ QrCodeIcon,
+} from '@heroicons/react/24/outline';
+import { QRCodeCanvas } from 'qrcode.react';
+import { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import Slider from 'react-slick';
+import 'slick-carousel/slick/slick-theme.css';
+import 'slick-carousel/slick/slick.css';
+import { Icon } from 'components/icon';
+import { useUsers } from 'stores';
+
+const QRCode = chakra(QRCodeCanvas);
+const NextIcon = chakra(ChevronRightIcon, {
+ baseStyle: {
+ w: 6,
+ h: 6,
+ color: 'gray.600',
+ _dark: {
+ color: 'white',
+ },
+ },
+});
+const PrevIcon = chakra(ChevronLeftIcon, {
+ baseStyle: {
+ w: 6,
+ h: 6,
+ color: 'gray.600',
+ _dark: {
+ color: 'white',
+ },
+ },
+});
+const QRIcon = chakra(QrCodeIcon, {
+ baseStyle: {
+ w: 5,
+ h: 5,
+ },
+});
+
+export const QRCodeDialog: FC = () => {
+ const { QRcodeLinks, setQRCode, setSubLink, subscribeUrl } = useUsers();
+ const isOpen = QRcodeLinks !== null;
+ const [index, setIndex] = useState(0);
+ const { t } = useTranslation();
+ const onClose = () => {
+ setQRCode(null);
+ setSubLink(null);
+ };
+
+ const subscribeQrLink = String(subscribeUrl).startsWith('/')
+ ? window.location.origin + subscribeUrl
+ : String(subscribeUrl);
+
+ return (
+ <Modal isOpen={isOpen} onClose={onClose}>
+ <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
+ <ModalContent mx="3" w="fit-content" maxW="3xl">
+ <ModalHeader pt={6}>
+ <Icon color="primary">
+ <QRIcon color="white" />
+ </Icon>
+ </ModalHeader>
+ <ModalCloseButton mt={3} />
+ {QRcodeLinks && (
+ <ModalBody
+ gap={{
+ base: '20px',
+ lg: '50px',
+ }}
+ pr={{
+ lg: '60px',
+ }}
+ px={{
+ base: '50px',
+ }}
+ display="flex"
+ justifyContent="center"
+ flexDirection={{
+ base: 'column',
+ lg: 'row',
+ }}
+ >
+ {subscribeUrl && (
+ <VStack>
+ <QRCode
+ mx="auto"
+ size={300}
+ p="2"
+ level={'L'}
+ includeMargin={false}
+ value={subscribeQrLink}
+ bg="white"
+ />
+ <Text display="block" textAlign="center" pb={3} mt={1}>
+ {t('qrcodeDialog.sublink')}
+ </Text>
+ </VStack>
+ )}
+ <Box w="300px">
+ <Slider
+ centerPadding="0px"
+ centerMode={true}
+ slidesToShow={1}
+ slidesToScroll={1}
+ dots={false}
+ afterChange={setIndex}
+ onInit={() => setIndex(0)}
+ nextArrow={
+ <IconButton
+ size="sm"
+ position="absolute"
+ display="flex !important"
+ _before={{ content: '""' }}
+ aria-label="next"
+ mr="-4"
+ >
+ <NextIcon />
+ </IconButton>
+ }
+ prevArrow={
+ <IconButton
+ size="sm"
+ position="absolute"
+ display="flex !important"
+ _before={{ content: '""' }}
+ aria-label="prev"
+ ml="-4"
+ >
+ <PrevIcon />
+ </IconButton>
+ }
+ >
+ {QRcodeLinks.map((link, i) => {
+ return (
+ <HStack key={i}>
+ <QRCode
+ mx="auto"
+ size={300}
+ p="2"
+ level={'L'}
+ includeMargin={false}
+ value={link}
+ bg="white"
+ />
+ </HStack>
+ );
+ })}
+ </Slider>
+ <Text display="block" textAlign="center" pb={3} mt={1}>
+ {index + 1} / {QRcodeLinks.length}
+ </Text>
+ </Box>
+ </ModalBody>
+ )}
+ </ModalContent>
+ </Modal>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/modals/reset-all-usage-modal.tsx b/app/dashboard/src/pages/hosts/users/modals/reset-all-usage-modal.tsx
@@ -0,0 +1,105 @@
+import {
+ Button,
+ chakra,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Spinner,
+ Text,
+ useToast,
+} from '@chakra-ui/react';
+import { FC, useState } from 'react';
+import { DocumentMinusIcon } from '@heroicons/react/24/outline';
+import { Icon } from 'components/icon';
+import { useTranslation } from 'react-i18next';
+import { useUsers } from 'stores';
+
+export const ResetIcon = chakra(DocumentMinusIcon, {
+ baseStyle: {
+ w: 5,
+ h: 5,
+ },
+});
+
+type DeleteUserModalProps = {};
+
+export const ResetAllUsageModal: FC<DeleteUserModalProps> = () => {
+ const [loading, setLoading] = useState(false);
+ const { isResetingAllUsage, onResetAllUsage, resetAllUsage } = useUsers();
+ const { t } = useTranslation();
+ const toast = useToast();
+ const onClose = () => {
+ onResetAllUsage(false);
+ };
+ const onReset = () => {
+ setLoading(true);
+ resetAllUsage()
+ .then(() => {
+ toast({
+ title: t('resetAllUsage.success'),
+ status: 'success',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .catch(() => {
+ toast({
+ title: t('resetAllUsage.error'),
+ status: 'error',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ };
+ return (
+ <Modal isCentered isOpen={isResetingAllUsage} onClose={onClose} size="sm">
+ <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
+ <ModalContent mx="3">
+ <ModalHeader pt={6}>
+ <Icon color="red">
+ <ResetIcon />
+ </Icon>
+ </ModalHeader>
+ <ModalCloseButton mt={3} />
+ <ModalBody>
+ <Text fontWeight="semibold" fontSize="lg">
+ {t('resetAllUsage.title')}
+ </Text>
+ {isResetingAllUsage && (
+ <Text
+ mt={1}
+ fontSize="sm"
+ _dark={{ color: 'gray.400' }}
+ color="gray.600"
+ >
+ {t('resetAllUsage.prompt')}
+ </Text>
+ )}
+ </ModalBody>
+ <ModalFooter display="flex">
+ <Button size="sm" onClick={onClose} mr={3} w="full" variant="outline">
+ {t('cancel')}
+ </Button>
+ <Button
+ size="sm"
+ w="full"
+ colorScheme="red"
+ onClick={onReset}
+ leftIcon={loading ? <Spinner size="xs" /> : undefined}
+ >
+ {t('reset')}
+ </Button>
+ </ModalFooter>
+ </ModalContent>
+ </Modal>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/modals/reset-user-usage-modal.tsx b/app/dashboard/src/pages/hosts/users/modals/reset-user-usage-modal.tsx
@@ -0,0 +1,110 @@
+import {
+ Button,
+ chakra,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Spinner,
+ Text,
+ useToast,
+} from '@chakra-ui/react';
+import { FC, useState } from 'react';
+import { ArrowPathIcon } from '@heroicons/react/24/outline';
+import { Icon } from 'components/icon';
+import { useTranslation, Trans } from 'react-i18next';
+import { useUsers } from 'stores';
+
+const ResetIcon = chakra(ArrowPathIcon, {
+ baseStyle: {
+ w: 5,
+ h: 5,
+ },
+});
+
+type DeleteUserModalProps = {};
+
+export const ResetUserUsageModal: FC<DeleteUserModalProps> = () => {
+ const [loading, setLoading] = useState(false);
+ const { resetUsageUser: user, resetDataUsage } = useUsers();
+ const { t } = useTranslation();
+ const toast = useToast();
+ const onClose = () => {
+ useUsers.setState({ resetUsageUser: null });
+ };
+ const onReset = () => {
+ if (user) {
+ setLoading(true);
+ resetDataUsage(user)
+ .then(() => {
+ toast({
+ title: t('resetUserUsage.success', { username: user.username }),
+ status: 'success',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .catch(() => {
+ toast({
+ title: t('resetUserUsage.error'),
+ status: 'error',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+ };
+ return (
+ <Modal isCentered isOpen={!!user} onClose={onClose} size="sm">
+ <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
+ <ModalContent mx="3">
+ <ModalHeader pt={6}>
+ <Icon color="blue">
+ <ResetIcon />
+ </Icon>
+ </ModalHeader>
+ <ModalCloseButton mt={3} />
+ <ModalBody>
+ <Text fontWeight="semibold" fontSize="lg">
+ {t('resetUserUsage.title')}
+ </Text>
+ {user && (
+ <Text
+ mt={1}
+ fontSize="sm"
+ _dark={{ color: 'gray.400' }}
+ color="gray.600"
+ >
+ <Trans
+ components={{ b: <b /> }}>
+ {t('resetUserUsage.prompt', { username: user.username })}
+ </Trans>
+ </Text>
+ )}
+ </ModalBody>
+ <ModalFooter display="flex">
+ <Button size="sm" onClick={onClose} mr={3} w="full" variant="outline">
+ {t('cancel')}
+ </Button>
+ <Button
+ size="sm"
+ w="full"
+ colorScheme="blue"
+ onClick={onReset}
+ leftIcon={loading ? <Spinner size="xs" /> : undefined}
+ >
+ {t('reset')}
+ </Button>
+ </ModalFooter>
+ </ModalContent>
+ </Modal>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/modals/revoke-subscription-modal.tsx b/app/dashboard/src/pages/hosts/users/modals/revoke-subscription-modal.tsx
@@ -0,0 +1,109 @@
+import {
+ Button,
+ chakra,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Spinner,
+ Text,
+ useToast,
+} from '@chakra-ui/react';
+import { ArrowPathIcon } from '@heroicons/react/24/outline';
+import { useUsers } from 'stores';
+import { FC, useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import { Icon } from 'components/icon';
+
+const ResetIcon = chakra(ArrowPathIcon, {
+ baseStyle: {
+ w: 5,
+ h: 5,
+ },
+});
+
+export type RevokeSubscriptionModalProps = {};
+
+export const RevokeSubscriptionModal: FC<RevokeSubscriptionModalProps> = () => {
+ const [loading, setLoading] = useState(false);
+ const { revokeSubscriptionUser: user, revokeSubscription } = useUsers();
+ const { t } = useTranslation();
+ const toast = useToast();
+ const onClose = () => {
+ useUsers.setState({ revokeSubscriptionUser: null });
+ };
+ const onReset = () => {
+ if (user) {
+ setLoading(true);
+ revokeSubscription(user)
+ .then(() => {
+ toast({
+ title: t('revokeUserSub.success', { username: user.username }),
+ status: 'success',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .catch(() => {
+ toast({
+ title: t('revokeUserSub.error'),
+ status: 'error',
+ isClosable: true,
+ position: 'top',
+ duration: 3000,
+ });
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+ };
+ return (
+ <Modal isCentered isOpen={!!user} onClose={onClose} size="sm">
+ <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
+ <ModalContent mx="3">
+ <ModalHeader pt={6}>
+ <Icon color="blue">
+ <ResetIcon />
+ </Icon>
+ </ModalHeader>
+ <ModalCloseButton mt={3} />
+ <ModalBody>
+ <Text fontWeight="semibold" fontSize="lg">
+ {t('revokeUserSub.title')}
+ </Text>
+ {user && (
+ <Text
+ mt={1}
+ fontSize="sm"
+ _dark={{ color: 'gray.400' }}
+ color="gray.600"
+ >
+ <Trans components={{ b: <b /> }}>
+ {t('revokeUserSub.prompt', { username: user.username })}
+ </Trans>
+ </Text>
+ )}
+ </ModalBody>
+ <ModalFooter display="flex">
+ <Button size="sm" onClick={onClose} mr={3} w="full" variant="outline">
+ {t('cancel')}
+ </Button>
+ <Button
+ size="sm"
+ w="full"
+ colorScheme="blue"
+ onClick={onReset}
+ leftIcon={loading ? <Spinner size="xs" /> : undefined}
+ >
+ {t('revoke')}
+ </Button>
+ </ModalFooter>
+ </ModalContent>
+ </Modal>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/table/action-buttons.tsx b/app/dashboard/src/pages/hosts/users/table/action-buttons.tsx
@@ -0,0 +1,144 @@
+
+import { t } from 'i18next';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { User } from 'types';
+import {
+ CopiedIcon,
+ CopyIcon,
+ QRIcon,
+ SubscriptionLinkIcon
+} from 'components/table';
+import {
+ HStack,
+ IconButton,
+ Tooltip,
+} from '@chakra-ui/react';
+import { FC, useEffect, useState } from 'react';
+import { useUsers } from 'stores';
+
+type ActionButtonsProps = {
+ user: User;
+};
+
+export const ActionButtons: FC<ActionButtonsProps> = ({ user }) => {
+ const { setQRCode, setSubLink } = useUsers();
+
+ const proxyLinks = user.links.join('\r\n');
+
+ const [copied, setCopied] = useState([-1, false]);
+ useEffect(() => {
+ if (copied[1]) {
+ setTimeout(() => {
+ setCopied([-1, false]);
+ }, 1000);
+ }
+ }, [copied]);
+ return (
+ <HStack
+ justifyContent="flex-end"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <CopyToClipboard
+ text={
+ user.subscription_url.startsWith('/')
+ ? window.location.origin + user.subscription_url
+ : user.subscription_url
+ }
+ onCopy={() => {
+ setCopied([0, true]);
+ }}
+ >
+ <div>
+ <Tooltip
+ label={
+ copied[0] == 0 && copied[1]
+ ? t('usersTable.copied')
+ : t('usersTable.copyLink')
+ }
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="copy subscription link"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ >
+ {copied[0] == 0 && copied[1] ? (
+ <CopiedIcon />
+ ) : (
+ <SubscriptionLinkIcon />
+ )}
+ </IconButton>
+ </Tooltip>
+ </div>
+ </CopyToClipboard>
+ <CopyToClipboard
+ text={proxyLinks}
+ onCopy={() => {
+ setCopied([1, true]);
+ }}
+ >
+ <div>
+ <Tooltip
+ label={
+ copied[0] == 1 && copied[1]
+ ? t('usersTable.copied')
+ : t('usersTable.copyConfigs')
+ }
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="copy configs"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ >
+ {copied[0] == 1 && copied[1] ? <CopiedIcon /> : <CopyIcon />}
+ </IconButton>
+ </Tooltip>
+ </div>
+ </CopyToClipboard>
+ <Tooltip label="QR Code" placement="top">
+ <IconButton
+ p="0 !important"
+ aria-label="qr code"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ onClick={() => {
+ setQRCode(user.links);
+ setSubLink(user.subscription_url);
+ }}
+ >
+ <QRIcon />
+ </IconButton>
+ </Tooltip>
+ </HStack >
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/table/index.tsx b/app/dashboard/src/pages/hosts/users/table/index.tsx
@@ -0,0 +1,408 @@
+
+import {
+ Accordion,
+ AccordionButton,
+ AccordionItem,
+ AccordionPanel,
+ Box,
+ HStack,
+ IconButton,
+ Table,
+ TableProps,
+ Tbody,
+ Td,
+ Text,
+ Th,
+ Thead,
+ Tooltip,
+ Tr,
+ useBreakpointValue,
+ VStack,
+} from '@chakra-ui/react';
+import classNames from 'classnames';
+import { statusColors } from 'constants/Settings';
+import { FC, Fragment, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { OnlineBadge } from 'components/online-badge';
+import { OnlineStatus } from 'components/online-status';
+import { StatusBadge } from 'components/status-badge';
+import {
+ Pagination,
+ EmptySection,
+ AccordionArrowIcon,
+ handleSort,
+ EditIcon,
+ StatusSortSelect,
+ Sort,
+} from 'components/table';
+import { UsageSlider, UsageSliderCompact } from './usage-slider';
+import { useUsers } from 'stores';
+import { ActionButtons } from './action-buttons';
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
+
+type ExpandedIndex = number | number[];
+
+type UsersTableProps = {} & TableProps;
+
+export const UsersTable: FC<UsersTableProps> = (props) => {
+ const {
+ usersFilters: filters,
+ users: { users },
+ users: { total },
+ onEditingUser,
+ onCreateUser,
+ onFilterChange,
+ } = useUsers();
+
+ const { t } = useTranslation();
+ const [selectedRow, setSelectedRow] = useState<ExpandedIndex | undefined>(
+ undefined
+ );
+ const useTable = useBreakpointValue({ base: false, md: true });
+
+ const isFiltered = users.length !== total;
+
+ const handleStatusFilter = (e: any) => {
+ onFilterChange({
+ status: e.target.value.length > 0 ? e.target.value : undefined,
+ });
+ };
+
+ const toggleAccordion = (index: number) => {
+ setSelectedRow(index === selectedRow ? undefined : index);
+ };
+ return (
+ <Box id="users-table" overflowX={{ base: 'unset', md: 'unset' }}>
+ <Accordion
+ allowMultiple
+ display={{ base: 'block', md: 'none' }}
+ index={selectedRow}
+ >
+ <Table orientation="vertical" zIndex="docked" {...props}>
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ position="sticky"
+ minW="120px"
+ pl={4}
+ pr={4}
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, filters, 'username', onFilterChange)}
+ >
+ <HStack>
+ <span>{t('users')}</span>
+ <Sort sort={filters.sort} column="username" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ minW="50px"
+ pl={0}
+ pr={0}
+ w="140px"
+ cursor={'pointer'}
+ >
+ <HStack spacing={0} position="relative">
+ <Text
+ position="absolute"
+ _dark={{
+ bg: 'gray.750',
+ }}
+ _light={{
+ bg: '#F9FAFB',
+ }}
+ userSelect="none"
+ pointerEvents="none"
+ zIndex={1}
+ w="100%"
+ >
+ {t('usersTable.status')}
+ {filters.status ? ': ' + filters.status : ''}
+ </Text>
+ <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} options={['active', 'disabled', 'limited', 'expired', 'on_hold']} />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, filters, 'used_traffic', onFilterChange)}
+ >
+ <HStack>
+ <span>{t('usersTable.dataUsage')}</span>
+ <Sort sort={filters.sort} column="used_traffic" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ minW="32px"
+ w="32px"
+ p={0}
+ cursor={'pointer'}
+ ></Th>
+ </Tr>
+ </Thead>
+ <Tbody>
+ {!useTable &&
+ users?.map((user, i) => {
+ return (
+ <Fragment key={user.username}>
+ <Tr
+ onClick={toggleAccordion.bind(null, i)}
+ cursor="pointer"
+ >
+ <Td borderBottom={0} minW="100px" pl={4} pr={4}>
+ <div className="flex-status">
+ <OnlineBadge lastOnline={user.online_at} />
+ {user.username}
+ </div>
+ </Td>
+ <Td borderBottom={0} minW="50px" pl={0} pr={0}>
+ <StatusBadge
+ compact
+ showDetail={false}
+ expiryDate={user.expire}
+ status={user.status}
+ />
+ </Td>
+ <Td borderBottom={0} minW="100px" pr={0}>
+ <UsageSliderCompact
+ totalUsedTraffic={user.lifetime_used_traffic}
+ dataLimitResetStrategy={
+ user.data_limit_reset_strategy
+ }
+ used={user.used_traffic}
+ total={user.data_limit}
+ colorScheme={statusColors[user.status].bandWidthColor}
+ />
+ </Td>
+ <Td p={0} borderBottom={0} w="32px" minW="32px">
+ <AccordionArrowIcon
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ transition="transform .2s ease-out"
+ transform={
+ selectedRow === i ? 'rotate(180deg)' : '0deg'
+ }
+ />
+ </Td>
+ </Tr>
+ <Tr
+ className="collapsible"
+ onClick={toggleAccordion.bind(null, i)}
+ >
+ <Td p={0} colSpan={4}>
+ <AccordionItem border={0}>
+ <AccordionButton display="none"></AccordionButton>
+ <AccordionPanel
+ border={0}
+ cursor="pointer"
+ px={6}
+ py={3}
+ >
+ <VStack justifyContent="space-between" spacing="4">
+ <VStack
+ alignItems="flex-start"
+ w="full"
+ spacing={-1}
+ >
+ <Text
+ textTransform="capitalize"
+ fontSize="xs"
+ fontWeight="bold"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ {t('usersTable.dataUsage')}
+ </Text>
+ <Box width="full" minW="230px">
+ <UsageSlider
+ totalUsedTraffic={
+ user.lifetime_used_traffic
+ }
+ dataLimitResetStrategy={
+ user.data_limit_reset_strategy
+ }
+ used={user.used_traffic}
+ total={user.data_limit}
+ colorScheme={
+ statusColors[user.status].bandWidthColor
+ }
+ />
+ </Box>
+ </VStack>
+ <HStack w="full" justifyContent="space-between">
+ <Box width="full">
+ <StatusBadge
+ compact
+ expiryDate={user.expire}
+ status={user.status}
+ />
+ <OnlineStatus lastOnline={user.online_at} />
+ </Box>
+ <HStack>
+ <ActionButtons user={user} />
+ <Tooltip
+ label={t('userDialog.editUser')}
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="Edit user"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onEditingUser(user);
+ }}
+ >
+ <EditIcon />
+ </IconButton>
+ </Tooltip>
+ </HStack>
+ </HStack>
+ </VStack>
+ </AccordionPanel>
+ </AccordionItem>
+ </Td>
+ </Tr>
+ </Fragment>
+ );
+ })}
+ </Tbody>
+ </Table>
+ </Accordion>
+ <Table
+ orientation="vertical"
+ display={{ base: 'none', md: 'table' }}
+ {...props}
+ >
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ position="sticky"
+ minW="140px"
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, filters, 'username', onFilterChange)}
+ >
+ <HStack>
+ <span>{t('username')}</span>
+ <Sort sort={filters.sort} column="username" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ width="400px"
+ minW="150px"
+ cursor={'pointer'}
+ >
+ <HStack spacing={0} position="relative">
+ <Text
+ position="absolute"
+ _dark={{
+ bg: 'gray.750',
+ }}
+ _light={{
+ bg: '#F9FAFB',
+ }}
+ userSelect="none"
+ pointerEvents="none"
+ zIndex={1}
+ w="100%"
+ >
+ {t('usersTable.status')}
+ {filters.status ? ': ' + filters.status : ''}
+ </Text>
+ <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} options={['active', 'disabled', 'limited', 'expired', 'on_hold']} />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ width="350px"
+ minW="230px"
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, filters, 'used_traffic', onFilterChange)}
+ >
+ <HStack>
+ <span>{t('usersTable.dataUsage')}</span>
+ <Sort sort={filters.sort} column="used_traffic" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ width="200px"
+ minW="180px"
+ />
+ </Tr>
+ </Thead>
+ <Tbody>
+ {useTable &&
+ users?.map((user, i) => {
+ return (
+ <Tr
+ key={user.username}
+ className={classNames('interactive', {
+ 'last-row': i === users.length - 1,
+ })}
+ onClick={() => onEditingUser(user)}
+ >
+ <Td minW="140px">
+ <div className="flex-status">
+ <OnlineBadge lastOnline={user.online_at} />
+ {user.username}
+ <OnlineStatus lastOnline={user.online_at} />
+ </div>
+ </Td>
+ <Td width="400px" minW="150px">
+ <StatusBadge
+ expiryDate={user.expire}
+ status={user.status}
+ />
+ </Td>
+ <Td width="350px" minW="230px">
+ <UsageSlider
+ totalUsedTraffic={user.lifetime_used_traffic}
+ dataLimitResetStrategy={user.data_limit_reset_strategy}
+ used={user.used_traffic}
+ total={user.data_limit}
+ colorScheme={statusColors[user.status].bandWidthColor}
+ />
+ </Td>
+ <Td width="200px" minW="180px">
+ <ActionButtons user={user} />
+ </Td>
+ </Tr>
+ );
+ })}
+ {users.length == 0 && (
+ <Tr>
+ <Td colSpan={4}>
+ <EmptySection
+ isFiltered={isFiltered}
+ noObjectT="usersTable.noUser"
+ noObjectMatchedT='usersTable.noUserMatched'
+ createObjectT="createUser"
+ onCreateObject={onCreateUser}
+ />
+ </Td>
+ </Tr>
+ )}
+ </Tbody>
+ </Table>
+ <Pagination filters={filters} total={total} onFilterChange={onFilterChange} pageSizeManager={pageSizeManagers.users} />
+ </Box>
+ );
+};
diff --git a/app/dashboard/src/pages/hosts/users/table/usage-slider.tsx b/app/dashboard/src/pages/hosts/users/table/usage-slider.tsx
@@ -0,0 +1,106 @@
+import { HStack, Text, SliderTrack, SliderFilledTrack, Slider, SliderProps } from '@chakra-ui/react';
+import { resetStrategy } from 'constants/Settings';
+import { formatBytes } from 'utils/formatByte';
+import { useTranslation } from 'react-i18next';
+import { FC } from 'react';
+
+export const UsageSliderCompact: FC<UsageSliderProps> = (props) => {
+ const { used, total } = props;
+ const isUnlimited = total === 0 || total === null;
+ return (
+ <HStack
+ justifyContent="space-between"
+ fontSize="xs"
+ fontWeight="medium"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ <Text>
+ {formatBytes(used)} /{' '}
+ {isUnlimited ? (
+ <Text as="span" fontFamily="system-ui">
+ ∞
+ </Text>
+ ) : (
+ formatBytes(total)
+ )}
+ </Text>
+ </HStack>
+ );
+};
+
+type UsageSliderProps = {
+ used: number;
+ total: number | null;
+ dataLimitResetStrategy: string | null;
+ totalUsedTraffic: number;
+} & SliderProps;
+
+const getResetStrategy = (strategy: string): string => {
+ for (var i = 0; i < resetStrategy.length; i++) {
+ const entry = resetStrategy[i];
+ if (entry.value == strategy) {
+ return entry.title;
+ }
+ }
+ return 'No';
+};
+
+export const UsageSlider: FC<UsageSliderProps> = (props) => {
+ const {
+ used,
+ total,
+ dataLimitResetStrategy,
+ totalUsedTraffic,
+ ...restOfProps
+ } = props;
+ const isUnlimited = total === 0 || total === null;
+ const isReached = !isUnlimited && (used / total) * 100 >= 100;
+ const { t } = useTranslation();
+ return (
+ <>
+ <Slider
+ orientation="horizontal"
+ value={isUnlimited ? 100 : Math.min((used / total) * 100, 100)}
+ colorScheme={isReached ? 'red' : 'primary'}
+ {...restOfProps}
+ >
+ <SliderTrack h="6px" borderRadius="full">
+ <SliderFilledTrack borderRadius="full" />
+ </SliderTrack>
+ </Slider>
+ <HStack
+ justifyContent="space-between"
+ fontSize="xs"
+ fontWeight="medium"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ <Text>
+ {formatBytes(used)} /{' '}
+ {isUnlimited ? (
+ <Text as="span" fontFamily="system-ui">
+ ∞
+ </Text>
+ ) : (
+ formatBytes(total) +
+ (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset'
+ ? ' ' +
+ t(
+ 'userDialog.resetStrategy' +
+ getResetStrategy(dataLimitResetStrategy)
+ )
+ : '')
+ )}
+ </Text>
+ <Text>
+ {t('usersTable.total')}: {formatBytes(totalUsedTraffic)}
+ </Text>
+ </HStack>
+ </>
+ );
+};
diff --git a/app/dashboard/src/pages/nodes/table/index.tsx b/app/dashboard/src/pages/nodes/table/index.tsx
@@ -21,7 +21,7 @@ import {
} from '@chakra-ui/react';
import classNames from 'classnames';
import { ActionButtons } from './action-buttons';
-
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
import { FC, Fragment, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StatusBadge } from 'components/status-badge';
@@ -388,7 +388,7 @@ export const NodesTable: FC<NodesTableProps> = (props) => {
)}
</Tbody>
</Table>
- <Pagination filters={filters} total={total} onFilterChange={onFilterChange} />
+ <Pagination filters={filters} total={total} onFilterChange={onFilterChange} pageSizeManager={pageSizeManagers.nodes} />
</Box>
);
};
diff --git a/app/dashboard/src/pages/services/table.tsx b/app/dashboard/src/pages/services/table.tsx
@@ -23,6 +23,7 @@ import {
Sort
} from 'components/table';
import { useServices } from 'stores';
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
type ExpandedIndex = number | number[];
type ServicesTableProps = {} & TableProps;
@@ -206,7 +207,12 @@ export const ServicesTable: FC<ServicesTableProps> = (props) => {
)}
</Tbody>
</Table>
- <Pagination total={services.length} onFilterChange={onFilterChange} filters={filters} />
+ <Pagination
+ total={services.length}
+ onFilterChange={onFilterChange}
+ filters={filters}
+ pageSizeManager={pageSizeManagers.services}
+ />
</Box>
);
};
diff --git a/app/dashboard/src/pages/users/table/index.tsx b/app/dashboard/src/pages/users/table/index.tsx
@@ -38,6 +38,7 @@ import {
import { UsageSlider, UsageSliderCompact } from './usage-slider';
import { useUsers } from 'stores';
import { ActionButtons } from './action-buttons';
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
type ExpandedIndex = number | number[];
@@ -401,7 +402,7 @@ export const UsersTable: FC<UsersTableProps> = (props) => {
)}
</Tbody>
</Table>
- <Pagination filters={filters} total={total} onFilterChange={onFilterChange} />
+ <Pagination filters={filters} total={total} onFilterChange={onFilterChange} pageSizeManager={pageSizeManagers.users} />
</Box>
);
};
diff --git a/app/dashboard/src/stores/inbounds.store.ts b/app/dashboard/src/stores/inbounds.store.ts
@@ -4,6 +4,9 @@ import { useDashboard } from './dashboard.store';
import { fetch } from 'service/http';
import { Inbounds, InboundType } from 'types/inbounds';
import { z } from 'zod';
+import { HostsFilterType, InboundsFilterType } from 'types';
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
+import { queryClient } from 'service/react-query';
const isPortThenValue = (value: number) => (value <= 65535 && value !== 0) ? value : null;
@@ -42,7 +45,7 @@ export const fetchInbounds = async () => {
});
};
-export const fetchInboundHosts = async (id: string): Promise<Hosts> => {
+export const fetchInboundHosts = async (id: number): Promise<Hosts> => {
useDashboard.setState({ loading: true });
return fetch(`/inbounds/${id}/hosts`)
.then((hosts: Hosts) => hosts)
@@ -61,6 +64,11 @@ type InboundsStateType = {
selectedInbound: InboundType | null;
selectInbound: (inbound: InboundType) => void;
// Hosts
+ refetchHosts: () => void;
+ hostsFilters: HostsFilterType;
+ inboundsFilters: InboundsFilterType;
+ onHostsFilterChange: (filters: Partial<HostsFilterType>) => void;
+ onInboundsFilterChange: (filters: Partial<InboundsFilterType>) => void;
selectedHost: HostType | null;
selectHost: (host: HostType) => void;
isEditingHost: boolean;
@@ -75,7 +83,7 @@ type InboundsStateType = {
};
export const useInbounds = create(
- subscribeWithSelector<InboundsStateType>((set,) => ({
+ subscribeWithSelector<InboundsStateType>((set, get) => ({
inbounds: [],
selectedHost: null,
selectedInbound: null,
@@ -83,12 +91,41 @@ export const useInbounds = create(
isDeletingHost: false,
isCreatingHost: false,
isEditingHost: false,
+ hostsFilters: {
+ name: '',
+ limit: pageSizeManagers.hosts.getPageSize(),
+ sort: '-created_at',
+ },
+ inboundsFilters: {
+ name: '',
+ limit: pageSizeManagers.inbounds.getPageSize(),
+ sort: '-created_at',
+ },
+ onHostsFilterChange: (filters) => {
+ set({
+ hostsFilters: {
+ ...get().hostsFilters,
+ ...filters,
+ },
+ });
+ },
+ onInboundsFilterChange: (filters) => {
+ set({
+ hostsFilters: {
+ ...get().inboundsFilters,
+ ...filters,
+ },
+ });
+ },
setLoading: (value) => {
set({ loading: value })
},
refetchInbounds: () => {
fetchInbounds();
},
+ refetchHosts: () => {
+ queryClient.invalidateQueries('hosts');
+ },
selectInbound: (host): void => {
set({ selectedInbound: host })
},
diff --git a/app/dashboard/src/stores/nodes.store.ts b/app/dashboard/src/stores/nodes.store.ts
@@ -3,7 +3,7 @@ import { fetch } from 'service/http';
import { create } from 'zustand';
import { z } from 'zod';
import { FilterUsageType, NodesFilterType } from 'types/filter';
-import { getNodesPerPageLimitSize } from 'utils/userPreferenceStorage';
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
import { useDashboard } from './dashboard.store';
export const NodeSchema = z.object({
@@ -109,7 +109,7 @@ export const useNodes = create<NodeStore>((set, get) => ({
isEditingCore: false,
nodesFilters: {
name: '',
- limit: getNodesPerPageLimitSize(),
+ limit: pageSizeManagers.nodes.getPageSize(),
sort: '-created_at',
},
onFilterChange: (filters) => {
diff --git a/app/dashboard/src/stores/services.store.ts b/app/dashboard/src/stores/services.store.ts
@@ -1,7 +1,7 @@
import { queryClient } from 'service/react-query';
import { StatisticsQueryKey } from 'components/statistics-card';
import { Service, ServiceCreate } from 'types/service';
-import { getServicesPerPageLimitSize } from 'utils/userPreferenceStorage';
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { fetch } from 'service/http';
@@ -49,7 +49,7 @@ export const useServices = create(
services: [],
servicesFilters: {
name: '',
- limit: getServicesPerPageLimitSize(),
+ limit: pageSizeManagers.services.getPageSize(),
sort: '-created_at',
},
onFilterChange: (filters) => {
diff --git a/app/dashboard/src/stores/users.store.ts b/app/dashboard/src/stores/users.store.ts
@@ -1,7 +1,7 @@
import { User, UserCreate } from 'types/user';
import { subscribeWithSelector } from 'zustand/middleware';
import { create } from 'zustand';
-import { getUsersPerPageLimitSize } from 'utils/userPreferenceStorage';
+import { pageSizeManagers } from 'utils/userPreferenceStorage';
import { fetch } from 'service/http';
import { useServices, useDashboard } from 'stores';
import { StatisticsQueryKey } from 'components/statistics-card';
@@ -70,7 +70,7 @@ export const useUsers = create(subscribeWithSelector<UsersStateType>((set, get)
isResetingAllUsage: false,
usersFilters: {
username: '',
- limit: getUsersPerPageLimitSize(),
+ limit: pageSizeManagers.users.getPageSize(),
sort: '-created_at',
},
QRcodeLinks: null,
diff --git a/app/dashboard/src/types/filter.ts b/app/dashboard/src/types/filter.ts
@@ -27,7 +27,16 @@ export type NodesFilterType = FilterType & {
address?: string;
port?: number;
xray_version?: number;
- message?: string | null;
- add_as_new_host?: boolean;
status?: 'healthy' | 'disabled' | 'healthy';
}
+
+export type HostsFilterType = FilterType & {
+ name?: string;
+ address?: string;
+ port?: number;
+ security?: number;
+}
+
+export type InboundsFilterType = FilterType & {
+ tag?: string;
+}
diff --git a/app/dashboard/src/utils/userPreferenceStorage.ts b/app/dashboard/src/utils/userPreferenceStorage.ts
@@ -1,40 +1,33 @@
-const NUM_USERS_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 = () => {
- const numUsersPerPage =
- localStorage.getItem(NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY) ||
- NUM_USERS_PER_PAGE_DEFAULT.toString(); // this catches `null` values
- return parseInt(numUsersPerPage) || NUM_USERS_PER_PAGE_DEFAULT; // this catches NaN values
-};
-export const setUsersPerPageLimitSize = (value: string) => {
- return localStorage.setItem(NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY, value);
-};
+export class PageSizeManager {
+ private localStorageKey: string;
+ private defaultPageSize: number;
+ constructor(localStorageKey: string, defaultPageSize: number) {
+ this.localStorageKey = `marzneshin-num-${localStorageKey}-per-page`;
+ this.defaultPageSize = defaultPageSize;
+ }
-export const getServicesPerPageLimitSize = () => {
- const numServicesPerPage =
- localStorage.getItem(NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY) ||
- NUM_SERVICES_PER_PAGE_DEFAULT.toString(); // this catches `null` values
- return parseInt(numServicesPerPage) || NUM_SERVICES_PER_PAGE_DEFAULT; // this catches NaN values
-};
+ private getStoredPageSize(): string | null {
+ return localStorage.getItem(this.localStorageKey);
+ }
-export const setServicesPerPageLimitSize = (value: string) => {
- return localStorage.setItem(NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY, value);
-};
+ getPageSize(): number {
+ const storedPageSize = this.getStoredPageSize();
+ const parsedPageSize = parseInt(storedPageSize || '', 10);
+ return isNaN(parsedPageSize) ? this.defaultPageSize : parsedPageSize;
+ }
-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
-};
+ setPageSize(value: string): void {
+ localStorage.setItem(this.localStorageKey, value);
+ }
+}
-export const setNodesPerPageLimitSize = (value: string) => {
- return localStorage.setItem(NUM_NODES_PER_PAGE_LOCAL_STORAGE_KEY, value);
-};
+export const pageSizeManagers = {
+ users: new PageSizeManager('users', 10),
+ inbounds: new PageSizeManager('inbounds', 10),
+ nodes: new PageSizeManager('nodes', 10),
+ hosts: new PageSizeManager('hosts', 10),
+ services: new PageSizeManager('services', 10),
+}