AmneziaWGLogModal.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
  4. import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
  5. import { HttpUtil, FileManager, IntlUtil, PromiseUtil, SizeFormatter } from '@/utils';
  6. import { activateOnKey } from '@/utils/a11y';
  7. import { useDatepicker } from '@/hooks/useDatepicker';
  8. import { useMediaQuery } from '@/hooks/useMediaQuery';
  9. import type { AmneziaWGLogs } from '@/generated/types';
  10. import './XrayLogModal.css';
  11. import './AmneziaWGLogModal.css';
  12. interface AmneziaWGLogModalProps {
  13. open: boolean;
  14. onClose: () => void;
  15. }
  16. const AUTO_UPDATE_INTERVAL = 5000;
  17. function shortTime(value?: number): string {
  18. if (!value) return '';
  19. const d = new Date(value);
  20. if (isNaN(d.getTime())) return '';
  21. const hh = String(d.getHours()).padStart(2, '0');
  22. const mm = String(d.getMinutes()).padStart(2, '0');
  23. const ss = String(d.getSeconds()).padStart(2, '0');
  24. return `${hh}:${mm}:${ss}`;
  25. }
  26. export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
  27. const { t } = useTranslation();
  28. const { datepicker } = useDatepicker();
  29. const { isMobile } = useMediaQuery();
  30. const [rows, setRows] = useState('50');
  31. const [filter, setFilter] = useState('');
  32. const [autoUpdate, setAutoUpdate] = useState(false);
  33. const [loading, setLoading] = useState(false);
  34. const [logs, setLogs] = useState<Partial<AmneziaWGLogs>>({});
  35. const peers = useMemo(() => logs.peers ?? [], [logs.peers]);
  36. const events = useMemo(() => logs.events ?? [], [logs.events]);
  37. const runRefresh = useCallback(async () => {
  38. try {
  39. const msg = await HttpUtil.post<AmneziaWGLogs>(`/panel/api/server/amneziawglogs/${rows}`, {
  40. filter,
  41. });
  42. if (msg?.success) setLogs(msg.obj || {});
  43. await PromiseUtil.sleep(300);
  44. } finally {
  45. setLoading(false);
  46. }
  47. }, [rows, filter]);
  48. const refresh = useCallback(() => {
  49. setLoading(true);
  50. void runRefresh();
  51. }, [runRefresh]);
  52. const refreshRef = useRef(refresh);
  53. useEffect(() => {
  54. refreshRef.current = refresh;
  55. });
  56. // The spinner is raised during render so the fetch effect stays side-effect
  57. // free until its response lands.
  58. const refreshKey = open ? `${rows}|${filter}` : null;
  59. const [loadingKey, setLoadingKey] = useState<string | null>(null);
  60. if (refreshKey !== loadingKey) {
  61. setLoadingKey(refreshKey);
  62. if (refreshKey) setLoading(true);
  63. }
  64. useEffect(() => {
  65. if (open) void runRefresh();
  66. }, [open, rows, filter, runRefresh]);
  67. useEffect(() => {
  68. if (!open || !autoUpdate) return;
  69. const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
  70. return () => clearInterval(id);
  71. }, [open, autoUpdate]);
  72. function fullDate(value?: number): string {
  73. return value ? IntlUtil.formatDate(value, datepicker) : '';
  74. }
  75. function download() {
  76. const peerLines = peers.map((p) => {
  77. const at = p.handshake ? new Date(p.handshake).toISOString() : 'never';
  78. return `${at} IFACE=${p.interface || ''} INBOUND=${p.tag || ''} EMAIL=${p.email || ''} ENDPOINT=${p.endpoint || '-'} ALLOWEDIPS=${p.allowedIPs || ''} UP=${p.up ?? 0} DOWN=${p.down ?? 0} ONLINE=${p.online ? 'yes' : 'no'}`;
  79. });
  80. FileManager.downloadTextFile([...peerLines, '', ...events].join('\n'), 'amneziawg.log');
  81. }
  82. return (
  83. <Modal
  84. open={open}
  85. footer={null}
  86. width={isMobile ? '100vw' : '80vw'}
  87. style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
  88. className={isMobile ? 'xraylog-modal-mobile' : undefined}
  89. onCancel={onClose}
  90. title={
  91. <>
  92. {t('pages.index.amneziawgLogs')}
  93. <SyncOutlined
  94. spin={loading}
  95. className="reload-icon"
  96. role="button"
  97. tabIndex={0}
  98. aria-label={t('refresh')}
  99. onClick={refresh}
  100. onKeyDown={activateOnKey(refresh)}
  101. />
  102. </>
  103. }
  104. >
  105. <Form layout="inline" className="log-toolbar">
  106. <Form.Item>
  107. <Select
  108. value={rows}
  109. size="small"
  110. style={{ width: 70 }}
  111. onChange={setRows}
  112. options={[
  113. { value: '20', label: '20' },
  114. { value: '50', label: '50' },
  115. { value: '100', label: '100' },
  116. { value: '500', label: '500' },
  117. ]}
  118. />
  119. </Form.Item>
  120. <Form.Item label={t('filter')} className="filter-item">
  121. <Input
  122. value={filter}
  123. size="small"
  124. onChange={(e) => setFilter(e.target.value)}
  125. onKeyUp={(e) => {
  126. if (e.key === 'Enter') refresh();
  127. }}
  128. />
  129. </Form.Item>
  130. <Form.Item>
  131. <Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
  132. {t('pages.index.autoUpdate')}
  133. </Checkbox>
  134. </Form.Item>
  135. <Form.Item className="download-item">
  136. <Button
  137. type="primary"
  138. onClick={download}
  139. icon={<DownloadOutlined />}
  140. aria-label={t('download')}
  141. />
  142. </Form.Item>
  143. </Form>
  144. <div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
  145. {peers.length === 0 ? (
  146. <div className="log-empty">
  147. <Empty
  148. image={Empty.PRESENTED_IMAGE_SIMPLE}
  149. description={t('pages.index.amneziawgNoPeers')}
  150. />
  151. </div>
  152. ) : isMobile ? (
  153. peers.map((peer, idx) => (
  154. <div key={idx} className="log-card">
  155. <div className="log-card-head">
  156. <span className="log-time" title={fullDate(peer.handshake)}>
  157. {shortTime(peer.handshake) || '—'}
  158. </span>
  159. <Tag color={peer.online ? 'green' : 'default'} className="log-event-tag">
  160. {peer.online ? t('online') : t('pages.index.amneziawgIdle')}
  161. </Tag>
  162. </div>
  163. <div className="log-route">
  164. <span className="log-addr">{peer.endpoint || '—'}</span>
  165. <span className="log-arrow">→</span>
  166. <span className="log-addr">{peer.allowedIPs}</span>
  167. </div>
  168. <div className="log-meta">
  169. <span className="log-meta-pair">
  170. <span className="log-meta-key">iface</span>
  171. <span className="log-meta-val">{peer.interface}</span>
  172. </span>
  173. <span className="log-meta-pair">
  174. <span className="log-meta-key">inbound</span>
  175. <span className="log-meta-val">{peer.tag}</span>
  176. </span>
  177. {peer.email && (
  178. <span className="log-meta-pair">
  179. <span className="log-meta-key">email</span>
  180. <span className="log-meta-val">{peer.email}</span>
  181. </span>
  182. )}
  183. <span className="log-meta-pair">
  184. <span className="log-meta-key">↑↓</span>
  185. <span className="log-meta-val">
  186. {`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}
  187. </span>
  188. </span>
  189. </div>
  190. </div>
  191. ))
  192. ) : (
  193. <table className="xraylog-table">
  194. <thead>
  195. <tr>
  196. <th>{t('pages.index.amneziawgHandshake')}</th>
  197. <th>{t('pages.index.amneziawgInterface')}</th>
  198. <th>{t('pages.index.amneziawgInbound')}</th>
  199. <th>Email</th>
  200. <th>{t('pages.index.amneziawgEndpoint')}</th>
  201. <th>{t('pages.clients.amneziaWgAllowedIPs')}</th>
  202. <th>↑ / ↓</th>
  203. </tr>
  204. </thead>
  205. <tbody>
  206. {peers.map((peer, idx) => (
  207. <tr key={idx} className={peer.online ? undefined : 'log-row-offline'}>
  208. <td>
  209. <b>{fullDate(peer.handshake) || '—'}</b>
  210. </td>
  211. <td>{peer.interface}</td>
  212. <td>{peer.tag}</td>
  213. <td>{peer.email}</td>
  214. <td>{peer.endpoint || '—'}</td>
  215. <td>{peer.allowedIPs}</td>
  216. <td>{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}</td>
  217. </tr>
  218. ))}
  219. </tbody>
  220. </table>
  221. )}
  222. </div>
  223. <div className="awglog-events-title">{t('pages.index.amneziawgEvents')}</div>
  224. <div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
  225. {events.length === 0 ? (
  226. <div className="log-empty">{t('pages.index.amneziawgNoEvents')}</div>
  227. ) : (
  228. events.map((line, idx) => (
  229. <div key={idx} className="awglog-event-line">
  230. {line}
  231. </div>
  232. ))
  233. )}
  234. </div>
  235. </Modal>
  236. );
  237. }