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};