gitscope
← marzneshin / v0.1.1 / 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 '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 { mutate: handleRestartCore, isLoading: isRestarting } =
173    useMutation(restartCore);
174
175  const handleOnSave = ({ config }: any) => {
176    updateConfig(config)
177      .then(() => {
178        toast({
179          title: t('core.successMessage'),
180          status: 'success',
181          isClosable: true,
182          position: 'top',
183          duration: 3000,
184        });
185      })
186      .catch((e) => {
187        let message = t('core.generalErrorMessage');
188        if (typeof e.response._data.detail === 'object')
189          message =
190            e.response._data.detail[Object.keys(e.response._data.detail)[0]];
191        if (typeof e.response._data.detail === 'string')
192          message = e.response._data.detail;
193
194        toast({
195          title: message,
196          status: 'error',
197          isClosable: true,
198          position: 'top',
199          duration: 3000,
200        });
201      });
202  };
203  const editorRef = useRef<HTMLDivElement>(null);
204  const [isFullScreen, setFullScreen] = useState(false);
205  const handleFullScreen = () => {
206    if (document.fullscreenElement) {
207      document.exitFullscreen();
208      setFullScreen(false);
209    } else {
210      editorRef.current?.requestFullscreen();
211      setFullScreen(true);
212    }
213  };
214  return (
215    <form onSubmit={form.handleSubmit(handleOnSave)}>
216      <ModalBody>
217        <FormControl>
218          <HStack justifyContent="space-between" alignItems="flex-start">
219            <FormLabel>
220              {t('core.configuration')}{' '}
221              {isLoading && <CircularProgress isIndeterminate size="15px" />}
222            </FormLabel>
223            <HStack gap={0}>
224              <Tooltip label="Xray Version" placement="top">
225                <Badge height="100%" textTransform="lowercase">
226                  {version && `v${version}`}
227                </Badge>
228              </Tooltip>
229            </HStack>
230          </HStack>
231          <Box position="relative" ref={editorRef} minHeight="300px">
232            <Controller
233              control={form.control}
234              name="config"
235              render={({ field }) => (
236                <JsonEditor json={config} onChange={field.onChange} />
237              )}
238            />
239            <IconButton
240              size="xs"
241              aria-label="full screen"
242              variant="ghost"
243              position="absolute"
244              top="2"
245              right="4"
246              onClick={handleFullScreen}
247            >
248              {!isFullScreen ? <FullScreenIcon /> : <ExitFullScreenIcon />}
249            </IconButton>
250          </Box>
251        </FormControl>
252        <FormControl mt="4">
253          <HStack justifyContent="space-between">
254            <FormLabel>{t('core.logs')}</FormLabel>
255            <Text as={FormLabel}>{t(`core.socket.${status}`)}</Text>
256          </HStack>
257          <Box
258            border="1px solid"
259            borderColor="gray.300"
260            bg="#F9F9F9"
261            _dark={{
262              borderColor: 'gray.500',
263              bg: '#2e3440',
264            }}
265            borderRadius={5}
266            minHeight="200px"
267            maxHeight={'250px'}
268            p={2}
269            overflowY="auto"
270            ref={logsDiv}
271          >
272            {logs.map((message, i) => (
273              <Text fontSize="xs" opacity={0.8} key={i} whiteSpace="pre-line">
274                {message}
275              </Text>
276            ))}
277          </Box>
278        </FormControl>
279      </ModalBody>
280      <ModalFooter>
281        <HStack w="full" justifyContent="space-between">
282          <Box>
283            <Button
284              size="sm"
285              leftIcon={
286                <ReloadIcon
287                  className={classNames({
288                    'animate-spin': isRestarting,
289                  })}
290                />
291              }
292              onClick={() => handleRestartCore()}
293            >
294              {t(isRestarting ? 'core.restarting' : 'core.restartCore')}
295            </Button>
296          </Box>
297          <HStack>
298            <Button
299              size="sm"
300              variant="solid"
301              colorScheme="primary"
302              px="5"
303              type="submit"
304              isDisabled={isLoading || isPostLoading}
305              isLoading={isPostLoading}
306            >
307              {t('core.save')}
308            </Button>
309          </HStack>
310        </HStack>
311      </ModalFooter>
312    </form>
313  );
314};
315export const CoreSettingsModal: FC = () => {
316  const { isEditingCore } = useNodes();
317  const onClose = useNodes.setState.bind(null, { isEditingCore: false });
318  const { t } = useTranslation();
319
320  return (
321    <Modal isOpen={isEditingCore} onClose={onClose} size="3xl">
322      <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
323      <ModalContent mx="3" w="full">
324        <ModalHeader pt={6}>
325          <HStack gap={2}>
326            <Icon color="primary">
327              <UsageIcon color="white" />
328            </Icon>
329            <Text fontWeight="semibold" fontSize="lg">
330              {t('core.title')}
331            </Text>
332          </HStack>
333        </ModalHeader>
334        <ModalCloseButton mt={3} />
335        <CoreSettingModalContent />
336      </ModalContent>
337    </Modal>
338  );
339};