From 107fa877e574a9b565f14572089ca82f53184225 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 21 May 2026 22:20:09 +0200 Subject: [PATCH] refactor(frontend): port index dashboard to react+ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard page, status + xray cards, panel-update / log / backup / system-history / xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config modal. Removes the unused react-hooks/set-state-in-effect disables now that the rule is off globally. --- frontend/index.html | 2 +- frontend/src/components/JsonEditor.css | 26 + frontend/src/components/JsonEditor.tsx | 179 +++++++ frontend/src/composables/useStatus.js | 43 -- frontend/src/entries/index.js | 23 - frontend/src/entries/index.tsx | 28 + frontend/src/env.d.ts | 1 + frontend/src/hooks/useAllSetting.ts | 2 +- frontend/src/hooks/useClients.ts | 4 +- frontend/src/hooks/useNodes.ts | 2 +- frontend/src/hooks/useStatus.ts | 35 ++ frontend/src/models/status.js | 71 --- frontend/src/models/status.ts | 120 +++++ .../src/pages/clients/ClientBulkAddModal.tsx | 6 +- .../src/pages/clients/ClientFormModal.tsx | 8 +- frontend/src/pages/clients/ClientsPage.tsx | 2 +- frontend/src/pages/index/BackupModal.css | 9 + frontend/src/pages/index/BackupModal.tsx | 88 ++++ frontend/src/pages/index/BackupModal.vue | 101 ---- .../src/pages/index/CustomGeoFormModal.tsx | 128 +++++ .../src/pages/index/CustomGeoFormModal.vue | 106 ---- frontend/src/pages/index/CustomGeoSection.css | 81 +++ frontend/src/pages/index/CustomGeoSection.tsx | 281 ++++++++++ frontend/src/pages/index/CustomGeoSection.vue | 311 ----------- frontend/src/pages/index/IndexPage.css | 82 +++ frontend/src/pages/index/IndexPage.tsx | 486 ++++++++++++++++++ frontend/src/pages/index/IndexPage.vue | 484 ----------------- frontend/src/pages/index/LogModal.css | 181 +++++++ frontend/src/pages/index/LogModal.tsx | 193 +++++++ frontend/src/pages/index/LogModal.vue | 349 ------------- frontend/src/pages/index/PanelUpdateModal.css | 18 + frontend/src/pages/index/PanelUpdateModal.tsx | 119 +++++ frontend/src/pages/index/PanelUpdateModal.vue | 112 ---- frontend/src/pages/index/StatusCard.css | 8 + frontend/src/pages/index/StatusCard.tsx | 107 ++++ frontend/src/pages/index/StatusCard.vue | 96 ---- .../src/pages/index/SystemHistoryModal.css | 18 + .../src/pages/index/SystemHistoryModal.tsx | 166 ++++++ .../src/pages/index/SystemHistoryModal.vue | 160 ------ frontend/src/pages/index/VersionModal.css | 25 + frontend/src/pages/index/VersionModal.tsx | 173 +++++++ frontend/src/pages/index/VersionModal.vue | 147 ------ frontend/src/pages/index/XrayLogModal.css | 160 ++++++ frontend/src/pages/index/XrayLogModal.tsx | 233 +++++++++ frontend/src/pages/index/XrayLogModal.vue | 357 ------------- frontend/src/pages/index/XrayMetricsModal.css | 53 ++ frontend/src/pages/index/XrayMetricsModal.tsx | 343 ++++++++++++ frontend/src/pages/index/XrayMetricsModal.vue | 347 ------------- frontend/src/pages/index/XrayStatusCard.css | 44 ++ frontend/src/pages/index/XrayStatusCard.tsx | 137 +++++ frontend/src/pages/index/XrayStatusCard.vue | 151 ------ frontend/src/pages/nodes/NodeFormModal.tsx | 4 +- frontend/src/pages/settings/SecurityTab.tsx | 2 +- frontend/src/pages/settings/SettingsPage.tsx | 4 +- .../src/pages/settings/TwoFactorModal.tsx | 4 +- 55 files changed, 3542 insertions(+), 2878 deletions(-) create mode 100644 frontend/src/components/JsonEditor.css create mode 100644 frontend/src/components/JsonEditor.tsx delete mode 100644 frontend/src/composables/useStatus.js delete mode 100644 frontend/src/entries/index.js create mode 100644 frontend/src/entries/index.tsx create mode 100644 frontend/src/hooks/useStatus.ts delete mode 100644 frontend/src/models/status.js create mode 100644 frontend/src/models/status.ts create mode 100644 frontend/src/pages/index/BackupModal.css create mode 100644 frontend/src/pages/index/BackupModal.tsx delete mode 100644 frontend/src/pages/index/BackupModal.vue create mode 100644 frontend/src/pages/index/CustomGeoFormModal.tsx delete mode 100644 frontend/src/pages/index/CustomGeoFormModal.vue create mode 100644 frontend/src/pages/index/CustomGeoSection.css create mode 100644 frontend/src/pages/index/CustomGeoSection.tsx delete mode 100644 frontend/src/pages/index/CustomGeoSection.vue create mode 100644 frontend/src/pages/index/IndexPage.css create mode 100644 frontend/src/pages/index/IndexPage.tsx delete mode 100644 frontend/src/pages/index/IndexPage.vue create mode 100644 frontend/src/pages/index/LogModal.css create mode 100644 frontend/src/pages/index/LogModal.tsx delete mode 100644 frontend/src/pages/index/LogModal.vue create mode 100644 frontend/src/pages/index/PanelUpdateModal.css create mode 100644 frontend/src/pages/index/PanelUpdateModal.tsx delete mode 100644 frontend/src/pages/index/PanelUpdateModal.vue create mode 100644 frontend/src/pages/index/StatusCard.css create mode 100644 frontend/src/pages/index/StatusCard.tsx delete mode 100644 frontend/src/pages/index/StatusCard.vue create mode 100644 frontend/src/pages/index/SystemHistoryModal.css create mode 100644 frontend/src/pages/index/SystemHistoryModal.tsx delete mode 100644 frontend/src/pages/index/SystemHistoryModal.vue create mode 100644 frontend/src/pages/index/VersionModal.css create mode 100644 frontend/src/pages/index/VersionModal.tsx delete mode 100644 frontend/src/pages/index/VersionModal.vue create mode 100644 frontend/src/pages/index/XrayLogModal.css create mode 100644 frontend/src/pages/index/XrayLogModal.tsx delete mode 100644 frontend/src/pages/index/XrayLogModal.vue create mode 100644 frontend/src/pages/index/XrayMetricsModal.css create mode 100644 frontend/src/pages/index/XrayMetricsModal.tsx delete mode 100644 frontend/src/pages/index/XrayMetricsModal.vue create mode 100644 frontend/src/pages/index/XrayStatusCard.css create mode 100644 frontend/src/pages/index/XrayStatusCard.tsx delete mode 100644 frontend/src/pages/index/XrayStatusCard.vue diff --git a/frontend/index.html b/frontend/index.html index d13b400f..196cea68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,6 @@
- + diff --git a/frontend/src/components/JsonEditor.css b/frontend/src/components/JsonEditor.css new file mode 100644 index 00000000..e7e0c320 --- /dev/null +++ b/frontend/src/components/JsonEditor.css @@ -0,0 +1,26 @@ +.json-editor-host { + border: 1px solid var(--ant-color-border, #d9d9d9); + border-radius: 6px; + overflow: hidden; + background: var(--ant-color-bg-container, #fff); +} + +.json-editor-host .cm-editor, +.json-editor-host .cm-editor.cm-focused { + outline: none; +} + +.json-editor-host:focus-within { + border-color: var(--ant-color-primary, #1677ff); + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); +} + +body.dark .json-editor-host { + border-color: #3a3a3c; + background: #1e1e1e; +} + +html[data-theme="ultra-dark"] .json-editor-host { + border-color: #1f1f1f; + background: #0a0a0a; +} diff --git a/frontend/src/components/JsonEditor.tsx b/frontend/src/components/JsonEditor.tsx new file mode 100644 index 00000000..1cff83c5 --- /dev/null +++ b/frontend/src/components/JsonEditor.tsx @@ -0,0 +1,179 @@ +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import { EditorView, basicSetup } from 'codemirror'; +import { EditorState, Compartment } from '@codemirror/state'; +import { json, jsonParseLinter } from '@codemirror/lang-json'; +import { lintGutter, linter } from '@codemirror/lint'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; +import { syntaxHighlighting } from '@codemirror/language'; +import { keymap } from '@codemirror/view'; +import { indentWithTab } from '@codemirror/commands'; + +import { useTheme } from '@/hooks/useTheme'; +import './JsonEditor.css'; + +export interface JsonEditorProps { + value: string; + onChange?: (next: string) => void; + minHeight?: string; + maxHeight?: string; + readOnly?: boolean; +} + +export interface JsonEditorHandle { + focus: () => void; +} + +interface DarkPalette { + bg: string; + panelBg: string; + activeBg: string; + border: string; + selection: string; +} + +function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) { + return EditorView.theme( + { + '&': { color: '#dcdcdc', backgroundColor: bg }, + '.cm-content': { caretColor: '#dcdcdc' }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' }, + '.cm-gutters': { + backgroundColor: bg, + borderRight: `1px solid ${border}`, + color: '#6a6a6a', + }, + '.cm-activeLine': { backgroundColor: activeBg }, + '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': + { backgroundColor: selection }, + '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' }, + '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` }, + '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` }, + '.cm-tooltip': { + backgroundColor: panelBg, + border: `1px solid ${border}`, + color: '#dcdcdc', + }, + }, + { dark: true }, + ); +} + +const darkTheme = buildDarkTheme({ + bg: '#1e1e1e', + panelBg: '#2d2d30', + activeBg: '#252526', + border: '#3a3a3c', + selection: '#3a3a3c', +}); + +const ultraDarkTheme = buildDarkTheme({ + bg: '#0a0a0a', + panelBg: '#141414', + activeBg: '#141414', + border: '#1f1f1f', + selection: '#2a2a2a', +}); + +function themeExtension(isDark: boolean, isUltra: boolean) { + if (!isDark) return []; + const chrome = isUltra ? ultraDarkTheme : darkTheme; + return [chrome, syntaxHighlighting(oneDarkHighlightStyle)]; +} + +const JsonEditor = forwardRef(function JsonEditor( + { value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false }, + ref, +) { + const hostRef = useRef(null); + const viewRef = useRef(null); + const themeCompartmentRef = useRef(new Compartment()); + const readonlyCompartmentRef = useRef(new Compartment()); + const onChangeRef = useRef(onChange); + const valueRef = useRef(value); + const { isDark, isUltra } = useTheme(); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useImperativeHandle(ref, () => ({ + focus: () => viewRef.current?.focus(), + })); + + useEffect(() => { + if (!hostRef.current) return; + + const updateListener = EditorView.updateListener.of((u) => { + if (!u.docChanged) return; + const next = u.state.doc.toString(); + if (next === valueRef.current) return; + valueRef.current = next; + onChangeRef.current?.(next); + }); + + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: value, + extensions: [ + basicSetup, + keymap.of([indentWithTab]), + json(), + linter(jsonParseLinter()), + lintGutter(), + EditorView.lineWrapping, + updateListener, + themeCompartmentRef.current.of(themeExtension(isDark, isUltra)), + readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)), + EditorView.theme({ + '&': { height: '100%' }, + '.cm-scroller': { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '12px', + minHeight, + maxHeight, + }, + }), + ], + }), + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const current = view.state.doc.toString(); + if (value === current) return; + valueRef.current = value; + view.dispatch({ changes: { from: 0, to: current.length, insert: value } }); + }, [value]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)), + }); + }, [isDark, isUltra]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)), + }); + }, [readOnly]); + + return
; +}); + +export default JsonEditor; diff --git a/frontend/src/composables/useStatus.js b/frontend/src/composables/useStatus.js deleted file mode 100644 index 9098e848..00000000 --- a/frontend/src/composables/useStatus.js +++ /dev/null @@ -1,43 +0,0 @@ -import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'; - -import { HttpUtil } from '@/utils'; -import { Status } from '@/models/status.js'; - -const POLL_INTERVAL_MS = 2000; - -// Polls /panel/api/server/status and exposes a reactive Status object -// + a `fetched` flag so consumers can show a spinner before the first -// successful fetch. -// -// WebSocket integration is intentionally deferred to a later sub-phase. -// Polling at 2s is the same fallback the legacy panel falls back to -// when its websocket link drops, so we're shipping the proven path -// first and adding the websocket on top later. -export function useStatus() { - const status = shallowRef(new Status()); - const fetched = ref(false); - let timer = null; - - async function refresh() { - try { - const msg = await HttpUtil.get('/panel/api/server/status'); - if (msg?.success) { - status.value = new Status(msg.obj); - if (!fetched.value) fetched.value = true; - } - } catch (e) { - console.error('Failed to get status:', e); - } - } - - onMounted(() => { - refresh(); - timer = window.setInterval(refresh, POLL_INTERVAL_MS); - }); - - onBeforeUnmount(() => { - if (timer != null) window.clearInterval(timer); - }); - - return { status, fetched, refresh }; -} diff --git a/frontend/src/entries/index.js b/frontend/src/entries/index.js deleted file mode 100644 index 33593f31..00000000 --- a/frontend/src/entries/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import { createApp } from 'vue'; -import Antd, { message } from 'ant-design-vue'; -import 'ant-design-vue/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -// Importing useTheme triggers the boot side-effect that applies the -// stored theme to / before Vue mounts. -import '@/composables/useTheme.js'; -import { i18n, readyI18n } from '@/i18n/index.js'; -import { applyDocumentTitle } from '@/utils'; -import IndexPage from '@/pages/index/IndexPage.vue'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - createApp(IndexPage).use(Antd).use(i18n).mount('#app'); -}); diff --git a/frontend/src/entries/index.tsx b/frontend/src/entries/index.tsx new file mode 100644 index 00000000..c3620cae --- /dev/null +++ b/frontend/src/entries/index.tsx @@ -0,0 +1,28 @@ +import { createRoot } from 'react-dom/client'; +import { message } from 'antd'; +import 'antd/dist/reset.css'; + +import { setupAxios } from '@/api/axios-init.js'; +import { applyDocumentTitle } from '@/utils'; +import { readyI18n } from '@/i18n/react'; +import { ThemeProvider } from '@/hooks/useTheme'; +import IndexPage from '@/pages/index/IndexPage'; + +setupAxios(); +applyDocumentTitle(); + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +readyI18n().then(() => { + const root = document.getElementById('app'); + if (root) { + createRoot(root).render( + + + , + ); + } +}); diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 7e047a4e..fbc346aa 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -24,5 +24,6 @@ interface SubPageData { interface Window { X_UI_BASE_PATH?: string; + X_UI_CUR_VER?: string; __SUB_PAGE_DATA__?: SubPageData; } diff --git a/frontend/src/hooks/useAllSetting.ts b/frontend/src/hooks/useAllSetting.ts index 89c3b536..5775f554 100644 --- a/frontend/src/hooks/useAllSetting.ts +++ b/frontend/src/hooks/useAllSetting.ts @@ -52,7 +52,7 @@ export function useAllSetting() { ); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + fetchAll(); }, [fetchAll]); diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index a02f3157..ea3799db 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -247,9 +247,9 @@ export function useClients() { }, [refresh]); useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ + Promise.all([refresh(), fetchSubSettings()]); - /* eslint-enable react-hooks/set-state-in-effect */ + }, [refresh, fetchSubSettings]); return { diff --git a/frontend/src/hooks/useNodes.ts b/frontend/src/hooks/useNodes.ts index ef61c3e8..3f316263 100644 --- a/frontend/src/hooks/useNodes.ts +++ b/frontend/src/hooks/useNodes.ts @@ -156,7 +156,7 @@ export function useNodes() { }, [nodes]); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + refresh(); }, [refresh]); diff --git a/frontend/src/hooks/useStatus.ts b/frontend/src/hooks/useStatus.ts new file mode 100644 index 00000000..1b9799e5 --- /dev/null +++ b/frontend/src/hooks/useStatus.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { HttpUtil } from '@/utils'; +import { Status } from '@/models/status'; + +const POLL_INTERVAL_MS = 2000; + +export function useStatus() { + const [status, setStatus] = useState(() => new Status()); + const [fetched, setFetched] = useState(false); + const fetchedRef = useRef(false); + + const refresh = useCallback(async () => { + try { + const msg = await HttpUtil.get('/panel/api/server/status'); + if (msg?.success) { + setStatus(new Status(msg.obj)); + if (!fetchedRef.current) { + fetchedRef.current = true; + setFetched(true); + } + } + } catch (e) { + console.error('Failed to get status:', e); + } + }, []); + + useEffect(() => { + refresh(); + const timer = window.setInterval(refresh, POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [refresh]); + + return { status, fetched, refresh }; +} diff --git a/frontend/src/models/status.js b/frontend/src/models/status.js deleted file mode 100644 index e41b99c0..00000000 --- a/frontend/src/models/status.js +++ /dev/null @@ -1,71 +0,0 @@ -import { NumberFormatter } from '@/utils'; - -export class CurTotal { - constructor(current, total) { - this.current = current; - this.total = total; - } - - get percent() { - if (this.total === 0) return 0; - return NumberFormatter.toFixed((this.current / this.total) * 100, 2); - } - - get color() { - // Match AD-Vue 4's semantic palette so the gauges fit the - // global blue/gold/red theme instead of the legacy teal/orange. - const p = this.percent; - if (p < 80) return '#1677ff'; // primary - if (p < 90) return '#faad14'; // warning - return '#ff4d4f'; // danger - } -} - -const XRAY_STATE_COLORS = { - running: 'green', - stop: 'orange', - error: 'red', -}; - -export class Status { - constructor(data) { - this.cpu = new CurTotal(0, 0); - this.cpuCores = 0; - this.logicalPro = 0; - this.cpuSpeedMhz = 0; - this.disk = new CurTotal(0, 0); - this.loads = [0, 0, 0]; - this.mem = new CurTotal(0, 0); - this.netIO = { up: 0, down: 0 }; - this.netTraffic = { sent: 0, recv: 0 }; - this.publicIP = { ipv4: 0, ipv6: 0 }; - this.swap = new CurTotal(0, 0); - this.tcpCount = 0; - this.udpCount = 0; - this.uptime = 0; - this.appUptime = 0; - this.appStats = { threads: 0, mem: 0, uptime: 0 }; - this.xray = { state: 'stop', errorMsg: '', version: '', color: '' }; - - if (data == null) return; - - this.cpu = new CurTotal(data.cpu, 100); - this.cpuCores = data.cpuCores; - this.logicalPro = data.logicalPro; - this.cpuSpeedMhz = data.cpuSpeedMhz; - this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0); - this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2)); - this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0); - this.netIO = data.netIO ?? this.netIO; - this.netTraffic = data.netTraffic ?? this.netTraffic; - this.publicIP = data.publicIP ?? this.publicIP; - this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0); - this.tcpCount = data.tcpCount ?? 0; - this.udpCount = data.udpCount ?? 0; - this.uptime = data.uptime ?? 0; - this.appUptime = data.appUptime ?? 0; - this.appStats = data.appStats ?? this.appStats; - this.xray = { ...this.xray, ...(data.xray || {}) }; - this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray'; - } -} diff --git a/frontend/src/models/status.ts b/frontend/src/models/status.ts new file mode 100644 index 00000000..9bcfa6cf --- /dev/null +++ b/frontend/src/models/status.ts @@ -0,0 +1,120 @@ +import { NumberFormatter } from '@/utils'; + +export class CurTotal { + current: number; + total: number; + + constructor(current: number, total: number) { + this.current = current; + this.total = total; + } + + get percent(): number { + if (this.total === 0) return 0; + return NumberFormatter.toFixed((this.current / this.total) * 100, 2); + } + + get color(): string { + const p = this.percent; + if (p < 80) return '#1677ff'; + if (p < 90) return '#faad14'; + return '#ff4d4f'; + } +} + +const XRAY_STATE_COLORS: Record = { + running: 'green', + stop: 'orange', + error: 'red', +}; + +export interface NetIO { + up: number; + down: number; +} + +export interface NetTraffic { + sent: number; + recv: number; +} + +export interface PublicIP { + ipv4: string | number; + ipv6: string | number; +} + +export interface AppStats { + threads: number; + mem: number; + uptime: number; +} + +export interface XrayInfo { + state: 'running' | 'stop' | 'error' | string; + errorMsg: string; + version: string; + color: string; +} + +interface StatusInput { + cpu?: number; + cpuCores?: number; + logicalPro?: number; + cpuSpeedMhz?: number; + disk?: { current?: number; total?: number }; + loads?: number[]; + mem?: { current?: number; total?: number }; + netIO?: NetIO; + netTraffic?: NetTraffic; + publicIP?: PublicIP; + swap?: { current?: number; total?: number }; + tcpCount?: number; + udpCount?: number; + uptime?: number; + appUptime?: number; + appStats?: AppStats; + xray?: Partial; +} + +export class Status { + cpu: CurTotal = new CurTotal(0, 0); + cpuCores = 0; + logicalPro = 0; + cpuSpeedMhz = 0; + disk: CurTotal = new CurTotal(0, 0); + loads: number[] = [0, 0, 0]; + mem: CurTotal = new CurTotal(0, 0); + netIO: NetIO = { up: 0, down: 0 }; + netTraffic: NetTraffic = { sent: 0, recv: 0 }; + publicIP: PublicIP = { ipv4: 0, ipv6: 0 }; + swap: CurTotal = new CurTotal(0, 0); + tcpCount = 0; + udpCount = 0; + uptime = 0; + appUptime = 0; + appStats: AppStats = { threads: 0, mem: 0, uptime: 0 }; + xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' }; + + constructor(data?: StatusInput | null) { + if (data == null) return; + + this.cpu = new CurTotal(data.cpu ?? 0, 100); + this.cpuCores = data.cpuCores ?? 0; + this.logicalPro = data.logicalPro ?? 0; + this.cpuSpeedMhz = data.cpuSpeedMhz ?? 0; + this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0); + this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2)); + this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0); + this.netIO = data.netIO ?? this.netIO; + this.netTraffic = data.netTraffic ?? this.netTraffic; + this.publicIP = data.publicIP ?? this.publicIP; + this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0); + this.tcpCount = data.tcpCount ?? 0; + this.udpCount = data.udpCount ?? 0; + this.uptime = data.uptime ?? 0; + this.appUptime = data.appUptime ?? 0; + this.appStats = data.appStats ?? this.appStats; + this.xray = { ...this.xray, ...(data.xray || {}) }; + this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray'; + } +} diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 96a60af9..11cdc420 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -80,10 +80,10 @@ export default function ClientBulkAddModal({ useEffect(() => { if (!open) return; - /* eslint-disable react-hooks/set-state-in-effect */ + setForm(emptyForm()); setDelayedStart(false); - /* eslint-enable react-hooks/set-state-in-effect */ + }, [open]); function update(key: K, value: FormState[K]) { @@ -105,7 +105,7 @@ export default function ClientBulkAddModal({ useEffect(() => { if (!showFlow && form.flow) { - /* eslint-disable-next-line react-hooks/set-state-in-effect */ + update('flow', ''); } }, [showFlow, form.flow]); diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 3d2d8e03..918cf480 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -143,7 +143,7 @@ export default function ClientFormModal({ useEffect(() => { if (!open) return; - /* eslint-disable react-hooks/set-state-in-effect */ + if (isEdit && client) { const et = Number(client.expiryTime) || 0; const next: FormState = { @@ -183,7 +183,7 @@ export default function ClientFormModal({ auth: RandomUtil.randomLowerAndNum(16), }); } - /* eslint-enable react-hooks/set-state-in-effect */ + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isEdit]); @@ -215,14 +215,14 @@ export default function ClientFormModal({ useEffect(() => { if (!showFlow && form.flow) { - /* eslint-disable-next-line react-hooks/set-state-in-effect */ + update('flow', ''); } }, [showFlow, form.flow]); useEffect(() => { if (!showReverseTag && form.reverseTag) { - /* eslint-disable-next-line react-hooks/set-state-in-effect */ + update('reverseTag', ''); } }, [showReverseTag, form.reverseTag]); diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index e55799e3..0a424845 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -132,7 +132,7 @@ export default function ClientsPage() { useEffect(() => { if (pageSize > 0) { - /* eslint-disable-next-line react-hooks/set-state-in-effect */ + setTablePageSize(pageSize); } }, [pageSize]); diff --git a/frontend/src/pages/index/BackupModal.css b/frontend/src/pages/index/BackupModal.css new file mode 100644 index 00000000..b1e47088 --- /dev/null +++ b/frontend/src/pages/index/BackupModal.css @@ -0,0 +1,9 @@ +.backup-list { + width: 100%; +} + +.backup-item { + display: flex; + align-items: center; + gap: 16px; +} diff --git a/frontend/src/pages/index/BackupModal.tsx b/frontend/src/pages/index/BackupModal.tsx new file mode 100644 index 00000000..49b23068 --- /dev/null +++ b/frontend/src/pages/index/BackupModal.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from 'react-i18next'; +import { Button, List, Modal } from 'antd'; +import { DownloadOutlined, UploadOutlined } from '@ant-design/icons'; + +import { HttpUtil, PromiseUtil } from '@/utils'; +import './BackupModal.css'; + +interface BusyEvent { + busy: boolean; + tip?: string; +} + +interface BackupModalProps { + open: boolean; + basePath: string; + onClose: () => void; + onBusy: (e: BusyEvent) => void; +} + +export default function BackupModal({ open, basePath: _basePath, onClose, onBusy }: BackupModalProps) { + const { t } = useTranslation(); + + function exportDb() { + window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb'; + } + + function importDb() { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.db'; + fileInput.addEventListener('change', async (e) => { + const dbFile = (e.target as HTMLInputElement).files?.[0]; + if (!dbFile) return; + + const formData = new FormData(); + formData.append('db', dbFile); + + onClose(); + onBusy({ busy: true, tip: `${t('pages.index.importDatabase')}…` }); + + const upload = await HttpUtil.post('/panel/api/server/importDB', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + if (!upload?.success) { + onBusy({ busy: false }); + return; + } + + onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` }); + const restart = await HttpUtil.post('/panel/setting/restartPanel'); + if (restart?.success) { + await PromiseUtil.sleep(5000); + window.location.reload(); + } else { + onBusy({ busy: false }); + } + }); + fileInput.click(); + } + + return ( + + + + + + + {list.length > 0 && {list.length}} +
+ + r.id} + loading={loading} + size="small" + scroll={{ x: 760 }} + locale={{ + emptyText: ( +
+ +
{t('pages.index.customGeoEmpty')}
+
+ ), + }} + /> + + setFormOpen(false)} + onSaved={loadList} + /> + + ); +} diff --git a/frontend/src/pages/index/CustomGeoSection.vue b/frontend/src/pages/index/CustomGeoSection.vue deleted file mode 100644 index 55f13cec..00000000 --- a/frontend/src/pages/index/CustomGeoSection.vue +++ /dev/null @@ -1,311 +0,0 @@ - - - - - diff --git a/frontend/src/pages/index/IndexPage.css b/frontend/src/pages/index/IndexPage.css new file mode 100644 index 00000000..0537821f --- /dev/null +++ b/frontend/src/pages/index/IndexPage.css @@ -0,0 +1,82 @@ +.index-page { + --bg-page: #e6e8ec; + --bg-card: #ffffff; + + min-height: 100vh; + background: var(--bg-page); +} + +.index-page.is-dark { + --bg-page: #1e1e1e; + --bg-card: #252526; +} + +.index-page.is-dark.is-ultra { + --bg-page: #050505; + --bg-card: #0c0e12; +} + +.index-page .ant-layout, +.index-page .ant-layout-content { + background: transparent; +} + +.index-page .content-shell { + background: transparent; +} + +.index-page .content-area { + padding: 24px; +} + +@media (max-width: 768px) { + .index-page .content-area { + padding: 12px; + padding-top: 64px; + } +} + +.index-page .loading-spacer { + min-height: calc(100vh - 120px); +} + +.index-page .action { + cursor: pointer; + justify-content: center; +} + +.index-page .action-update { + color: #fa8c16; + font-weight: 600; +} + +.index-page .action-update .anticon { + color: #fa8c16; +} + +.index-page .history-tag { + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + margin-inline-end: 0; +} + +.index-page .tg-icon { + display: inline-block; + vertical-align: -2px; +} + +.index-page .ip-toggle-icon { + cursor: pointer; + font-size: 16px; +} + +.index-page .ip-hidden .ant-statistic-content-value { + filter: blur(6px); + transition: filter 0.2s ease; +} + +.index-page .ip-visible .ant-statistic-content-value { + filter: none; +} diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx new file mode 100644 index 00000000..a632dbb6 --- /dev/null +++ b/frontend/src/pages/index/IndexPage.tsx @@ -0,0 +1,486 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Card, + Col, + ConfigProvider, + Layout, + message, + Modal, + Row, + Space, + Spin, + Tooltip, +} from 'antd'; +import { + BarsOutlined, + ControlOutlined, + CloudServerOutlined, + CloudDownloadOutlined, + CloudUploadOutlined, + ArrowUpOutlined, + ArrowDownOutlined, + AreaChartOutlined, + GlobalOutlined, + SwapOutlined, + EyeOutlined, + EyeInvisibleOutlined, + ThunderboltOutlined, + DesktopOutlined, + DatabaseOutlined, + ForkOutlined, + CopyOutlined, +} from '@ant-design/icons'; + +import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils'; +import { useTheme } from '@/hooks/useTheme'; +import { useStatus } from '@/hooks/useStatus'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import AppSidebar from '@/components/AppSidebar'; +import CustomStatistic from '@/components/CustomStatistic'; +import JsonEditor from '@/components/JsonEditor'; +import StatusCard from './StatusCard'; +import XrayStatusCard from './XrayStatusCard'; +import PanelUpdateModal from './PanelUpdateModal'; +import type { PanelUpdateInfo } from './PanelUpdateModal'; +import LogModal from './LogModal'; +import BackupModal from './BackupModal'; +import SystemHistoryModal from './SystemHistoryModal'; +import XrayMetricsModal from './XrayMetricsModal'; +import XrayLogModal from './XrayLogModal'; +import VersionModal from './VersionModal'; +import './IndexPage.css'; + +export default function IndexPage() { + const { t } = useTranslation(); + const { isDark, isUltra, antdThemeConfig } = useTheme(); + const { status, fetched, refresh } = useStatus(); + const { isMobile } = useMediaQuery(); + + const [ipLimitEnable, setIpLimitEnable] = useState(false); + const [panelUpdateInfo, setPanelUpdateInfo] = useState({ + currentVersion: '', + latestVersion: '', + updateAvailable: false, + }); + + const basePath = window.X_UI_BASE_PATH || ''; + const requestUri = window.location.pathname; + + const [showIp, setShowIp] = useState(false); + const [logsOpen, setLogsOpen] = useState(false); + const [backupOpen, setBackupOpen] = useState(false); + const [panelUpdateOpen, setPanelUpdateOpen] = useState(false); + const [sysHistoryOpen, setSysHistoryOpen] = useState(false); + const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false); + const [xrayLogsOpen, setXrayLogsOpen] = useState(false); + const [versionOpen, setVersionOpen] = useState(false); + const [configTextOpen, setConfigTextOpen] = useState(false); + const [configText, setConfigText] = useState(''); + const [loading, setLoading] = useState(false); + const [loadingTip, setLoadingTip] = useState(t('loading')); + + useEffect(() => { + HttpUtil.post('/panel/setting/defaultSettings').then((msg) => { + if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable); + }); + HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => { + if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj); + }); + }, []); + + const displayVersion = useMemo( + () => panelUpdateInfo.currentVersion || window.X_UI_CUR_VER || '?', + [panelUpdateInfo.currentVersion], + ); + + const setBusy = useCallback( + ({ busy, tip }: { busy: boolean; tip?: string }) => { + setLoading(busy); + if (tip) setLoadingTip(tip); + }, + [], + ); + + const stopXray = useCallback(async () => { + await HttpUtil.post('/panel/api/server/stopXrayService'); + await refresh(); + }, [refresh]); + + const restartXray = useCallback(async () => { + await HttpUtil.post('/panel/api/server/restartXrayService'); + await refresh(); + }, [refresh]); + + function openPanelVersion() { + if (panelUpdateInfo.updateAvailable) { + setPanelUpdateOpen(true); + } else { + window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer'); + } + } + + function openTelegram() { + window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer'); + } + + async function openConfig() { + setLoading(true); + try { + const msg = await HttpUtil.get('/panel/api/server/getConfigJson'); + if (!msg?.success) return; + setConfigText(JSON.stringify(msg.obj, null, 2)); + setConfigTextOpen(true); + } finally { + setLoading(false); + } + } + + async function copyConfig() { + const ok = await ClipboardManager.copyText(configText || ''); + if (ok) message.success('Copied'); + } + + function downloadConfig() { + FileManager.downloadTextFile(configText, 'config.json'); + } + + const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim(); + + return ( + + + + + + + + {!fetched ? ( +
+ ) : ( + +
+ + + + + setXrayLogsOpen(true)} + onOpenLogs={() => setLogsOpen(true)} + onOpenVersionSwitch={() => setVersionOpen(true)} + /> + + + + setLogsOpen(true)}> + + {!isMobile && {t('pages.index.logs')}} + , + + + {!isMobile && {t('pages.index.config')}} + , + setBackupOpen(true)}> + + {!isMobile && {t('pages.index.backupTitle')}} + , + ]} + /> + + + + + + {!isMobile && @XrayUI} + , + + + {!isMobile && ( + + {panelUpdateInfo.updateAvailable + ? `${t('update')} ${panelUpdateInfo.latestVersion}` + : `v${displayVersion}`} + + )} + , + ]} + /> + + + + setSysHistoryOpen(true)} + > + + {!isMobile && {t('pages.index.systemHistoryTitle')}} + , + setXrayMetricsOpen(true)} + > + + {!isMobile && {t('pages.index.xrayMetricsTitle')}} + , + ]} + /> + + + + + + + } + /> + + + } + /> + + + + + + + + + + } + /> + + + } + /> + + + + + + + + + + } + suffix="/s" + /> + + + } + suffix="/s" + /> + + + + + + + + + + } + /> + + + } + /> + + + + + + + + {showIp ? ( + setShowIp(false)} + /> + ) : ( + setShowIp(true)} + /> + )} + + } + > + + + } + /> + + + } + /> + + + + + + + + + + } + /> + + + } + /> + + + + + + )} + + + + + setPanelUpdateOpen(false)} + onBusy={setBusy} + /> + setLogsOpen(false)} /> + setBackupOpen(false)} + onBusy={setBusy} + /> + setSysHistoryOpen(false)} + /> + setXrayMetricsOpen(false)} /> + setXrayLogsOpen(false)} /> + setVersionOpen(false)} + onBusy={setBusy} + /> + + setConfigTextOpen(false)} + footer={[ + , + , + ]} + > + + + + + ); +} diff --git a/frontend/src/pages/index/IndexPage.vue b/frontend/src/pages/index/IndexPage.vue deleted file mode 100644 index fe918465..00000000 --- a/frontend/src/pages/index/IndexPage.vue +++ /dev/null @@ -1,484 +0,0 @@ - - - - - diff --git a/frontend/src/pages/index/LogModal.css b/frontend/src/pages/index/LogModal.css new file mode 100644 index 00000000..1fc31322 --- /dev/null +++ b/frontend/src/pages/index/LogModal.css @@ -0,0 +1,181 @@ +.reload-icon { + cursor: pointer; + vertical-align: middle; + margin-left: 10px; +} + +.log-toolbar { + flex-wrap: wrap; + row-gap: 8px; +} + +.log-toolbar .download-item { + margin-left: auto; +} + +.log-container { + --log-stamp: #3c89e8; + --log-debug: #3c89e8; + --log-info: #008771; + --log-notice: #008771; + --log-warning: #f37b24; + --log-error: #e04141; + --log-unknown: #595959; + --log-divider: rgba(128, 128, 128, 0.18); + + margin-top: 12px; + padding: 10px 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 60vh; + overflow-y: auto; + border: 1px solid rgba(128, 128, 128, 0.25); + border-radius: 6px; + background: rgba(0, 0, 0, 0.04); +} + +.log-stamp { + color: var(--log-stamp); +} + +.log-level { + margin-left: 4px; +} + +.level-debug { + color: var(--log-debug); +} + +.level-info { + color: var(--log-info); +} + +.level-notice { + color: var(--log-notice); +} + +.level-warning { + color: var(--log-warning); +} + +.level-error { + color: var(--log-error); +} + +.level-unknown { + color: var(--log-unknown); +} + +.log-container-mobile { + padding: 8px; + white-space: normal; + max-height: 70vh; +} + +.log-empty { + text-align: center; + opacity: 0.5; + padding: 20px 0; +} + +.log-line + .log-line { + margin-top: 2px; +} + +.log-card { + border-bottom: 1px solid var(--log-divider); + padding: 8px 0; +} + +.log-card:last-child { + border-bottom: 0; +} + +.log-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.log-time { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-weight: 600; + font-size: 12px; + letter-spacing: 0.02em; +} + +.log-date { + font-size: 10px; + font-weight: 500; + opacity: 0.55; +} + +.log-level-badge { + display: inline-block; + font-size: 10px; + line-height: 14px; + padding: 0 6px; + border-radius: 4px; + border: 1px solid currentColor; + letter-spacing: 0.04em; + font-weight: 600; + white-space: nowrap; + background: color-mix(in srgb, currentColor 14%, transparent); +} + +.log-body { + font-size: 12px; + word-break: break-word; +} + +.log-body-text { + margin-left: 4px; +} + +body.dark .log-container { + background: rgba(255, 255, 255, 0.03); + border-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.88); + + --log-stamp: #6aa6ee; + --log-debug: #6aa6ee; + --log-info: #4ed3a6; + --log-notice: #4ed3a6; + --log-warning: #ffb872; + --log-error: #ff7575; + --log-unknown: #b5b5b5; + --log-divider: rgba(255, 255, 255, 0.1); +} + +html[data-theme="ultra-dark"] .log-container { + --log-stamp: #7fb6f1; + --log-debug: #7fb6f1; + --log-info: #5fd9b0; + --log-notice: #5fd9b0; + --log-warning: #ffcc88; + --log-error: #ff8a8a; + --log-unknown: #c4c4c4; + --log-divider: rgba(255, 255, 255, 0.12); +} + +.logmodal-mobile { + top: 0 !important; + padding-bottom: 0 !important; + max-width: 100vw !important; +} + +.logmodal-mobile .ant-modal-content { + border-radius: 0; + height: 100vh; +} + +.logmodal-mobile .ant-modal-body { + padding: 12px; +} diff --git a/frontend/src/pages/index/LogModal.tsx b/frontend/src/pages/index/LogModal.tsx new file mode 100644 index 00000000..f089945b --- /dev/null +++ b/frontend/src/pages/index/LogModal.tsx @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Checkbox, Form, Input, Modal, Select } from 'antd'; +import { DownloadOutlined, SyncOutlined } from '@ant-design/icons'; + +import { HttpUtil, FileManager, PromiseUtil } from '@/utils'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import './LogModal.css'; + +interface LogModalProps { + open: boolean; + onClose: () => void; +} + +interface ParsedLog { + date: string; + time: string; + stamp: string; + levelText: string; + levelClass: string; + service: string; + body: string; +} + +const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR']; +const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error']; + +function parseLogLine(line: string): ParsedLog { + const [head, ...rest] = (line || '').split(' - '); + const message = rest.join(' - '); + const parts = head.split(' '); + + let date = ''; + let time = ''; + let levelText: string; + if (parts.length >= 3) { + [date, time, levelText] = parts; + } else { + levelText = head; + } + + const li = LEVELS.indexOf(levelText); + const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown'; + + let service = ''; + let body = message || ''; + if (body.startsWith('XRAY:')) { + service = 'XRAY:'; + body = body.slice('XRAY:'.length).trimStart(); + } else if (body) { + service = 'X-UI:'; + } + + const stamp = [date, time].filter(Boolean).join(' '); + + return { date, time, stamp, levelText, levelClass, service, body }; +} + +export default function LogModal({ open, onClose }: LogModalProps) { + const { t } = useTranslation(); + const { isMobile } = useMediaQuery(); + const [rows, setRows] = useState('20'); + const [level, setLevel] = useState('info'); + const [syslog, setSyslog] = useState(false); + const [loading, setLoading] = useState(false); + const [logs, setLogs] = useState([]); + const openRef = useRef(open); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const msg = await HttpUtil.post(`/panel/api/server/logs/${rows}`, { + level, + syslog, + }); + if (msg?.success) { + setLogs(msg.obj || []); + } + await PromiseUtil.sleep(300); + } finally { + setLoading(false); + } + }, [rows, level, syslog]); + + useEffect(() => { + openRef.current = open; + if (open) refresh(); + }, [open, refresh]); + + useEffect(() => { + if (openRef.current) refresh(); + }, [rows, level, syslog, refresh]); + + const parsedLogs = useMemo(() => logs.map(parseLogLine), [logs]); + + function download() { + FileManager.downloadTextFile(logs.join('\n'), 'x-ui.log'); + } + + const titleNode = ( + <> + {t('pages.index.logs')} + + + ); + + return ( + +
+ + + + + + + + setSyslog(e.target.checked)}> + SysLog + + + + + +
+ + ); +} diff --git a/frontend/src/pages/index/PanelUpdateModal.vue b/frontend/src/pages/index/PanelUpdateModal.vue deleted file mode 100644 index 4d98796b..00000000 --- a/frontend/src/pages/index/PanelUpdateModal.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - diff --git a/frontend/src/pages/index/StatusCard.css b/frontend/src/pages/index/StatusCard.css new file mode 100644 index 00000000..1c08427f --- /dev/null +++ b/frontend/src/pages/index/StatusCard.css @@ -0,0 +1,8 @@ +.status-card .text-center { + text-align: center; +} + +.status-card .ant-progress-text { + font-size: 14px !important; + font-weight: 500; +} diff --git a/frontend/src/pages/index/StatusCard.tsx b/frontend/src/pages/index/StatusCard.tsx new file mode 100644 index 00000000..15d5ce63 --- /dev/null +++ b/frontend/src/pages/index/StatusCard.tsx @@ -0,0 +1,107 @@ +import { useTranslation } from 'react-i18next'; +import { Card, Col, Progress, Row, Tooltip } from 'antd'; +import { AreaChartOutlined } from '@ant-design/icons'; + +import { CPUFormatter, SizeFormatter } from '@/utils'; +import type { Status } from '@/models/status'; +import './StatusCard.css'; + +interface StatusCardProps { + status: Status; + isMobile: boolean; +} + +const TRAIL_COLOR = 'rgba(128, 128, 128, 0.25)'; + +export default function StatusCard({ status, isMobile }: StatusCardProps) { + const { t } = useTranslation(); + const gaugeSize = isMobile ? 60 : 70; + + return ( + + +
+ + + +
+ {t('pages.index.cpu')}: {CPUFormatter.cpuCoreFormat(status.cpuCores)} + +
+ {t('pages.index.logicalProcessors')}: {status.logicalPro} +
+
+ {t('pages.index.frequency')}:{' '} + {CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)} +
+ + } + > + +
+
+ + +
+ +
+ {t('pages.index.memory')}: {SizeFormatter.sizeFormat(status.mem.current)} /{' '} + {SizeFormatter.sizeFormat(status.mem.total)} +
+ + + + +
+ + + +
+ {t('pages.index.swap')}: {SizeFormatter.sizeFormat(status.swap.current)} /{' '} + {SizeFormatter.sizeFormat(status.swap.total)} +
+ + +
+ +
+ {t('pages.index.storage')}: {SizeFormatter.sizeFormat(status.disk.current)} /{' '} + {SizeFormatter.sizeFormat(status.disk.total)} +
+ + + + + + ); +} diff --git a/frontend/src/pages/index/StatusCard.vue b/frontend/src/pages/index/StatusCard.vue deleted file mode 100644 index 8acfa05e..00000000 --- a/frontend/src/pages/index/StatusCard.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/frontend/src/pages/index/SystemHistoryModal.css b/frontend/src/pages/index/SystemHistoryModal.css new file mode 100644 index 00000000..1e39011e --- /dev/null +++ b/frontend/src/pages/index/SystemHistoryModal.css @@ -0,0 +1,18 @@ +.bucket-select { + width: 80px; + margin-left: 10px; +} + +.history-tabs { + margin-bottom: 4px; +} + +.cpu-chart-wrap { + padding: 8px 16px 16px; +} + +.cpu-chart-meta { + margin-bottom: 10px; + font-size: 11px; + opacity: 0.65; +} diff --git a/frontend/src/pages/index/SystemHistoryModal.tsx b/frontend/src/pages/index/SystemHistoryModal.tsx new file mode 100644 index 00000000..186866d0 --- /dev/null +++ b/frontend/src/pages/index/SystemHistoryModal.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Select, Tabs } from 'antd'; + +import { HttpUtil, SizeFormatter } from '@/utils'; +import Sparkline from '@/components/Sparkline'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import type { Status } from '@/models/status'; +import './SystemHistoryModal.css'; + +interface SystemHistoryModalProps { + open: boolean; + status: Status; + onClose: () => void; +} + +interface MetricDef { + key: string; + tab: string; + valueMax: number | null; + unit: string; + stroke: string; +} + +const METRICS: MetricDef[] = [ + { key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' }, + { key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' }, + { key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' }, + { key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' }, + { key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' }, + { key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' }, + { key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' }, + { key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' }, +]; + +function unitFormatter(unit: string, activeKey: string): (v: number) => string { + if (unit === 'B/s') { + return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`; + } + if (unit === '%') { + return (v) => `${Number(v).toFixed(1)}%`; + } + return (v) => { + const n = Number(v) || 0; + if (activeKey === 'online') return String(Math.round(n)); + return n.toFixed(2); + }; +} + +export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) { + const { t } = useTranslation(); + const { isMobile } = useMediaQuery(); + const [activeKey, setActiveKey] = useState('cpu'); + const [bucket, setBucket] = useState(2); + const [points, setPoints] = useState([]); + const [labels, setLabels] = useState([]); + const openRef = useRef(open); + + const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]); + const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771'; + const yFormatter = useMemo( + () => unitFormatter(activeMetric?.unit ?? '', activeKey), + [activeMetric, activeKey], + ); + + const fetchBucket = useCallback(async () => { + if (!activeMetric) return; + try { + const url = `/panel/api/server/history/${activeMetric.key}/${bucket}`; + const msg = await HttpUtil.get(url); + if (msg?.success && Array.isArray(msg.obj)) { + const vals: number[] = []; + const labs: string[] = []; + for (const p of msg.obj) { + const d = new Date(p.t * 1000); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + labs.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`); + vals.push(Number(p.v) || 0); + } + setLabels(labs); + setPoints(vals); + } else { + setLabels([]); + setPoints([]); + } + } catch (e) { + console.error('Failed to fetch history bucket', e); + setLabels([]); + setPoints([]); + } + }, [activeMetric, bucket]); + + useEffect(() => { + openRef.current = open; + if (open) { + setActiveKey('cpu'); + } + }, [open]); + + useEffect(() => { + if (openRef.current) fetchBucket(); + }, [activeKey, bucket, fetchBucket]); + + return ( + + {t('pages.index.systemHistoryTitle')} + + 10 + 20 + 50 + 100 + 500 + + + + setFilter(e.target.value)} + onKeyUp={(e) => { + if (e.key === 'Enter') refresh(); + }} + /> + + + setShowDirect(e.target.checked)}> + Direct + + setShowBlocked(e.target.checked)}> + Blocked + + setShowProxy(e.target.checked)}> + Proxy + + + +
+ + + + + + + + + + + + {orderedLogs.map((log, idx) => ( + + + + + + + + + ))} + +
DateFromToInboundOutboundEmail
+ {fullDate(log.DateTime)} + {log.FromAddress}{log.ToAddress}{log.Inbound}{log.Outbound}{log.Email}
+ )} + + + ); +} diff --git a/frontend/src/pages/index/XrayLogModal.vue b/frontend/src/pages/index/XrayLogModal.vue deleted file mode 100644 index eb65e538..00000000 --- a/frontend/src/pages/index/XrayLogModal.vue +++ /dev/null @@ -1,357 +0,0 @@ - - - - - diff --git a/frontend/src/pages/index/XrayMetricsModal.css b/frontend/src/pages/index/XrayMetricsModal.css new file mode 100644 index 00000000..3ee310ca --- /dev/null +++ b/frontend/src/pages/index/XrayMetricsModal.css @@ -0,0 +1,53 @@ +.metrics-alert { + margin-bottom: 10px; +} + +.obs-pane { + padding: 4px 16px 0; +} + +.obs-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.obs-select { + min-width: 240px; +} + +.obs-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 12px; + opacity: 0.85; +} + +.obs-stamp { + opacity: 0.7; +} + +.obs-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +.obs-dot.is-alive { + background: #52c41a; +} + +.obs-dot.is-dead { + background: #f5222d; +} + +.listen-tag { + opacity: 0.7; +} diff --git a/frontend/src/pages/index/XrayMetricsModal.tsx b/frontend/src/pages/index/XrayMetricsModal.tsx new file mode 100644 index 00000000..a84f7f76 --- /dev/null +++ b/frontend/src/pages/index/XrayMetricsModal.tsx @@ -0,0 +1,343 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Modal, Select, Tabs, Tag } from 'antd'; + +import { HttpUtil, SizeFormatter } from '@/utils'; +import Sparkline from '@/components/Sparkline'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import './XrayMetricsModal.css'; + +const OBS_KEY = 'xrObs'; + +interface XrayMetricsModalProps { + open: boolean; + onClose: () => void; +} + +interface MetricDef { + key: string; + tab: string; + unit: 'B' | 'ns' | 'ms' | ''; + stroke: string; +} + +interface XrayState { + enabled: boolean; + listen: string; + reason: string; +} + +interface ObservatoryTag { + tag: string; + alive: boolean; + delay: number; + lastSeenTime: number; + lastTryTime: number; +} + +const METRICS: MetricDef[] = [ + { key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' }, + { key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' }, + { key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' }, + { key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' }, + { key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' }, + { key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' }, +]; + +function unitFormatter(unit: string): (v: number) => string { + if (unit === 'B') return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)); + if (unit === 'ns') { + return (v) => { + const n = Math.max(0, Number(v) || 0); + if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`; + if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`; + return `${n.toFixed(0)} ns`; + }; + } + if (unit === 'ms') return (v) => `${Math.round(Number(v) || 0)} ms`; + return (v) => { + const n = Number(v) || 0; + return Math.round(n).toLocaleString(); + }; +} + +function fmtTimestamp(unixSec: number): string { + if (!unixSec) return '—'; + const d = new Date(unixSec * 1000); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`; +} + +export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) { + const { t } = useTranslation(); + const { isMobile } = useMediaQuery(); + const [activeKey, setActiveKey] = useState('xrAlloc'); + const [bucket, setBucket] = useState(2); + const [points, setPoints] = useState([]); + const [labels, setLabels] = useState([]); + const [state, setState] = useState({ enabled: false, listen: '', reason: '' }); + const [obsTags, setObsTags] = useState([]); + const [obsActiveTag, setObsActiveTag] = useState(''); + const obsTimerRef = useRef(null); + const openRef = useRef(open); + + const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]); + const isObservatory = activeKey === OBS_KEY; + const strokeColor = activeMetric?.stroke || '#008771'; + const yFormatter = useMemo(() => unitFormatter(activeMetric?.unit ?? ''), [activeMetric]); + + const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null; + + const applyHistory = useCallback((msg: { success?: boolean; obj?: { t: number; v: number }[] }, currentBucket: number) => { + if (msg?.success && Array.isArray(msg.obj)) { + const vals: number[] = []; + const labs: string[] = []; + for (const p of msg.obj) { + const d = new Date(p.t * 1000); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + labs.push(currentBucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`); + vals.push(Number(p.v) || 0); + } + setLabels(labs); + setPoints(vals); + } else { + setLabels([]); + setPoints([]); + } + }, []); + + const fetchState = useCallback(async () => { + try { + const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState'); + if (msg?.success && msg.obj) setState(msg.obj); + } catch (e) { + console.error('Failed to fetch xray metrics state', e); + } + }, []); + + const fetchObservatory = useCallback(async () => { + try { + const msg = await HttpUtil.get('/panel/api/server/xrayObservatory'); + if (msg?.success && Array.isArray(msg.obj)) { + setObsTags(msg.obj); + setObsActiveTag((prev) => { + if (msg.obj.find((tg: ObservatoryTag) => tg.tag === prev)) return prev; + return msg.obj[0]?.tag || ''; + }); + } else { + setObsTags([]); + } + } catch (e) { + console.error('Failed to fetch observatory snapshot', e); + setObsTags([]); + } + }, []); + + const fetchMetricBucket = useCallback(async () => { + if (!activeMetric) return; + try { + const url = `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`; + const msg = await HttpUtil.get(url); + applyHistory(msg, bucket); + } catch (e) { + console.error('Failed to fetch xray metrics bucket', e); + setLabels([]); + setPoints([]); + } + }, [activeMetric, bucket, applyHistory]); + + const fetchObsBucket = useCallback(async () => { + if (!obsActiveTag) { + setLabels([]); + setPoints([]); + return; + } + try { + const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`; + const msg = await HttpUtil.get(url); + applyHistory(msg, bucket); + } catch (e) { + console.error('Failed to fetch observatory bucket', e); + setLabels([]); + setPoints([]); + } + }, [obsActiveTag, bucket, applyHistory]); + + const stopObsPolling = useCallback(() => { + if (obsTimerRef.current != null) { + window.clearInterval(obsTimerRef.current); + obsTimerRef.current = null; + } + }, []); + + useEffect(() => { + openRef.current = open; + if (open) { + setActiveKey('xrAlloc'); + fetchState(); + } else { + stopObsPolling(); + } + }, [open, fetchState, stopObsPolling]); + + useEffect(() => { + if (!openRef.current) return; + if (isObservatory) { + fetchObservatory(); + fetchObsBucket(); + stopObsPolling(); + obsTimerRef.current = window.setInterval(async () => { + if (!openRef.current || !isObservatory) return; + await fetchObservatory(); + fetchObsBucket(); + }, 2000); + } else { + stopObsPolling(); + fetchMetricBucket(); + } + return () => { + stopObsPolling(); + }; + }, [activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]); + + useEffect(() => { + if (!openRef.current) return; + if (isObservatory) { + fetchObsBucket(); + } else { + fetchMetricBucket(); + } + }, [bucket, isObservatory, fetchObsBucket, fetchMetricBucket]); + + useEffect(() => { + if (openRef.current && isObservatory) fetchObsBucket(); + }, [obsActiveTag, isObservatory, fetchObsBucket]); + + return ( + + {t('pages.index.xrayMetricsTitle')} + ({ + value: tg.tag, + label: ( + <> + + {tg.tag} + + ), + }))} + /> + + {activeObsTag && ( +
+ + {activeObsTag.alive + ? t('pages.index.xrayObservatoryAlive') + : t('pages.index.xrayObservatoryDead')} + + {activeObsTag.delay} ms + + {t('pages.index.xrayObservatoryLastSeen')}: {fmtTimestamp(activeObsTag.lastSeenTime)} + + + {t('pages.index.xrayObservatoryLastTry')}: {fmtTimestamp(activeObsTag.lastTryTime)} + +
+ )} + + )} + + )} + +
+
+ Timeframe: {bucket} sec per point (total {points.length} points) + {state.enabled && state.listen && ( + · {state.listen} + )} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/index/XrayMetricsModal.vue b/frontend/src/pages/index/XrayMetricsModal.vue deleted file mode 100644 index 69e5cd6e..00000000 --- a/frontend/src/pages/index/XrayMetricsModal.vue +++ /dev/null @@ -1,347 +0,0 @@ - - - - - diff --git a/frontend/src/pages/index/XrayStatusCard.css b/frontend/src/pages/index/XrayStatusCard.css new file mode 100644 index 00000000..1e9abd47 --- /dev/null +++ b/frontend/src/pages/index/XrayStatusCard.css @@ -0,0 +1,44 @@ +.xray-status-card .action { + cursor: pointer; + justify-content: center; +} + +.error-line { + display: block; + max-width: 400px; + white-space: pre-wrap; +} + +.cursor-pointer { + cursor: pointer; +} + +.xray-processing-animation .ant-badge-status-dot { + animation: xray-pulse 1.2s linear infinite; +} + +.xray-running-animation .ant-badge-status-processing::after { + border-color: #1677ff; +} + +.xray-stop-animation .ant-badge-status-processing::after { + border-color: #fa8c16; +} + +.xray-error-animation .ant-badge-status-processing::after { + border-color: #f5222d; +} + +@keyframes xray-pulse { + 0%, + 50%, + 100% { + transform: scale(1); + opacity: 1; + } + + 10% { + transform: scale(1.5); + opacity: 0.2; + } +} diff --git a/frontend/src/pages/index/XrayStatusCard.tsx b/frontend/src/pages/index/XrayStatusCard.tsx new file mode 100644 index 00000000..e5a6a227 --- /dev/null +++ b/frontend/src/pages/index/XrayStatusCard.tsx @@ -0,0 +1,137 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge, Card, Col, Popover, Row, Space, Tag } from 'antd'; +import { + BarsOutlined, + PoweroffOutlined, + ReloadOutlined, + ToolOutlined, +} from '@ant-design/icons'; + +import type { Status } from '@/models/status'; +import './XrayStatusCard.css'; + +interface XrayStatusCardProps { + status: Status; + isMobile: boolean; + ipLimitEnable: boolean; + onStopXray: () => void; + onRestartXray: () => void; + onOpenLogs: () => void; + onOpenXrayLogs: () => void; + onOpenVersionSwitch: () => void; +} + +const XRAY_STATE_KEYS: Record = { + running: 'pages.index.xrayStatusRunning', + stop: 'pages.index.xrayStatusStop', + error: 'pages.index.xrayStatusError', +}; + +function badgeAnimationClass(color: string): string { + if (color === 'green') return 'xray-running-animation'; + if (color === 'orange') return 'xray-stop-animation'; + if (color === 'red') return 'xray-error-animation'; + return 'xray-processing-animation'; +} + +export default function XrayStatusCard({ + status, + isMobile, + ipLimitEnable, + onStopXray, + onRestartXray, + onOpenLogs, + onOpenXrayLogs, + onOpenVersionSwitch, +}: XrayStatusCardProps) { + const { t } = useTranslation(); + + const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown'); + + const title = ( + + {t('pages.index.xrayStatus')} + {isMobile && status.xray.version && status.xray.version !== 'Unknown' && ( + v{status.xray.version} + )} + + ); + + const errorLines = useMemo( + () => (status.xray.errorMsg || '').split('\n'), + [status.xray.errorMsg], + ); + + const extra = + status.xray.state !== 'error' ? ( + + ) : ( + + + {t('pages.index.xrayStatusError')} + + + + + + } + content={ + <> + {errorLines.map((line, i) => ( + + {line} + + ))} + + } + > + + + ); + + const actions = [ + ...(ipLimitEnable + ? [ + + + {!isMobile && {t('pages.index.logs')}} + , + ] + : []), + + + {!isMobile && {t('pages.index.stopXray')}} + , + + + {!isMobile && {t('pages.index.restartXray')}} + , + + + {!isMobile && ( + + {status.xray.version && status.xray.version !== 'Unknown' + ? `v${status.xray.version}` + : t('pages.index.xraySwitch')} + + )} + , + ]; + + return ( + + ); +} diff --git a/frontend/src/pages/index/XrayStatusCard.vue b/frontend/src/pages/index/XrayStatusCard.vue deleted file mode 100644 index c28b87e0..00000000 --- a/frontend/src/pages/index/XrayStatusCard.vue +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 49b2e8be..8242601e 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -96,10 +96,10 @@ export default function NodeFormModal({ scheme: (node.scheme as 'http' | 'https') || base.scheme, } : base; - /* eslint-disable react-hooks/set-state-in-effect */ + setForm(next); setTestResult(null); - /* eslint-enable react-hooks/set-state-in-effect */ + }, [open, mode, node]); const title = useMemo( diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx index efe2626e..853002e6 100644 --- a/frontend/src/pages/settings/SecurityTab.tsx +++ b/frontend/src/pages/settings/SecurityTab.tsx @@ -129,7 +129,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr }, []); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + loadApiTokens(); }, [loadApiTokens]); diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 1e5bdca9..1ff5c4ff 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -93,12 +93,12 @@ export default function SettingsPage() { const [entryIsIP, setEntryIsIP] = useState(false); useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ + const host = window.location.hostname; setEntryHost(host); setEntryPort(window.location.port); setEntryIsIP(isIp(host)); - /* eslint-enable react-hooks/set-state-in-effect */ + }, []); const [alertVisible, setAlertVisible] = useState(true); diff --git a/frontend/src/pages/settings/TwoFactorModal.tsx b/frontend/src/pages/settings/TwoFactorModal.tsx index 3262ae77..8ae8a35f 100644 --- a/frontend/src/pages/settings/TwoFactorModal.tsx +++ b/frontend/src/pages/settings/TwoFactorModal.tsx @@ -34,7 +34,7 @@ export default function TwoFactorModal({ useEffect(() => { if (!open) return; - /* eslint-disable react-hooks/set-state-in-effect */ + setEnteredCode(''); totpRef.current = null; setQrValue(''); @@ -50,7 +50,7 @@ export default function TwoFactorModal({ totpRef.current = totp; setQrValue(totp.toString()); } - /* eslint-enable react-hooks/set-state-in-effect */ + }, [open, token]); function close(success: boolean, code = '') {