gitscope
← marzneshin / v0.1.4 / app/dashboard/src/pages/nodes/modals/core-settings-modal.tsx
https://dev.kronoz.win/r/marzneshin.git
app/dashboard/src/pages/nodes/modals/core-settings-modal.tsx · TypeScript
Blame Raw
  1import {
  2  Badge,
  3  Box,
  4  Button,
  5  chakra,
  6  CircularProgress,
  7  FormControl,
  8  FormLabel,
  9  HStack,
 10  IconButton,
 11  Modal,
 12  ModalBody,
 13  ModalCloseButton,
 14  ModalContent,
 15  ModalFooter,
 16  ModalHeader,
 17  ModalOverlay,
 18  Text,
 19  Tooltip,
 20  useToast,
 21} from '@chakra-ui/react';
 22import {
 23  ArrowPathIcon,
 24  ArrowsPointingInIcon,
 25  ArrowsPointingOutIcon,
 26  Cog6ToothIcon,
 27} from '@heroicons/react/24/outline';
 28import { joinPaths } from '@remix-run/router';
 29import classNames from 'classnames';
 30import { useNodes, useCoreSettings } from 'stores';
 31import debounce from 'lodash.debounce';
 32import { FC, useCallback, useEffect, useRef, useState } from 'react';
 33import { Controller, useForm } from 'react-hook-form';
 34import { useTranslation } from 'react-i18next';
 35import { useMutation } from '@tanstack/react-query';
 36import { ReadyState } from 'react-use-websocket';
 37import { useWebSocket } from 'react-use-websocket/dist/lib/use-websocket';
 38import { getAuthToken } from 'service/auth-storage';
 39import { Icon } from 'components/icon';
 40import { JsonEditor } from 'components/json-editor';
 41import 'components/json-editor/themes.js';
 42
 43export const MAX_NUMBER_OF_LOGS = 500;
 44
 45const UsageIcon = chakra(Cog6ToothIcon, {
 46  baseStyle: {
 47    w: 5,
 48    h: 5,
 49  },
 50});
 51export const ReloadIcon = chakra(ArrowPathIcon, {
 52  baseStyle: {
 53    w: 4,
 54    h: 4,
 55  },
 56});
 57
 58export const FullScreenIcon = chakra(ArrowsPointingOutIcon, {
 59  baseStyle: {
 60    w: 4,
 61    h: 4,
 62  },
 63});
 64export const ExitFullScreenIcon = chakra(ArrowsPointingInIcon, {
 65  baseStyle: {
 66    w: 3,
 67    h: 3,
 68  },
 69});
 70
 71const getStatus = (status: string) => {
 72  return {
 73    [ReadyState.CONNECTING]: 'connecting',
 74    [ReadyState.OPEN]: 'connected',
 75    [ReadyState.CLOSING]: 'closed',
 76    [ReadyState.CLOSED]: 'closed',
 77    [ReadyState.UNINSTANTIATED]: 'closed',
 78  }[status];
 79};
 80
 81const getWebsocketUrl = () => {
 82  try {
 83    let baseURL = new URL(
 84      import.meta.env.VITE_BASE_API.startsWith('/')
 85        ? window.location.origin + import.meta.env.VITE_BASE_API
 86        : import.meta.env.VITE_BASE_API
 87    );
 88
 89    return (
 90      (baseURL.protocol === 'https:' ? 'wss://' : 'ws://') +
 91      joinPaths([baseURL.host + baseURL.pathname, '/core/logs']) +
 92      '?interval=1&token=' +
 93      getAuthToken()
 94    );
 95  } catch (e) {
 96    console.error('Unable to generate websocket url');
 97    console.error(e);
 98    return null;
 99  }
100};
101
102let logsTmp: string[] = [];
103const CoreSettingModalContent: FC = () => {
104  const { isEditingCore } = useNodes();
105  const {
106    fetchCoreSettings,
107    updateConfig,
108    isLoading,
109    config,
110    isPostLoading,
111    version,
112    restartCore,
113  } = useCoreSettings();
114  const logsDiv = useRef<HTMLDivElement | null>(null);
115  const [logs, setLogs] = useState<string[]>([]);
116  const { t } = useTranslation();
117  const toast = useToast();
118  const form = useForm({
119    defaultValues: { config: config || {} },
120  });
121
122  useEffect(() => {
123    if (config) form.setValue('config', config);
124  }, [config]);
125
126  useEffect(() => {
127    if (isEditingCore) fetchCoreSettings();
128  }, [isEditingCore]);
129  ''.startsWith;
130  const scrollShouldStayOnEnd = useRef(true);
131  const updateLogs = useCallback(
132    debounce((logs: string[]) => {
133      const isScrollOnEnd =
134        Math.abs(
135          (logsDiv.current?.scrollTop || 0) -
136          (logsDiv.current?.scrollHeight || 0) +
137          (logsDiv.current?.offsetHeight || 0)
138        ) < 10;
139      if (logsDiv.current && isScrollOnEnd)
140        scrollShouldStayOnEnd.current = true;
141      else scrollShouldStayOnEnd.current = false;
142      if (logs.length < 40) setLogs(logs);
143    }, 300),
144    []
145  );
146
147  const { readyState } = useWebSocket(getWebsocketUrl(), {
148    onMessage: (e: any) => {
149      logsTmp.push(e.data);
150      if (logsTmp.length > MAX_NUMBER_OF_LOGS)
151        logsTmp = logsTmp.splice(0, logsTmp.length - MAX_NUMBER_OF_LOGS);
152      updateLogs([...logsTmp]);
153    },
154    shouldReconnect: () => true,
155    reconnectAttempts: 10,
156    reconnectInterval: 1000,
157  });
158
159  useEffect(() => {
160    if (logsDiv.current && scrollShouldStayOnEnd.current)
161      logsDiv.current.scrollTop = logsDiv.current?.scrollHeight;
162  }, [logs]);
163
164  useEffect(() => {
165    return () => {
166      logsTmp = [];
167    };
168  }, []);
169
170  const status = getStatus(readyState.toString());
171
172  const {
173    mutate: handleRestartCore,
174    isPending: isRestarting
175  } = useMutation({ mutationFn: restartCore });
176
177  const handleOnSave = ({ config }: any) => {
178    updateConfig(config)
179      .then(() => {
180        toast({
181          title: t('core.successMessage'),
182          status: 'success',
183          isClosable: true,
184          position: 'top',
185          duration: 3000,
186        });
187      })
188      .catch((e) => {
189        let message = t('core.generalErrorMessage');
190        if (typeof e.response._data.detail === 'object')
191          message =
192            e.response._data.detail[Object.keys(e.response._data.detail)[0]];
193        if (typeof e.response._data.detail === 'string')
194          message = e.response._data.detail;
195
196        toast({
197          title: message,
198          status: 'error',
199          isClosable: true,
200          position: 'top',
201          duration: 3000,
202        });
203      });
204  };
205  const editorRef = useRef<HTMLDivElement>(null);
206  const [isFullScreen, setFullScreen] = useState(false);
207  const handleFullScreen = () => {
208    if (document.fullscreenElement) {
209      document.exitFullscreen();
210      setFullScreen(false);
211    } else {
212      editorRef.current?.requestFullscreen();
213      setFullScreen(true);
214    }
215  };
216  return (
217    <form onSubmit={form.handleSubmit(handleOnSave)}>
218      <ModalBody>
219        <FormControl>
220          <HStack justifyContent="space-between" alignItems="flex-start">
221            <FormLabel>
222              {t('core.configuration')}{' '}
223              {isLoading && <CircularProgress isIndeterminate size="15px" />}
224            </FormLabel>
225            <HStack gap={0}>
226              <Tooltip label="Xray Version" placement="top">
227                <Badge height="100%" textTransform="lowercase">
228                  {version && `v${version}`}
229                </Badge>
230              </Tooltip>
231            </HStack>
232          </HStack>
233          <Box position="relative" ref={editorRef} minHeight="300px">
234            <Controller
235              control={form.control}
236              name="config"
237              render={({ field }) => (
238                <JsonEditor json={config} onChange={field.onChange} />
239              )}
240            />
241            <IconButton
242              size="xs"
243              aria-label="full screen"
244              variant="ghost"
245              position="absolute"
246              top="2"
247              right="4"
248              onClick={handleFullScreen}
249            >
250              {!isFullScreen ? <FullScreenIcon /> : <ExitFullScreenIcon />}
251            </IconButton>
252          </Box>
253        </FormControl>
254        <FormControl mt="4">
255          <HStack justifyContent="space-between">
256            <FormLabel>{t('core.logs')}</FormLabel>
257            <Text as={FormLabel}>{t(`core.socket.${status}`)}</Text>
258          </HStack>
259          <Box
260            border="1px solid"
261            borderColor="gray.300"
262            bg="#F9F9F9"
263            _dark={{
264              borderColor: 'gray.500',
265              bg: '#2e3440',
266            }}
267            borderRadius={5}
268            minHeight="200px"
269            maxHeight={'250px'}
270            p={2}
271            overflowY="auto"
272            ref={logsDiv}
273          >
274            {logs.map((message, i) => (
275              <Text fontSize="xs" opacity={0.8} key={i} whiteSpace="pre-line">
276                {message}
277              </Text>
278            ))}
279          </Box>
280        </FormControl>
281      </ModalBody>
282      <ModalFooter>
283        <HStack w="full" justifyContent="space-between">
284          <Box>
285            <Button
286              size="sm"
287              leftIcon={
288                <ReloadIcon
289                  className={classNames({
290                    'animate-spin': isRestarting,
291                  })}
292                />
293              }
294              onClick={() => handleRestartCore()}
295            >
296              {t(isRestarting ? 'core.restarting' : 'core.restartCore')}
297            </Button>
298          </Box>
299          <HStack>
300            <Button
301              size="sm"
302              variant="solid"
303              colorScheme="primary"
304              px="5"
305              type="submit"
306              isDisabled={isLoading || isPostLoading}
307              isLoading={isPostLoading}
308            >
309              {t('core.save')}
310            </Button>
311          </HStack>
312        </HStack>
313      </ModalFooter>
314    </form>
315  );
316};
317export const CoreSettingsModal: FC = () => {
318  const { isEditingCore } = useNodes();
319  const onClose = useNodes.setState.bind(null, { isEditingCore: false });
320  const { t } = useTranslation();
321
322  return (
323    <Modal isOpen={isEditingCore} onClose={onClose} size="3xl">
324      <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
325      <ModalContent mx="3" w="full">
326        <ModalHeader pt={6}>
327          <HStack gap={2}>
328            <Icon color="primary">
329              <UsageIcon color="white" />
330            </Icon>
331            <Text fontWeight="semibold" fontSize="lg">
332              {t('core.title')}
333            </Text>
334          </HStack>
335        </ModalHeader>
336        <ModalCloseButton mt={3} />
337        <CoreSettingModalContent />
338      </ModalContent>
339    </Modal>
340  );
341};