XrayLogModal.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import type { TFunction } from 'i18next';
  3. import { useTranslation } from 'react-i18next';
  4. import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
  5. import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
  6. import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
  7. import { activateOnKey } from '@/utils/a11y';
  8. import { useDatepicker } from '@/hooks/useDatepicker';
  9. import { useMediaQuery } from '@/hooks/useMediaQuery';
  10. import './XrayLogModal.css';
  11. interface XrayLogModalProps {
  12. open: boolean;
  13. onClose: () => void;
  14. }
  15. interface XrayLogEntry {
  16. DateTime?: string | number;
  17. FromAddress?: string;
  18. ToAddress?: string;
  19. Inbound?: string;
  20. Outbound?: string;
  21. Email?: string;
  22. Event?: number;
  23. }
  24. // The downloaded log is a data format people grep, so it keeps the stable
  25. // tokens; only what is rendered on screen follows the panel language.
  26. const EVENT_TOKENS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
  27. const EVENT_KEYS: Record<number, string> = {
  28. 0: 'pages.index.accessDirect',
  29. 1: 'pages.index.accessBlocked',
  30. 2: 'pages.index.accessProxy',
  31. };
  32. const EVENT_COLORS: Record<number, string> = { 0: 'green', 1: 'red', 2: 'blue' };
  33. function eventToken(ev?: number): string {
  34. return EVENT_TOKENS[ev ?? -1] ?? String(ev ?? '');
  35. }
  36. function eventLabel(t: TFunction, ev?: number): string {
  37. const key = EVENT_KEYS[ev ?? -1];
  38. return key ? t(key) : String(ev ?? '');
  39. }
  40. function eventColor(ev?: number): string {
  41. return EVENT_COLORS[ev ?? -1] ?? 'default';
  42. }
  43. function shortTime(value?: string | number): string {
  44. if (!value) return '';
  45. const d = new Date(value);
  46. if (isNaN(d.getTime())) return '';
  47. const hh = String(d.getHours()).padStart(2, '0');
  48. const mm = String(d.getMinutes()).padStart(2, '0');
  49. const ss = String(d.getSeconds()).padStart(2, '0');
  50. return `${hh}:${mm}:${ss}`;
  51. }
  52. const AUTO_UPDATE_INTERVAL = 5000;
  53. export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
  54. const { t } = useTranslation();
  55. const { datepicker } = useDatepicker();
  56. const { isMobile } = useMediaQuery();
  57. const [rows, setRows] = useState('20');
  58. const [filter, setFilter] = useState('');
  59. const [showDirect, setShowDirect] = useState(true);
  60. const [showBlocked, setShowBlocked] = useState(true);
  61. const [showProxy, setShowProxy] = useState(true);
  62. const [autoUpdate, setAutoUpdate] = useState(false);
  63. const [loading, setLoading] = useState(false);
  64. const [logs, setLogs] = useState<XrayLogEntry[]>([]);
  65. const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
  66. const runRefresh = useCallback(async () => {
  67. try {
  68. const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
  69. filter,
  70. showDirect,
  71. showBlocked,
  72. showProxy,
  73. });
  74. if (msg?.success) setLogs(msg.obj || []);
  75. await PromiseUtil.sleep(300);
  76. } finally {
  77. setLoading(false);
  78. }
  79. }, [rows, filter, showDirect, showBlocked, showProxy]);
  80. const refresh = useCallback(() => {
  81. setLoading(true);
  82. void runRefresh();
  83. }, [runRefresh]);
  84. const refreshRef = useRef(refresh);
  85. useEffect(() => {
  86. refreshRef.current = refresh;
  87. });
  88. // The spinner is raised during render so the fetch effect stays side-effect
  89. // free until its response lands.
  90. const refreshKey = open
  91. ? `${rows}\u0000${showDirect}\u0000${showBlocked}\u0000${showProxy}`
  92. : null;
  93. const [loadingKey, setLoadingKey] = useState<string | null>(null);
  94. if (refreshKey !== loadingKey) {
  95. setLoadingKey(refreshKey);
  96. if (refreshKey) setLoading(true);
  97. }
  98. useEffect(() => {
  99. if (open) void runRefresh();
  100. }, [open, rows, showDirect, showBlocked, showProxy, runRefresh]);
  101. useEffect(() => {
  102. if (!open || !autoUpdate) return;
  103. const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
  104. return () => clearInterval(id);
  105. }, [open, autoUpdate]);
  106. function fullDate(value?: string | number): string {
  107. return IntlUtil.formatDate(value, datepicker);
  108. }
  109. function download() {
  110. if (!Array.isArray(logs) || logs.length === 0) {
  111. FileManager.downloadTextFile('', 'x-ui.log');
  112. return;
  113. }
  114. const lines = logs
  115. .map((l) => {
  116. try {
  117. const dt = l.DateTime ? new Date(l.DateTime) : null;
  118. const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
  119. const eventText = eventToken(l.Event);
  120. const emailPart = l.Email ? ` Email=${l.Email}` : '';
  121. return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
  122. } catch {
  123. return JSON.stringify(l);
  124. }
  125. })
  126. .join('\n');
  127. FileManager.downloadTextFile(lines, 'x-ui.log');
  128. }
  129. return (
  130. <Modal
  131. open={open}
  132. footer={null}
  133. width={isMobile ? '100vw' : '80vw'}
  134. style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
  135. className={isMobile ? 'xraylog-modal-mobile' : undefined}
  136. onCancel={onClose}
  137. title={
  138. <>
  139. {t('pages.index.accessLogs')}
  140. <SyncOutlined
  141. spin={loading}
  142. className="reload-icon"
  143. role="button"
  144. tabIndex={0}
  145. aria-label={t('refresh')}
  146. onClick={refresh}
  147. onKeyDown={activateOnKey(refresh)}
  148. />
  149. </>
  150. }
  151. >
  152. <Form layout="inline" className="log-toolbar">
  153. <Form.Item>
  154. <Select
  155. value={rows}
  156. size="small"
  157. style={{ width: 100 }}
  158. onChange={setRows}
  159. options={[
  160. { value: '20', label: '20' },
  161. { value: '50', label: '50' },
  162. { value: '100', label: '100' },
  163. { value: '500', label: '500' },
  164. { value: '1000', label: '1000' },
  165. ]}
  166. />
  167. </Form.Item>
  168. <Form.Item label={t('filter')} className="filter-item">
  169. <Input
  170. value={filter}
  171. size="small"
  172. onChange={(e) => setFilter(e.target.value)}
  173. onKeyUp={(e) => {
  174. if (e.key === 'Enter') refresh();
  175. }}
  176. />
  177. </Form.Item>
  178. <Form.Item>
  179. <Checkbox checked={showDirect} onChange={(e) => setShowDirect(e.target.checked)}>
  180. Direct
  181. </Checkbox>
  182. <Checkbox checked={showBlocked} onChange={(e) => setShowBlocked(e.target.checked)}>
  183. Blocked
  184. </Checkbox>
  185. <Checkbox checked={showProxy} onChange={(e) => setShowProxy(e.target.checked)}>
  186. Proxy
  187. </Checkbox>
  188. <Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
  189. {t('pages.index.autoUpdate')}
  190. </Checkbox>
  191. </Form.Item>
  192. <Form.Item className="download-item">
  193. <Button
  194. type="primary"
  195. onClick={download}
  196. icon={<DownloadOutlined />}
  197. aria-label={t('download')}
  198. />
  199. </Form.Item>
  200. </Form>
  201. <div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
  202. {orderedLogs.length === 0 ? (
  203. <div className="log-empty">No Record...</div>
  204. ) : isMobile ? (
  205. orderedLogs.map((log, idx) => (
  206. <div key={idx} className="log-card">
  207. <div className="log-card-head">
  208. <span className="log-time" title={fullDate(log.DateTime)}>
  209. {shortTime(log.DateTime)}
  210. </span>
  211. <Tag color={eventColor(log.Event)} className="log-event-tag">
  212. {eventLabel(t, log.Event)}
  213. </Tag>
  214. </div>
  215. <div className="log-route">
  216. <span className="log-addr">{log.FromAddress}</span>
  217. <span className="log-arrow">→</span>
  218. <span className="log-addr">{log.ToAddress}</span>
  219. </div>
  220. <div className="log-meta">
  221. {log.Inbound && (
  222. <span className="log-meta-pair">
  223. <span className="log-meta-key">in</span>
  224. <span className="log-meta-val">{log.Inbound}</span>
  225. </span>
  226. )}
  227. {log.Outbound && (
  228. <span className="log-meta-pair">
  229. <span className="log-meta-key">out</span>
  230. <span className="log-meta-val">{log.Outbound}</span>
  231. </span>
  232. )}
  233. {log.Email && (
  234. <span className="log-meta-pair">
  235. <span className="log-meta-key">email</span>
  236. <span className="log-meta-val">{log.Email}</span>
  237. </span>
  238. )}
  239. </div>
  240. </div>
  241. ))
  242. ) : (
  243. <table className="xraylog-table">
  244. <thead>
  245. <tr>
  246. <th>Date</th>
  247. <th>From</th>
  248. <th>To</th>
  249. <th>Inbound</th>
  250. <th>Outbound</th>
  251. <th>Email</th>
  252. </tr>
  253. </thead>
  254. <tbody>
  255. {orderedLogs.map((log, idx) => (
  256. <tr key={idx} className={`log-row-${log.Event}`}>
  257. <td>
  258. <b>{fullDate(log.DateTime)}</b>
  259. </td>
  260. <td>{log.FromAddress}</td>
  261. <td>{log.ToAddress}</td>
  262. <td>{log.Inbound}</td>
  263. <td>{log.Outbound}</td>
  264. <td>{log.Email}</td>
  265. </tr>
  266. ))}
  267. </tbody>
  268. </table>
  269. )}
  270. </div>
  271. </Modal>
  272. );
  273. }