1
2import {
3 Accordion,
4 AccordionButton,
5 AccordionItem,
6 AccordionPanel,
7 Box,
8 HStack,
9 IconButton,
10 Table,
11 TableProps,
12 Tbody,
13 Td,
14 Text,
15 Th,
16 Thead,
17 Tooltip,
18 Tr,
19 useBreakpointValue,
20 VStack,
21} from '@chakra-ui/react';
22import classNames from 'classnames';
23import { ActionButtons } from './action-buttons';
24import { pageSizeManagers } from 'utils/userPreferenceStorage';
25import { FC, Fragment, useState } from 'react';
26import { useTranslation } from 'react-i18next';
27import { StatusBadge } from 'components/status-badge';
28import {
29 Pagination,
30 EmptySection,
31 AccordionArrowIcon,
32 EditIcon,
33 Sort,
34 StatusSortSelect,
35 handleSort,
36} from 'components/table';
37import { fetchNodes, useNodes } from 'stores';
38import { useQuery } from '@tanstack/react-query';
39import { queryIds } from 'constants/query-ids';
40
41type ExpandedIndex = number | number[];
42
43type NodesTableProps = {} & TableProps;
44
45export const NodesTable: FC<NodesTableProps> = (props) => {
46 const {
47 nodesFilters: filters,
48 onEditingNode,
49 onAddingNode,
50 onFilterChange,
51 } = useNodes();
52 const { data: nodes } = useQuery({ queryKey: [queryIds.nodes], queryFn: fetchNodes });
53 const total = nodes?.length || 0;
54
55 const { t } = useTranslation();
56 const [selectedRow, setSelectedRow] = useState<ExpandedIndex | undefined>(
57 undefined
58 );
59
60 const useTable = useBreakpointValue({ base: false, md: true });
61
62 // TODO: Find a different mechancism to to detect ftiler
63 // const isFiltered = users.length !== total;
64
65 const handleStatusFilter = (e: any) => {
66 onFilterChange({
67 status: e.target.value.length > 0 ? e.target.value : undefined,
68 });
69 };
70
71 const toggleAccordion = (index: number) => {
72 setSelectedRow(index === selectedRow ? undefined : index);
73 };
74
75 const StatusSortSelectNodes = () => (
76 <StatusSortSelect
77 filters={filters}
78 options={['healthy', 'unhealthy', 'disabled']}
79 handleStatusFilter={handleStatusFilter}
80 />
81 );
82
83 return (
84 <Box id="node-table" overflowX={{ base: 'unset', md: 'unset' }} >
85 <Accordion
86 allowMultiple
87 display={{ base: 'block', md: 'none' }}
88 index={selectedRow}
89 >
90 <Table orientation="vertical" zIndex="docked" {...props}>
91 <Thead zIndex="docked" position="relative">
92 <Tr>
93 <Th
94 position="fixed"
95 minW="120px"
96 pl={4}
97 pr={4}
98 cursor={'pointer'}
99 onClick={handleSort.bind(null, filters, 'name', onFilterChange)}
100 >
101 <HStack>
102 <span>{t('name')}</span>
103 <Sort sort={filters.sort} column="name" />
104 </HStack>
105 </Th>
106 <Th
107 position="fixed"
108 minW="50px"
109 pl={0}
110 pr={0}
111 w="140px"
112 cursor={'pointer'}
113 >
114 <HStack spacing={0} position="relative">
115 <Text
116 position="absolute"
117 _dark={{
118 bg: 'gray.750',
119 }}
120 _light={{
121 bg: '#F9FAFB',
122 }}
123 userSelect="none"
124 pointerEvents="none"
125 zIndex={1}
126 w="100%"
127 >
128 {t('nodesTable.status')}
129 {filters.status ? ': ' + filters.status : ''}
130 </Text>
131 <StatusSortSelectNodes />
132 </HStack>
133 </Th>
134 <Th
135 position="fixed"
136 minW="100px"
137 cursor={'pointer'}
138 pr={0}
139 onClick={handleSort.bind(null, filters, 'xray_version', onFilterChange)}
140 >
141 <HStack>
142 <span>{t('nodesTable.xrayVersion')}</span>
143 <Sort sort={filters.sort} column="xray_version" />
144 </HStack>
145 </Th>
146 <Th
147 position="fixed"
148 minW="100px"
149 cursor={'pointer'}
150 pr={0}
151 >
152 <span>{t('nodesTable.ipPort')}</span>
153 </Th>
154 {/* Action buttons (certificate, core settings)*/}
155 <Th
156 position="fixed"
157 minW="32px"
158 w="32px"
159 p={0}
160 cursor={'pointer'}
161 ></Th>
162 </Tr>
163 </Thead>
164 <Tbody>
165 {!useTable &&
166 nodes?.map((node, i) => {
167 return (
168 <Fragment key={node.name}>
169 <Tr
170 onClick={toggleAccordion.bind(null, i)}
171 cursor="pointer"
172 >
173 <Td borderBottom={0} minW="100px" pl={4} pr={4}>
174 {node.name}
175 </Td>
176 <Td borderBottom={0} minW="50px" pl={0} pr={0}>
177 <StatusBadge
178 compact
179 showDetail={false}
180 status={node.status}
181 />
182 </Td>
183 <Td borderBottom={0} minW="100px" pr={0}>
184 {node.xray_version}
185 </Td>
186 <Td p={0} borderBottom={0} w="32px" minW="32px">
187 <AccordionArrowIcon
188 color="gray.600"
189 _dark={{
190 color: 'gray.400',
191 }}
192 transition="transform .2s ease-out"
193 transform={
194 selectedRow === i ? 'rotate(180deg)' : '0deg'
195 }
196 />
197 </Td>
198 </Tr>
199 <Tr
200 className="collapsible"
201 onClick={toggleAccordion.bind(null, i)}
202 >
203 <Td p={0} colSpan={4}>
204 <AccordionItem border={0}>
205 <AccordionButton display="none"></AccordionButton>
206 <AccordionPanel
207 border={0}
208 cursor="pointer"
209 px={6}
210 py={3}
211 >
212 <VStack justifyContent="space-between" spacing="4">
213 <VStack
214 alignItems="flex-start"
215 w="full"
216 spacing={-1}
217 >
218 <Text
219 textTransform="capitalize"
220 fontSize="xs"
221 fontWeight="bold"
222 color="gray.600"
223 _dark={{
224 color: 'gray.400',
225 }}
226 >
227 {t('nodesTable.ipPort')}
228 </Text>
229 <Box width="full" minW="230px">
230 {node.address}:{node.port}
231 </Box>
232 </VStack>
233 <HStack w="full" justifyContent="space-between">
234 <HStack>
235 <ActionButtons />
236 <Tooltip
237 label={t('nodesDialog.editNode')}
238 placement="top"
239 >
240 <IconButton
241 p="0 !important"
242 aria-label="Edit user"
243 bg="transparent"
244 _dark={{
245 _hover: {
246 bg: 'gray.700',
247 },
248 }}
249 size={{
250 base: 'sm',
251 md: 'md',
252 }}
253 onClick={(e) => {
254 e.stopPropagation();
255 onEditingNode(node);
256 }}
257 >
258 <EditIcon />
259 </IconButton>
260 </Tooltip>
261 </HStack>
262 </HStack>
263 </VStack>
264 </AccordionPanel>
265 </AccordionItem>
266 </Td>
267 </Tr>
268 </Fragment>
269 );
270 })}
271 </Tbody>
272 </Table>
273 </Accordion>
274 <Table
275 orientation="vertical"
276 display={{ base: 'none', md: 'table' }}
277 {...props}
278 >
279 <Thead zIndex="docked" position="relative">
280 <Tr>
281 <Th
282 minW="200px"
283 cursor={'pointer'}
284 onClick={handleSort.bind(null, filters, 'name', onFilterChange)}
285 >
286 <HStack>
287 <span>{t('name')}</span>
288 <Sort sort={filters.sort} column="name" />
289 </HStack>
290 </Th>
291 <Th
292 width="100px"
293 minW="70px"
294 cursor={'pointer'}
295 >
296 <HStack spacing={0} position="relative">
297 <Text
298 position="absolute"
299 _dark={{
300 bg: 'gray.750',
301 }}
302 _light={{
303 bg: '#F9FAFB',
304 }}
305 userSelect="none"
306 pointerEvents="none"
307 zIndex={1}
308 w="100%"
309 >
310 {t('nodesTable.status')}
311 {filters.status ? ': ' + filters.status : ''}
312 </Text>
313 <StatusSortSelectNodes />
314 </HStack>
315 </Th>
316 <Th
317 width="100px"
318 minW="70px"
319 cursor={'pointer'}
320 onClick={handleSort.bind(null, filters, 'xray_version', onFilterChange)}
321 >
322 <HStack>
323 <span>{t('nodesTable.xrayVersion')}</span>
324 <Sort sort={filters.sort} column="xray_version" />
325 </HStack>
326 </Th>
327 <Th
328 width="100px"
329 minW="70px"
330 cursor={'pointer'}
331 onClick={handleSort.bind(null, filters, 'address', onFilterChange)}
332 >
333 <HStack>
334 <span>{t('nodesTable.ipPort')}</span>
335 <Sort sort={filters.sort} column="address" />
336 </HStack>
337 </Th>
338 <Th
339 width="60px"
340 minW="50px"
341 />
342 </Tr>
343 </Thead>
344 <Tbody overflowY="auto">
345 {useTable &&
346 nodes?.map((node, i) => {
347 return (
348 <Tr
349 key={node.name}
350 className={classNames('interactive', {
351 'last-row': i === nodes.length - 1,
352 })}
353 onClick={() => onEditingNode(node)}
354 >
355 <Td minW="140px">
356 <div className="flex-status">
357 {node.name}
358 </div>
359 </Td>
360 <Td width="400px" minW="150px">
361 <StatusBadge
362 status={node.status}
363 />
364 </Td>
365 <Td width="350px" minW="150px">
366 {node.xray_version}
367 </Td>
368 <Td width="350px" minW="150px">
369 {node.address}:{node.port}
370 </Td>
371 <Td width="200px" minW="180px">
372 <ActionButtons />
373 </Td>
374 </Tr>
375 );
376 })}
377 {nodes?.length == 0 && (
378 <Tr>
379 <Td colSpan={5}>
380 <EmptySection
381 isFiltered={/* isFiltered */ false}
382 noObjectT="nodesTable.noNode"
383 noObjectMatchedT='nodesTable.noNodeMatched'
384 createObjectT="addNode"
385 onCreateObject={onAddingNode}
386 />
387 </Td>
388 </Tr>
389 )}
390 </Tbody>
391 </Table>
392 <Pagination filters={filters} total={total} onFilterChange={onFilterChange} pageSizeManager={pageSizeManagers.nodes} />
393 </Box>
394 );
395};