mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
refactor(frontend): port index dashboard to react+ts
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.
This commit is contained in:
parent
ef36757b88
commit
107fa877e5
55 changed files with 3542 additions and 2878 deletions
|
|
@ -8,6 +8,6 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/entries/index.js"></script>
|
<script type="module" src="/src/entries/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
26
frontend/src/components/JsonEditor.css
Normal file
26
frontend/src/components/JsonEditor.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
179
frontend/src/components/JsonEditor.tsx
Normal file
179
frontend/src/components/JsonEditor.tsx
Normal file
|
|
@ -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<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
|
||||||
|
{ value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const themeCompartmentRef = useRef<Compartment>(new Compartment());
|
||||||
|
const readonlyCompartmentRef = useRef<Compartment>(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 <div ref={hostRef} className="json-editor-host" />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default JsonEditor;
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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 <body>/<html> 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');
|
|
||||||
});
|
|
||||||
28
frontend/src/entries/index.tsx
Normal file
28
frontend/src/entries/index.tsx
Normal file
|
|
@ -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(
|
||||||
|
<ThemeProvider>
|
||||||
|
<IndexPage />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
1
frontend/src/env.d.ts
vendored
1
frontend/src/env.d.ts
vendored
|
|
@ -24,5 +24,6 @@ interface SubPageData {
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
X_UI_BASE_PATH?: string;
|
X_UI_BASE_PATH?: string;
|
||||||
|
X_UI_CUR_VER?: string;
|
||||||
__SUB_PAGE_DATA__?: SubPageData;
|
__SUB_PAGE_DATA__?: SubPageData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function useAllSetting() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}, [fetchAll]);
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -247,9 +247,9 @@ export function useClients() {
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
Promise.all([refresh(), fetchSubSettings()]);
|
Promise.all([refresh(), fetchSubSettings()]);
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
}, [refresh, fetchSubSettings]);
|
}, [refresh, fetchSubSettings]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ export function useNodes() {
|
||||||
}, [nodes]);
|
}, [nodes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
|
|
||||||
35
frontend/src/hooks/useStatus.ts
Normal file
35
frontend/src/hooks/useStatus.ts
Normal file
|
|
@ -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<Status>(() => 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 };
|
||||||
|
}
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
120
frontend/src/models/status.ts
Normal file
120
frontend/src/models/status.ts
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<XrayInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,10 +80,10 @@ export default function ClientBulkAddModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
setForm(emptyForm());
|
setForm(emptyForm());
|
||||||
setDelayedStart(false);
|
setDelayedStart(false);
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
|
|
@ -105,7 +105,7 @@ export default function ClientBulkAddModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showFlow && form.flow) {
|
if (!showFlow && form.flow) {
|
||||||
/* eslint-disable-next-line react-hooks/set-state-in-effect */
|
|
||||||
update('flow', '');
|
update('flow', '');
|
||||||
}
|
}
|
||||||
}, [showFlow, form.flow]);
|
}, [showFlow, form.flow]);
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ export default function ClientFormModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
if (isEdit && client) {
|
if (isEdit && client) {
|
||||||
const et = Number(client.expiryTime) || 0;
|
const et = Number(client.expiryTime) || 0;
|
||||||
const next: FormState = {
|
const next: FormState = {
|
||||||
|
|
@ -183,7 +183,7 @@ export default function ClientFormModal({
|
||||||
auth: RandomUtil.randomLowerAndNum(16),
|
auth: RandomUtil.randomLowerAndNum(16),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, isEdit]);
|
}, [open, isEdit]);
|
||||||
|
|
||||||
|
|
@ -215,14 +215,14 @@ export default function ClientFormModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showFlow && form.flow) {
|
if (!showFlow && form.flow) {
|
||||||
/* eslint-disable-next-line react-hooks/set-state-in-effect */
|
|
||||||
update('flow', '');
|
update('flow', '');
|
||||||
}
|
}
|
||||||
}, [showFlow, form.flow]);
|
}, [showFlow, form.flow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showReverseTag && form.reverseTag) {
|
if (!showReverseTag && form.reverseTag) {
|
||||||
/* eslint-disable-next-line react-hooks/set-state-in-effect */
|
|
||||||
update('reverseTag', '');
|
update('reverseTag', '');
|
||||||
}
|
}
|
||||||
}, [showReverseTag, form.reverseTag]);
|
}, [showReverseTag, form.reverseTag]);
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ export default function ClientsPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageSize > 0) {
|
if (pageSize > 0) {
|
||||||
/* eslint-disable-next-line react-hooks/set-state-in-effect */
|
|
||||||
setTablePageSize(pageSize);
|
setTablePageSize(pageSize);
|
||||||
}
|
}
|
||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
|
||||||
9
frontend/src/pages/index/BackupModal.css
Normal file
9
frontend/src/pages/index/BackupModal.css
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.backup-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
88
frontend/src/pages/index/BackupModal.tsx
Normal file
88
frontend/src/pages/index/BackupModal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('pages.index.backupTitle')}
|
||||||
|
closable
|
||||||
|
footer={null}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<List bordered className="backup-list">
|
||||||
|
<List.Item className="backup-item">
|
||||||
|
<List.Item.Meta
|
||||||
|
title={t('pages.index.exportDatabase')}
|
||||||
|
description={t('pages.index.exportDatabaseDesc')}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
||||||
|
</List.Item>
|
||||||
|
|
||||||
|
<List.Item className="backup-item">
|
||||||
|
<List.Item.Meta
|
||||||
|
title={t('pages.index.importDatabase')}
|
||||||
|
description={t('pages.index.importDatabaseDesc')}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
|
||||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
basePath: { type: String, default: '' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'busy']);
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportDb() {
|
|
||||||
// The Go endpoint streams x-ui.db as a download. Setting
|
|
||||||
// window.location triggers a browser download without leaving
|
|
||||||
// the page (the Go side responds with Content-Disposition: attachment).
|
|
||||||
window.location = 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.files?.[0];
|
|
||||||
if (!dbFile) return;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('db', dbFile);
|
|
||||||
|
|
||||||
close();
|
|
||||||
emit('busy', { 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) {
|
|
||||||
emit('busy', { busy: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('busy', { 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 {
|
|
||||||
emit('busy', { busy: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
fileInput.click();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
|
|
||||||
<a-list bordered class="backup-list">
|
|
||||||
<a-list-item class="backup-item">
|
|
||||||
<a-list-item-meta>
|
|
||||||
<template #title>{{ t('pages.index.exportDatabase') }}</template>
|
|
||||||
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
|
|
||||||
</a-list-item-meta>
|
|
||||||
<a-button type="primary" @click="exportDb">
|
|
||||||
<template #icon>
|
|
||||||
<DownloadOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-list-item>
|
|
||||||
|
|
||||||
<a-list-item class="backup-item">
|
|
||||||
<a-list-item-meta>
|
|
||||||
<template #title>{{ t('pages.index.importDatabase') }}</template>
|
|
||||||
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
|
|
||||||
</a-list-item-meta>
|
|
||||||
<a-button type="primary" @click="importDb">
|
|
||||||
<template #icon>
|
|
||||||
<UploadOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-list-item>
|
|
||||||
</a-list>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.backup-list {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
128
frontend/src/pages/index/CustomGeoFormModal.tsx
Normal file
128
frontend/src/pages/index/CustomGeoFormModal.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Form, Input, message, Modal, Select } from 'antd';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
export interface CustomGeoRecord {
|
||||||
|
id: number;
|
||||||
|
type: 'geosite' | 'geoip';
|
||||||
|
alias: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomGeoFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
record: CustomGeoRecord | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomGeoFormModal({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: CustomGeoFormModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
|
||||||
|
const [alias, setAlias] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const editing = record != null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (record) {
|
||||||
|
setType(record.type);
|
||||||
|
setAlias(record.alias);
|
||||||
|
setUrl(record.url);
|
||||||
|
} else {
|
||||||
|
setType('geosite');
|
||||||
|
setAlias('');
|
||||||
|
setUrl('');
|
||||||
|
}
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(alias || '')) {
|
||||||
|
message.error(t('pages.index.customGeoValidationAlias'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const u = (url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) {
|
||||||
|
message.error(t('pages.index.customGeoValidationUrl'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(u);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
message.error(t('pages.index.customGeoValidationUrl'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error(t('pages.index.customGeoValidationUrl'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!validate()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const apiUrl = editing
|
||||||
|
? `/panel/api/custom-geo/update/${record!.id}`
|
||||||
|
: '/panel/api/custom-geo/add';
|
||||||
|
const msg = await HttpUtil.post(apiUrl, { type, alias, url });
|
||||||
|
if (msg?.success) {
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
|
||||||
|
confirmLoading={saving}
|
||||||
|
okText={t('pages.index.customGeoModalSave')}
|
||||||
|
cancelText={t('close')}
|
||||||
|
onOk={submit}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label={t('pages.index.customGeoType')}>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
disabled={editing}
|
||||||
|
onChange={(v) => setType(v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'geosite', label: 'geosite' },
|
||||||
|
{ value: 'geoip', label: 'geoip' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.index.customGeoAlias')}>
|
||||||
|
<Input
|
||||||
|
value={alias}
|
||||||
|
disabled={editing}
|
||||||
|
placeholder={t('pages.index.customGeoAliasPlaceholder')}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.index.customGeoUrl')}>
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
placeholder="https://"
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { message } from 'ant-design-vue';
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
// Populate with the record when editing; null/undefined when adding.
|
|
||||||
record: { type: Object, default: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'saved']);
|
|
||||||
|
|
||||||
const form = reactive({ type: 'geosite', alias: '', url: '' });
|
|
||||||
const saving = ref(false);
|
|
||||||
|
|
||||||
const editing = ref(false);
|
|
||||||
const editId = ref(null);
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => {
|
|
||||||
if (!next) return;
|
|
||||||
if (props.record) {
|
|
||||||
editing.value = true;
|
|
||||||
editId.value = props.record.id;
|
|
||||||
form.type = props.record.type;
|
|
||||||
form.alias = props.record.alias;
|
|
||||||
form.url = props.record.url;
|
|
||||||
} else {
|
|
||||||
editing.value = false;
|
|
||||||
editId.value = null;
|
|
||||||
form.type = 'geosite';
|
|
||||||
form.alias = '';
|
|
||||||
form.url = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate() {
|
|
||||||
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
|
|
||||||
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
|
|
||||||
message.error(t('pages.index.customGeoValidationAlias'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const u = (form.url || '').trim();
|
|
||||||
if (!/^https?:\/\//i.test(u)) {
|
|
||||||
message.error(t('pages.index.customGeoValidationUrl'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(u);
|
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
||||||
message.error(t('pages.index.customGeoValidationUrl'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
message.error(t('pages.index.customGeoValidationUrl'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!validate()) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const url = editing.value
|
|
||||||
? `/panel/api/custom-geo/update/${editId.value}`
|
|
||||||
: '/panel/api/custom-geo/add';
|
|
||||||
const msg = await HttpUtil.post(url, form);
|
|
||||||
if (msg?.success) {
|
|
||||||
emit('saved');
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
|
|
||||||
:confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
|
|
||||||
@cancel="close">
|
|
||||||
<a-form layout="vertical">
|
|
||||||
<a-form-item :label="t('pages.index.customGeoType')">
|
|
||||||
<a-select v-model:value="form.type" :disabled="editing">
|
|
||||||
<a-select-option value="geosite">geosite</a-select-option>
|
|
||||||
<a-select-option value="geoip">geoip</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item :label="t('pages.index.customGeoAlias')">
|
|
||||||
<a-input v-model:value="form.alias" :disabled="editing"
|
|
||||||
:placeholder="t('pages.index.customGeoAliasPlaceholder')" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item :label="t('pages.index.customGeoUrl')">
|
|
||||||
<a-input v-model:value="form.url" placeholder="https://" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
81
frontend/src/pages/index/CustomGeoSection.css
Normal file
81
frontend/src/pages/index/CustomGeoSection.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
.mb-10 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .custom-geo-count {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-alias-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-alias {
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-type-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-url {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-ext-code {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-copyable:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .custom-geo-ext-code {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .custom-geo-copyable:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-muted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-empty-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
281
frontend/src/pages/index/CustomGeoSection.tsx
Normal file
281
frontend/src/pages/index/CustomGeoSection.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Button, message, Modal, Space, Table, Tag, Tooltip } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||||
|
import CustomGeoFormModal from './CustomGeoFormModal';
|
||||||
|
import type { CustomGeoRecord } from './CustomGeoFormModal';
|
||||||
|
import './CustomGeoSection.css';
|
||||||
|
|
||||||
|
interface CustomGeoSectionProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomGeoListRecord extends CustomGeoRecord {
|
||||||
|
lastUpdatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts?: number): string {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
if (isNaN(d.getTime())) return String(ts);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(ts?: number): string {
|
||||||
|
if (!ts) return '';
|
||||||
|
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||||
|
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||||
|
return formatTime(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extDisplay(record: CustomGeoListRecord): string {
|
||||||
|
const fn = record.type === 'geoip'
|
||||||
|
? `geoip_${record.alias}.dat`
|
||||||
|
: `geosite_${record.alias}.dat`;
|
||||||
|
return `ext:${fn}:tag`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [list, setList] = useState<CustomGeoListRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [updatingAll, setUpdatingAll] = useState(false);
|
||||||
|
const [actionId, setActionId] = useState<number | null>(null);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingRecord, setEditingRecord] = useState<CustomGeoListRecord | null>(null);
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) setList(msg.obj);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) loadList();
|
||||||
|
}, [active, loadList]);
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditingRecord(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: CustomGeoListRecord) {
|
||||||
|
setEditingRecord(record);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyExt(record: CustomGeoListRecord) {
|
||||||
|
const text = extDisplay(record);
|
||||||
|
const ok = await ClipboardManager.copyText(text);
|
||||||
|
if (ok) message.success(`${t('copied')}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(record: CustomGeoListRecord) {
|
||||||
|
modal.confirm({
|
||||||
|
title: t('pages.index.customGeoDelete'),
|
||||||
|
content: t('pages.index.customGeoDeleteConfirm'),
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||||
|
if (msg?.success) await loadList();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadOne(id: number) {
|
||||||
|
setActionId(id);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||||
|
if (msg?.success) await loadList();
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAll() {
|
||||||
|
setUpdatingAll(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||||
|
const ok = msg?.obj?.succeeded?.length || 0;
|
||||||
|
const failed = msg?.obj?.failed?.length || 0;
|
||||||
|
if (msg?.success || ok > 0) {
|
||||||
|
await loadList();
|
||||||
|
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUpdatingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnsType<CustomGeoListRecord>>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t('pages.index.customGeoAlias'),
|
||||||
|
key: 'alias',
|
||||||
|
width: 200,
|
||||||
|
render: (_v, record) => (
|
||||||
|
<div className="custom-geo-alias-cell">
|
||||||
|
<Tag color={record.type === 'geoip' ? 'cyan' : 'purple'} className="custom-geo-type-tag">
|
||||||
|
{record.type}
|
||||||
|
</Tag>
|
||||||
|
<span className="custom-geo-alias">{record.alias}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.index.customGeoUrl'),
|
||||||
|
key: 'url',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_v, record) => (
|
||||||
|
<Tooltip placement="topLeft" title={record.url}>
|
||||||
|
<a
|
||||||
|
href={record.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="custom-geo-url"
|
||||||
|
>
|
||||||
|
{record.url}
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.index.customGeoExtColumn'),
|
||||||
|
key: 'extDat',
|
||||||
|
width: 220,
|
||||||
|
render: (_v, record) => (
|
||||||
|
<Tooltip title={t('copy')}>
|
||||||
|
<code
|
||||||
|
className="custom-geo-ext-code custom-geo-copyable"
|
||||||
|
onClick={() => copyExt(record)}
|
||||||
|
>
|
||||||
|
{extDisplay(record)}
|
||||||
|
</code>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.index.customGeoLastUpdated'),
|
||||||
|
key: 'lastUpdatedAt',
|
||||||
|
width: 140,
|
||||||
|
render: (_v, record) =>
|
||||||
|
record.lastUpdatedAt ? (
|
||||||
|
<Tooltip title={formatTime(record.lastUpdatedAt)}>
|
||||||
|
<span>{relativeTime(record.lastUpdatedAt)}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span className="custom-geo-muted">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.index.customGeoActions'),
|
||||||
|
key: 'action',
|
||||||
|
width: 120,
|
||||||
|
render: (_v, record) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Tooltip title={t('pages.index.customGeoEdit')}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => openEdit(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.index.customGeoDownload')}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
loading={actionId === record.id}
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => downloadOne(record.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.index.customGeoDelete')}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => confirmDelete(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[t, actionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="custom-geo-section">
|
||||||
|
{modalContextHolder}
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="mb-10"
|
||||||
|
message={t('pages.index.customGeoRoutingHint')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<Button type="primary" loading={loading} onClick={openAdd} icon={<PlusOutlined />}>
|
||||||
|
{t('pages.index.customGeoAdd')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={updatingAll}
|
||||||
|
disabled={list.length === 0}
|
||||||
|
onClick={updateAll}
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
>
|
||||||
|
{t('pages.index.geofilesUpdateAll')}
|
||||||
|
</Button>
|
||||||
|
{list.length > 0 && <span className="custom-geo-count">{list.length}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={list}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 760 }}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="custom-geo-empty">
|
||||||
|
<InboxOutlined className="custom-geo-empty-icon" />
|
||||||
|
<div>{t('pages.index.customGeoEmpty')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomGeoFormModal
|
||||||
|
open={formOpen}
|
||||||
|
record={editingRecord}
|
||||||
|
onClose={() => setFormOpen(false)}
|
||||||
|
onSaved={loadList}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { Modal, message } from 'ant-design-vue';
|
|
||||||
import {
|
|
||||||
PlusOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
import { HttpUtil, ClipboardManager } from '@/utils';
|
|
||||||
import CustomGeoFormModal from './CustomGeoFormModal.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
// Re-fetch the list when the parent collapse expands this section.
|
|
||||||
active: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const list = ref([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
const updatingAll = ref(false);
|
|
||||||
const actionId = ref(null);
|
|
||||||
|
|
||||||
const formOpen = ref(false);
|
|
||||||
const editingRecord = ref(null);
|
|
||||||
|
|
||||||
// Computed so column titles re-render after a locale swap.
|
|
||||||
const columns = computed(() => [
|
|
||||||
{ title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
|
|
||||||
{ title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
|
|
||||||
{ title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
|
|
||||||
{ title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
|
|
||||||
{ title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function loadList() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
|
||||||
if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAdd() {
|
|
||||||
editingRecord.value = null;
|
|
||||||
formOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(record) {
|
|
||||||
editingRecord.value = record;
|
|
||||||
formOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extDisplay(record) {
|
|
||||||
const fn = record.type === 'geoip'
|
|
||||||
? `geoip_${record.alias}.dat`
|
|
||||||
: `geosite_${record.alias}.dat`;
|
|
||||||
return `ext:${fn}:tag`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyExt(record) {
|
|
||||||
const text = extDisplay(record);
|
|
||||||
const ok = await ClipboardManager.copyText(text);
|
|
||||||
if (ok) message.success(`${t('copied')}: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts) {
|
|
||||||
if (!ts) return '';
|
|
||||||
const d = new Date(ts * 1000);
|
|
||||||
if (isNaN(d.getTime())) return String(ts);
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tiny inline relative-time formatter so we don't pull in moment.
|
|
||||||
function relativeTime(ts) {
|
|
||||||
if (!ts) return '';
|
|
||||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
|
||||||
if (diff < 60) return 'just now';
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
|
||||||
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
|
||||||
return formatTime(ts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete(record) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('pages.index.customGeoDelete'),
|
|
||||||
content: t('pages.index.customGeoDeleteConfirm'),
|
|
||||||
okText: t('delete'),
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: async () => {
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
|
||||||
if (msg?.success) await loadList();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadOne(id) {
|
|
||||||
actionId.value = id;
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
|
||||||
if (msg?.success) await loadList();
|
|
||||||
} finally {
|
|
||||||
actionId.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateAll() {
|
|
||||||
updatingAll.value = true;
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
|
||||||
const ok = msg?.obj?.succeeded?.length || 0;
|
|
||||||
const failed = msg?.obj?.failed?.length || 0;
|
|
||||||
if (msg?.success || ok > 0) {
|
|
||||||
await loadList();
|
|
||||||
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
updatingAll.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy-load: only fetch when the parent collapse opens this panel.
|
|
||||||
watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="custom-geo-section">
|
|
||||||
<a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<a-button type="primary" :loading="loading" @click="openAdd">
|
|
||||||
<template #icon>
|
|
||||||
<PlusOutlined />
|
|
||||||
</template>
|
|
||||||
{{ t('pages.index.customGeoAdd') }}
|
|
||||||
</a-button>
|
|
||||||
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
|
|
||||||
<template #icon>
|
|
||||||
<ReloadOutlined />
|
|
||||||
</template>
|
|
||||||
{{ t('pages.index.geofilesUpdateAll') }}
|
|
||||||
</a-button>
|
|
||||||
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
|
|
||||||
size="small" :scroll="{ x: 760 }">
|
|
||||||
<template #bodyCell="{ column, record }">
|
|
||||||
<template v-if="column.key === 'alias'">
|
|
||||||
<div class="custom-geo-alias-cell">
|
|
||||||
<a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
|
|
||||||
{{ record.type }}
|
|
||||||
</a-tag>
|
|
||||||
<span class="custom-geo-alias">{{ record.alias }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="column.key === 'url'">
|
|
||||||
<a-tooltip placement="topLeft" :title="record.url">
|
|
||||||
<a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
|
|
||||||
{{ record.url }}
|
|
||||||
</a>
|
|
||||||
</a-tooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="column.key === 'extDat'">
|
|
||||||
<a-tooltip :title="t('copy')">
|
|
||||||
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
|
|
||||||
{{ extDisplay(record) }}
|
|
||||||
</code>
|
|
||||||
</a-tooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="column.key === 'lastUpdatedAt'">
|
|
||||||
<a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
|
|
||||||
<span>{{ relativeTime(record.lastUpdatedAt) }}</span>
|
|
||||||
</a-tooltip>
|
|
||||||
<span v-else class="custom-geo-muted">—</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="column.key === 'action'">
|
|
||||||
<a-space size="small">
|
|
||||||
<a-tooltip :title="t('pages.index.customGeoEdit')">
|
|
||||||
<a-button type="link" size="small" @click="openEdit(record)">
|
|
||||||
<template #icon>
|
|
||||||
<EditOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip :title="t('pages.index.customGeoDownload')">
|
|
||||||
<a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
|
|
||||||
<template #icon>
|
|
||||||
<ReloadOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip :title="t('pages.index.customGeoDelete')">
|
|
||||||
<a-button type="link" size="small" danger @click="confirmDelete(record)">
|
|
||||||
<template #icon>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #emptyText>
|
|
||||||
<div class="custom-geo-empty">
|
|
||||||
<InboxOutlined class="custom-geo-empty-icon" />
|
|
||||||
<div>{{ t('pages.index.customGeoEmpty') }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</a-table>
|
|
||||||
|
|
||||||
<CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mb-10 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-count {
|
|
||||||
margin-left: 4px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body.dark) .custom-geo-count {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-alias-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-alias {
|
|
||||||
font-weight: 500;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-type-tag {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-url {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-ext-code {
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
user-select: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-copyable:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body.dark) .custom-geo-ext-code {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body.dark) .custom-geo-copyable:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-muted {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 18px 0;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-empty-icon {
|
|
||||||
font-size: 32px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
82
frontend/src/pages/index/IndexPage.css
Normal file
82
frontend/src/pages/index/IndexPage.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
486
frontend/src/pages/index/IndexPage.tsx
Normal file
486
frontend/src/pages/index/IndexPage.tsx
Normal file
|
|
@ -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<PanelUpdateInfo>({
|
||||||
|
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 (
|
||||||
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
<Layout className={pageClass}>
|
||||||
|
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||||
|
|
||||||
|
<Layout className="content-shell">
|
||||||
|
<Layout.Content className="content-area">
|
||||||
|
<Spin
|
||||||
|
spinning={loading || !fetched}
|
||||||
|
delay={200}
|
||||||
|
tip={loading ? loadingTip : t('loading')}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{!fetched ? (
|
||||||
|
<div className="loading-spacer" />
|
||||||
|
) : (
|
||||||
|
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<StatusCard status={status} isMobile={isMobile} />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<XrayStatusCard
|
||||||
|
status={status}
|
||||||
|
isMobile={isMobile}
|
||||||
|
ipLimitEnable={ipLimitEnable}
|
||||||
|
onStopXray={stopXray}
|
||||||
|
onRestartXray={restartXray}
|
||||||
|
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
||||||
|
onOpenLogs={() => setLogsOpen(true)}
|
||||||
|
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
title={t('menu.link')}
|
||||||
|
hoverable
|
||||||
|
actions={[
|
||||||
|
<Space className="action" key="logs" onClick={() => setLogsOpen(true)}>
|
||||||
|
<BarsOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.logs')}</span>}
|
||||||
|
</Space>,
|
||||||
|
<Space className="action" key="config" onClick={openConfig}>
|
||||||
|
<ControlOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.config')}</span>}
|
||||||
|
</Space>,
|
||||||
|
<Space className="action" key="backup" onClick={() => setBackupOpen(true)}>
|
||||||
|
<CloudServerOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
|
||||||
|
</Space>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
title="3X-UI"
|
||||||
|
hoverable
|
||||||
|
actions={[
|
||||||
|
<Space className="action" key="tg" onClick={openTelegram}>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
fill="currentColor"
|
||||||
|
className="tg-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
|
||||||
|
</svg>
|
||||||
|
{!isMobile && <span>@XrayUI</span>}
|
||||||
|
</Space>,
|
||||||
|
<Space
|
||||||
|
key="panel-version"
|
||||||
|
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
|
||||||
|
onClick={openPanelVersion}
|
||||||
|
>
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
{!isMobile && (
|
||||||
|
<span>
|
||||||
|
{panelUpdateInfo.updateAvailable
|
||||||
|
? `${t('update')} ${panelUpdateInfo.latestVersion}`
|
||||||
|
: `v${displayVersion}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Space>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
title={t('pages.index.charts')}
|
||||||
|
hoverable
|
||||||
|
actions={[
|
||||||
|
<Space
|
||||||
|
className="action"
|
||||||
|
key="sys-history"
|
||||||
|
onClick={() => setSysHistoryOpen(true)}
|
||||||
|
>
|
||||||
|
<AreaChartOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
|
||||||
|
</Space>,
|
||||||
|
<Space
|
||||||
|
className="action"
|
||||||
|
key="xray-metrics"
|
||||||
|
onClick={() => setXrayMetricsOpen(true)}
|
||||||
|
>
|
||||||
|
<AreaChartOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
|
||||||
|
</Space>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title={t('pages.index.operationHours')} hoverable>
|
||||||
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title="Xray"
|
||||||
|
value={TimeFormatter.formatSecond(status.appStats.uptime)}
|
||||||
|
prefix={<ThunderboltOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title="OS"
|
||||||
|
value={TimeFormatter.formatSecond(status.uptime)}
|
||||||
|
prefix={<DesktopOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title={t('usage')} hoverable>
|
||||||
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title={t('pages.index.memory')}
|
||||||
|
value={SizeFormatter.sizeFormat(status.appStats.mem)}
|
||||||
|
prefix={<DatabaseOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title={t('pages.index.threads')}
|
||||||
|
value={status.appStats.threads}
|
||||||
|
prefix={<ForkOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title={t('pages.index.overallSpeed')} hoverable>
|
||||||
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title={t('pages.index.upload')}
|
||||||
|
value={SizeFormatter.sizeFormat(status.netIO.up)}
|
||||||
|
prefix={<ArrowUpOutlined />}
|
||||||
|
suffix="/s"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title={t('pages.index.download')}
|
||||||
|
value={SizeFormatter.sizeFormat(status.netIO.down)}
|
||||||
|
prefix={<ArrowDownOutlined />}
|
||||||
|
suffix="/s"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title={t('pages.index.totalData')} hoverable>
|
||||||
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title={t('pages.index.sent')}
|
||||||
|
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
|
||||||
|
prefix={<CloudUploadOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title={t('pages.index.received')}
|
||||||
|
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
|
||||||
|
prefix={<CloudDownloadOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
title={t('pages.index.ipAddresses')}
|
||||||
|
hoverable
|
||||||
|
extra={
|
||||||
|
<Tooltip
|
||||||
|
title={t('pages.index.toggleIpVisibility')}
|
||||||
|
placement={isMobile ? 'topRight' : 'top'}
|
||||||
|
>
|
||||||
|
{showIp ? (
|
||||||
|
<EyeOutlined
|
||||||
|
className="ip-toggle-icon"
|
||||||
|
onClick={() => setShowIp(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EyeInvisibleOutlined
|
||||||
|
className="ip-toggle-icon"
|
||||||
|
onClick={() => setShowIp(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
|
||||||
|
<Col span={isMobile ? 24 : 12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title="IPv4"
|
||||||
|
value={status.publicIP.ipv4}
|
||||||
|
prefix={<GlobalOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={isMobile ? 24 : 12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title="IPv6"
|
||||||
|
value={status.publicIP.ipv6}
|
||||||
|
prefix={<GlobalOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title={t('pages.index.connectionCount')} hoverable>
|
||||||
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title="TCP"
|
||||||
|
value={status.tcpCount}
|
||||||
|
prefix={<SwapOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<CustomStatistic
|
||||||
|
title="UDP"
|
||||||
|
value={status.udpCount}
|
||||||
|
prefix={<SwapOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<PanelUpdateModal
|
||||||
|
open={panelUpdateOpen}
|
||||||
|
info={panelUpdateInfo}
|
||||||
|
onClose={() => setPanelUpdateOpen(false)}
|
||||||
|
onBusy={setBusy}
|
||||||
|
/>
|
||||||
|
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
|
||||||
|
<BackupModal
|
||||||
|
open={backupOpen}
|
||||||
|
basePath={basePath}
|
||||||
|
onClose={() => setBackupOpen(false)}
|
||||||
|
onBusy={setBusy}
|
||||||
|
/>
|
||||||
|
<SystemHistoryModal
|
||||||
|
open={sysHistoryOpen}
|
||||||
|
status={status}
|
||||||
|
onClose={() => setSysHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
<XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
|
||||||
|
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
|
||||||
|
<VersionModal
|
||||||
|
open={versionOpen}
|
||||||
|
status={status}
|
||||||
|
onClose={() => setVersionOpen(false)}
|
||||||
|
onBusy={setBusy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={configTextOpen}
|
||||||
|
title={t('pages.index.config')}
|
||||||
|
width={isMobile ? '100%' : 900}
|
||||||
|
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined}
|
||||||
|
closable
|
||||||
|
onCancel={() => setConfigTextOpen(false)}
|
||||||
|
footer={[
|
||||||
|
<Button
|
||||||
|
key="download"
|
||||||
|
onClick={downloadConfig}
|
||||||
|
size={isMobile ? 'small' : 'middle'}
|
||||||
|
icon={<CloudDownloadOutlined />}
|
||||||
|
>
|
||||||
|
{isMobile ? 'Download' : 'config.json'}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="copy"
|
||||||
|
type="primary"
|
||||||
|
onClick={copyConfig}
|
||||||
|
size={isMobile ? 'small' : 'middle'}
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<JsonEditor
|
||||||
|
value={configText}
|
||||||
|
onChange={setConfigText}
|
||||||
|
minHeight={isMobile ? '300px' : '420px'}
|
||||||
|
maxHeight={isMobile ? '500px' : '720px'}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,484 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { message } from 'ant-design-vue';
|
|
||||||
import {
|
|
||||||
BarsOutlined,
|
|
||||||
ControlOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
CloudDownloadOutlined,
|
|
||||||
CloudUploadOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
ArrowDownOutlined,
|
|
||||||
AreaChartOutlined,
|
|
||||||
GlobalOutlined,
|
|
||||||
SwapOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
EyeInvisibleOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
DesktopOutlined,
|
|
||||||
DatabaseOutlined,
|
|
||||||
ForkOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
|
|
||||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
|
||||||
import { useStatus } from '@/composables/useStatus.js';
|
|
||||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
|
||||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
|
||||||
import JsonEditor from '@/components/JsonEditor.vue';
|
|
||||||
import StatusCard from './StatusCard.vue';
|
|
||||||
import XrayStatusCard from './XrayStatusCard.vue';
|
|
||||||
import PanelUpdateModal from './PanelUpdateModal.vue';
|
|
||||||
import LogModal from './LogModal.vue';
|
|
||||||
import BackupModal from './BackupModal.vue';
|
|
||||||
import SystemHistoryModal from './SystemHistoryModal.vue';
|
|
||||||
import XrayMetricsModal from './XrayMetricsModal.vue';
|
|
||||||
import XrayLogModal from './XrayLogModal.vue';
|
|
||||||
import VersionModal from './VersionModal.vue';
|
|
||||||
|
|
||||||
const { status, fetched, refresh } = useStatus();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
|
|
||||||
// `/panel/setting/defaultSettings` returns ipLimitEnable; the xray
|
|
||||||
// card hides its log button when access logs are off.
|
|
||||||
const ipLimitEnable = ref(false);
|
|
||||||
HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
|
|
||||||
if (msg?.success && msg.obj) ipLimitEnable.value = !!msg.obj.ipLimitEnable;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Panel-update info — fetched once on mount, drives both the badge
|
|
||||||
// in QuickActions and the contents of PanelUpdateModal.
|
|
||||||
const panelUpdateInfo = ref({ currentVersion: '', latestVersion: '', updateAvailable: false });
|
|
||||||
onMounted(() => {
|
|
||||||
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
|
||||||
if (msg?.success && msg.obj) panelUpdateInfo.value = msg.obj;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const basePath = window.X_UI_BASE_PATH || '';
|
|
||||||
const requestUri = window.location.pathname;
|
|
||||||
|
|
||||||
// In production, dist.go injects window.X_UI_CUR_VER at serve time.
|
|
||||||
// In dev, Vite serves the HTML directly so the global is missing — fall
|
|
||||||
// back to currentVersion from the panel-update API once it answers.
|
|
||||||
const displayVersion = computed(
|
|
||||||
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
|
||||||
const showIp = ref(false);
|
|
||||||
|
|
||||||
// Modal open state.
|
|
||||||
const logsOpen = ref(false);
|
|
||||||
const backupOpen = ref(false);
|
|
||||||
const panelUpdateOpen = ref(false);
|
|
||||||
const sysHistoryOpen = ref(false);
|
|
||||||
const xrayMetricsOpen = ref(false);
|
|
||||||
const xrayLogsOpen = ref(false);
|
|
||||||
const versionOpen = ref(false);
|
|
||||||
const configTextOpen = ref(false);
|
|
||||||
const configText = ref('');
|
|
||||||
|
|
||||||
// Page-level loading overlay; modals can request it via @busy.
|
|
||||||
const loading = ref(false);
|
|
||||||
const loadingTip = ref(t('loading'));
|
|
||||||
function setBusy({ busy, tip }) {
|
|
||||||
loading.value = busy;
|
|
||||||
if (tip) loadingTip.value = tip;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Xray controls
|
|
||||||
async function stopXray() {
|
|
||||||
await HttpUtil.post('/panel/api/server/stopXrayService');
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
async function restartXray() {
|
|
||||||
await HttpUtil.post('/panel/api/server/restartXrayService');
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSystemHistory() { sysHistoryOpen.value = true; }
|
|
||||||
function openXrayLogs() { xrayLogsOpen.value = true; }
|
|
||||||
function openVersionSwitch() { versionOpen.value = true; }
|
|
||||||
|
|
||||||
function openPanelVersion() {
|
|
||||||
if (panelUpdateInfo.value.updateAvailable) {
|
|
||||||
panelUpdateOpen.value = 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy "Config" action — fetch the rendered xray config and show
|
|
||||||
// it as JSON in the config modal with syntax highlighting.
|
|
||||||
async function openConfig() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
|
||||||
if (!msg?.success) return;
|
|
||||||
configText.value = JSON.stringify(msg.obj, null, 2);
|
|
||||||
configTextOpen.value = true;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyConfig() {
|
|
||||||
const ok = await ClipboardManager.copyText(configText.value || '');
|
|
||||||
if (ok) {
|
|
||||||
message.success('Copied');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadConfig() {
|
|
||||||
FileManager.downloadTextFile(configText.value, 'config.json');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-config-provider :theme="antdThemeConfig">
|
|
||||||
<a-layout class="index-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
|
||||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
|
||||||
|
|
||||||
<a-layout class="content-shell">
|
|
||||||
<a-layout-content class="content-area">
|
|
||||||
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
|
|
||||||
<div v-if="!fetched" class="loading-spacer" />
|
|
||||||
|
|
||||||
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
|
||||||
<a-col :span="24">
|
|
||||||
<StatusCard :status="status" :is-mobile="isMobile" />
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
|
|
||||||
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
|
|
||||||
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('menu.link')" hoverable>
|
|
||||||
<template #actions>
|
|
||||||
<a-space class="action" @click="logsOpen = true">
|
|
||||||
<BarsOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space class="action" @click="openConfig">
|
|
||||||
<ControlOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.config') }}</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space class="action" @click="backupOpen = true">
|
|
||||||
<CloudServerOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card title="3X-UI" hoverable>
|
|
||||||
<template #actions>
|
|
||||||
<a-space class="action" @click="openTelegram">
|
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="tg-icon"
|
|
||||||
aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
|
|
||||||
</svg>
|
|
||||||
<span v-if="!isMobile">@XrayUI</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space class="action" :class="{ 'action-update': panelUpdateInfo.updateAvailable }"
|
|
||||||
@click="openPanelVersion">
|
|
||||||
<CloudDownloadOutlined />
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ panelUpdateInfo.updateAvailable
|
|
||||||
? `${t('update')} ${panelUpdateInfo.latestVersion}`
|
|
||||||
: `v${displayVersion}` }}
|
|
||||||
</span>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('pages.index.charts')" hoverable>
|
|
||||||
<template #actions>
|
|
||||||
<a-space class="action" @click="openSystemHistory">
|
|
||||||
<AreaChartOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.systemHistoryTitle') }}</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space class="action" @click="xrayMetricsOpen = true">
|
|
||||||
<AreaChartOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.xrayMetricsTitle') }}</span>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('pages.index.operationHours')" hoverable>
|
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic title="Xray" :value="TimeFormatter.formatSecond(status.appStats.uptime)">
|
|
||||||
<template #prefix>
|
|
||||||
<ThunderboltOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic title="OS" :value="TimeFormatter.formatSecond(status.uptime)">
|
|
||||||
<template #prefix>
|
|
||||||
<DesktopOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('usage')" hoverable>
|
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic :title="t('pages.index.memory')"
|
|
||||||
:value="SizeFormatter.sizeFormat(status.appStats.mem)">
|
|
||||||
<template #prefix>
|
|
||||||
<DatabaseOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic :title="t('pages.index.threads')" :value="status.appStats.threads">
|
|
||||||
<template #prefix>
|
|
||||||
<ForkOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('pages.index.overallSpeed')" hoverable>
|
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic :title="t('pages.index.upload')"
|
|
||||||
:value="SizeFormatter.sizeFormat(status.netIO.up)">
|
|
||||||
<template #prefix>
|
|
||||||
<ArrowUpOutlined />
|
|
||||||
</template>
|
|
||||||
<template #suffix>/s</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic :title="t('pages.index.download')"
|
|
||||||
:value="SizeFormatter.sizeFormat(status.netIO.down)">
|
|
||||||
<template #prefix>
|
|
||||||
<ArrowDownOutlined />
|
|
||||||
</template>
|
|
||||||
<template #suffix>/s</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('pages.index.totalData')" hoverable>
|
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic :title="t('pages.index.sent')"
|
|
||||||
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
|
|
||||||
<template #prefix>
|
|
||||||
<CloudUploadOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic :title="t('pages.index.received')"
|
|
||||||
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
|
|
||||||
<template #prefix>
|
|
||||||
<CloudDownloadOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('pages.index.ipAddresses')" hoverable>
|
|
||||||
<template #extra>
|
|
||||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
|
|
||||||
<component :is="showIp ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
|
||||||
@click="showIp = !showIp" />
|
|
||||||
</a-tooltip>
|
|
||||||
</template>
|
|
||||||
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8, 8] : 0">
|
|
||||||
<a-col :span="isMobile ? 24 : 12">
|
|
||||||
<CustomStatistic title="IPv4" :value="status.publicIP.ipv4">
|
|
||||||
<template #prefix>
|
|
||||||
<GlobalOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="isMobile ? 24 : 12">
|
|
||||||
<CustomStatistic title="IPv6" :value="status.publicIP.ipv6">
|
|
||||||
<template #prefix>
|
|
||||||
<GlobalOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card :title="t('pages.index.connectionCount')" hoverable>
|
|
||||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic title="TCP" :value="status.tcpCount">
|
|
||||||
<template #prefix>
|
|
||||||
<SwapOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<CustomStatistic title="UDP" :value="status.udpCount">
|
|
||||||
<template #prefix>
|
|
||||||
<SwapOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-spin>
|
|
||||||
</a-layout-content>
|
|
||||||
</a-layout>
|
|
||||||
|
|
||||||
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
|
|
||||||
<LogModal v-model:open="logsOpen" />
|
|
||||||
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
|
||||||
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
|
||||||
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
|
|
||||||
<XrayLogModal v-model:open="xrayLogsOpen" />
|
|
||||||
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
|
||||||
|
|
||||||
<a-modal v-model:open="configTextOpen" :title="t('pages.index.config')" :width="isMobile ? '100%' : '900px'"
|
|
||||||
:style="isMobile ? { top: '20px', maxWidth: 'calc(100vw - 16px)' } : {}" :closable="true">
|
|
||||||
<JsonEditor v-model:value="configText" :min-height="isMobile ? '300px' : '420px'"
|
|
||||||
:max-height="isMobile ? '500px' : '720px'" :readonly="true" />
|
|
||||||
<template #footer>
|
|
||||||
<a-button @click="downloadConfig" :size="isMobile ? 'small' : 'middle'">
|
|
||||||
<template #icon>
|
|
||||||
<CloudDownloadOutlined />
|
|
||||||
</template>
|
|
||||||
<span v-if="!isMobile">config.json</span>
|
|
||||||
<span v-else>Download</span>
|
|
||||||
</a-button>
|
|
||||||
<a-button type="primary" @click="copyConfig" :size="isMobile ? 'small' : 'middle'">
|
|
||||||
<template #icon>
|
|
||||||
<CopyOutlined />
|
|
||||||
</template>
|
|
||||||
Copy
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</a-layout>
|
|
||||||
</a-config-provider>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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 :deep(.ant-layout),
|
|
||||||
.index-page :deep(.ant-layout-content) {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-shell {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-area {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.content-area {
|
|
||||||
padding: 12px;
|
|
||||||
padding-top: 64px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spacer {
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
cursor: pointer;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-update {
|
|
||||||
color: #fa8c16;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-update :deep(.anticon) {
|
|
||||||
color: #fa8c16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-tag {
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tg-icon {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-toggle-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-hidden :deep(.ant-statistic-content-value) {
|
|
||||||
filter: blur(6px);
|
|
||||||
transition: filter 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-visible :deep(.ant-statistic-content-value) {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
181
frontend/src/pages/index/LogModal.css
Normal file
181
frontend/src/pages/index/LogModal.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
193
frontend/src/pages/index/LogModal.tsx
Normal file
193
frontend/src/pages/index/LogModal.tsx
Normal file
|
|
@ -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<string[]>([]);
|
||||||
|
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')}
|
||||||
|
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
closable
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '100vw' : 800}
|
||||||
|
className={isMobile ? 'logmodal-mobile' : undefined}
|
||||||
|
onCancel={onClose}
|
||||||
|
title={titleNode}
|
||||||
|
>
|
||||||
|
<Form layout="inline" className="log-toolbar">
|
||||||
|
<Form.Item>
|
||||||
|
<Input.Group compact>
|
||||||
|
<Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
|
||||||
|
<Select.Option value="10">10</Select.Option>
|
||||||
|
<Select.Option value="20">20</Select.Option>
|
||||||
|
<Select.Option value="50">50</Select.Option>
|
||||||
|
<Select.Option value="100">100</Select.Option>
|
||||||
|
<Select.Option value="500">500</Select.Option>
|
||||||
|
</Select>
|
||||||
|
<Select value={level} size="small" style={{ width: 95 }} onChange={setLevel}>
|
||||||
|
<Select.Option value="debug">Debug</Select.Option>
|
||||||
|
<Select.Option value="info">Info</Select.Option>
|
||||||
|
<Select.Option value="notice">Notice</Select.Option>
|
||||||
|
<Select.Option value="warning">Warning</Select.Option>
|
||||||
|
<Select.Option value="err">Error</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Input.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
|
||||||
|
SysLog
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item className="download-item">
|
||||||
|
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||||
|
{parsedLogs.length === 0 ? (
|
||||||
|
<div className="log-empty">No Record...</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
parsedLogs.map((log, idx) => (
|
||||||
|
<div key={idx} className="log-card">
|
||||||
|
<div className="log-card-head">
|
||||||
|
{log.stamp && (
|
||||||
|
<span className="log-time">
|
||||||
|
{log.time && <span>{log.time}</span>}
|
||||||
|
{log.time && log.date ? ' ' : ''}
|
||||||
|
{log.date && <span className="log-date">{log.date}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.levelText && (
|
||||||
|
<span className={`log-level-badge ${log.levelClass}`}>{log.levelText}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(log.body || log.service) && (
|
||||||
|
<div className="log-body">
|
||||||
|
{log.service && <b>{log.service}</b>}
|
||||||
|
{log.service && log.body ? ' ' : ''}
|
||||||
|
{log.body && <span className="log-body-text">{log.body}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
parsedLogs.map((log, idx) => (
|
||||||
|
<div key={idx} className="log-line">
|
||||||
|
{log.stamp && <span className="log-stamp">{log.stamp}</span>}
|
||||||
|
{log.stamp && log.levelText ? ' ' : ''}
|
||||||
|
{log.levelText && <span className={`log-level ${log.levelClass}`}>{log.levelText}</span>}
|
||||||
|
{(log.body || log.service) && (
|
||||||
|
<>
|
||||||
|
<span> - </span>
|
||||||
|
{log.service && <b>{log.service}</b>}
|
||||||
|
{log.service && log.body ? ' ' : ''}
|
||||||
|
<span>{log.body}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
|
||||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open']);
|
|
||||||
|
|
||||||
const rows = ref('20');
|
|
||||||
const level = ref('info');
|
|
||||||
const syslog = ref(false);
|
|
||||||
const loading = ref(false);
|
|
||||||
const logs = ref([]);
|
|
||||||
|
|
||||||
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
|
||||||
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
|
|
||||||
|
|
||||||
// Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the
|
|
||||||
// 3-token header degrade gracefully: the unparsed head becomes the
|
|
||||||
// level so it still gets color-coded.
|
|
||||||
function parseLogLine(line) {
|
|
||||||
const [head, ...rest] = (line || '').split(' - ');
|
|
||||||
const message = rest.join(' - ');
|
|
||||||
const parts = head.split(' ');
|
|
||||||
|
|
||||||
let date = '';
|
|
||||||
let time = '';
|
|
||||||
let levelText;
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedLogs = computed(() => logs.value.map(parseLogLine));
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/server/logs/${rows.value}`, {
|
|
||||||
level: level.value,
|
|
||||||
syslog: syslog.value,
|
|
||||||
});
|
|
||||||
if (msg?.success) {
|
|
||||||
logs.value = msg.obj || [];
|
|
||||||
}
|
|
||||||
await PromiseUtil.sleep(300);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function download() {
|
|
||||||
FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => { if (next) refresh(); });
|
|
||||||
watch([rows, level, syslog], () => { if (props.open) refresh(); });
|
|
||||||
|
|
||||||
const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" :class="{ 'logmodal-mobile': isMobile }"
|
|
||||||
@cancel="close">
|
|
||||||
<template #title>
|
|
||||||
{{ t('pages.index.logs') }}
|
|
||||||
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-form layout="inline" class="log-toolbar">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input-group compact>
|
|
||||||
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
|
||||||
<a-select-option value="10">10</a-select-option>
|
|
||||||
<a-select-option value="20">20</a-select-option>
|
|
||||||
<a-select-option value="50">50</a-select-option>
|
|
||||||
<a-select-option value="100">100</a-select-option>
|
|
||||||
<a-select-option value="500">500</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<a-select v-model:value="level" size="small" :style="{ width: '95px' }">
|
|
||||||
<a-select-option value="debug">Debug</a-select-option>
|
|
||||||
<a-select-option value="info">Info</a-select-option>
|
|
||||||
<a-select-option value="notice">Notice</a-select-option>
|
|
||||||
<a-select-option value="warning">Warning</a-select-option>
|
|
||||||
<a-select-option value="err">Error</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-input-group>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item class="download-item">
|
|
||||||
<a-button type="primary" @click="download">
|
|
||||||
<template #icon>
|
|
||||||
<DownloadOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
|
|
||||||
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
|
||||||
<div v-if="parsedLogs.length === 0" class="log-empty">No Record...</div>
|
|
||||||
|
|
||||||
<template v-else-if="isMobile">
|
|
||||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
|
||||||
<div class="log-card-head">
|
|
||||||
<span v-if="log.stamp" class="log-time">
|
|
||||||
<span v-if="log.time">{{ log.time }}</span>{{ log.time && log.date ? ' ' : '' }}<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
|
||||||
{{ log.levelText }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="log.body || log.service" class="log-body">
|
|
||||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
|
||||||
<span v-if="log.stamp" class="log-stamp">{{ log.stamp }}</span>{{ log.stamp && log.levelText ? ' ' : '' }}<span v-if="log.levelText" class="log-level" :class="log.levelClass">{{ log.levelText }}</span>
|
|
||||||
<template v-if="log.body || log.service">
|
|
||||||
<span> - </span>
|
|
||||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span>{{ log.body }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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 {
|
|
||||||
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
|
||||||
below so each level keeps ≥4.5:1 contrast against the 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global([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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: pull the modal flush with the screen edges. */
|
|
||||||
:global(.logmodal-mobile) {
|
|
||||||
top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
max-width: 100vw !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.logmodal-mobile .ant-modal-content) {
|
|
||||||
border-radius: 0;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.logmodal-mobile .ant-modal-body) {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
18
frontend/src/pages/index/PanelUpdateModal.css
Normal file
18
frontend/src/pages/index/PanelUpdateModal.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
119
frontend/src/pages/index/PanelUpdateModal.tsx
Normal file
119
frontend/src/pages/index/PanelUpdateModal.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Button, List, Modal, Tag } from 'antd';
|
||||||
|
import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||||
|
import './PanelUpdateModal.css';
|
||||||
|
|
||||||
|
export interface PanelUpdateInfo {
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BusyEvent {
|
||||||
|
busy: boolean;
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelUpdateModalProps {
|
||||||
|
open: boolean;
|
||||||
|
info: PanelUpdateInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
onBusy: (e: BusyEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelUpdateModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
|
||||||
|
async function pollUntilBack(): Promise<boolean> {
|
||||||
|
await PromiseUtil.sleep(5000);
|
||||||
|
const deadline = Date.now() + 90_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
||||||
|
if (r?.data?.success) return true;
|
||||||
|
} catch {
|
||||||
|
/* still restarting */
|
||||||
|
}
|
||||||
|
await PromiseUtil.sleep(2000);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanel() {
|
||||||
|
modal.confirm({
|
||||||
|
title: t('pages.index.panelUpdateDialog'),
|
||||||
|
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', info.latestVersion || ''),
|
||||||
|
okText: t('confirm'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const baseTip = t('pages.index.dontRefresh');
|
||||||
|
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
|
||||||
|
onClose();
|
||||||
|
onBusy({ busy: true, tip });
|
||||||
|
const result = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||||
|
if (!result?.success) {
|
||||||
|
onBusy({ busy: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const back = await pollUntilBack();
|
||||||
|
if (back) await PromiseUtil.sleep(800);
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{contextHolder}
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('pages.index.updatePanel')}
|
||||||
|
closable
|
||||||
|
footer={null}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
{info.updateAvailable && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
className="mb-12"
|
||||||
|
message={t('pages.index.panelUpdateDesc')}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<List bordered className="version-list">
|
||||||
|
<List.Item className="version-list-item">
|
||||||
|
<span>{t('pages.index.currentPanelVersion')}</span>
|
||||||
|
<Tag color="green">v{info.currentVersion || '?'}</Tag>
|
||||||
|
</List.Item>
|
||||||
|
{info.updateAvailable ? (
|
||||||
|
<List.Item className="version-list-item">
|
||||||
|
<span>{t('pages.index.latestPanelVersion')}</span>
|
||||||
|
<Tag color="purple">{info.latestVersion || '-'}</Tag>
|
||||||
|
</List.Item>
|
||||||
|
) : (
|
||||||
|
<List.Item className="version-list-item">
|
||||||
|
<span>{t('pages.index.panelUpToDate')}</span>
|
||||||
|
<Tag color="green">{t('pages.index.panelUpToDate')}</Tag>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={!info.updateAvailable}
|
||||||
|
onClick={updatePanel}
|
||||||
|
icon={<CloudDownloadOutlined />}
|
||||||
|
>
|
||||||
|
{t('pages.index.updatePanel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { Modal, message } from 'ant-design-vue';
|
|
||||||
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
|
|
||||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
info: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ currentVersion: '', latestVersion: '', updateAvailable: false }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'busy']);
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePanel() {
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('pages.index.panelUpdateDialog'),
|
|
||||||
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', props.info.latestVersion || ''),
|
|
||||||
okText: t('confirm'),
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: async () => {
|
|
||||||
const baseTip = t('pages.index.dontRefresh');
|
|
||||||
const tip = props.info.latestVersion ? `${baseTip} (${props.info.latestVersion})` : baseTip;
|
|
||||||
close();
|
|
||||||
emit('busy', { busy: true, tip });
|
|
||||||
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
|
|
||||||
if (!msg?.success) {
|
|
||||||
emit('busy', { busy: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Wait for the running process to exit, then poll the new panel
|
|
||||||
// until it answers (up to ~90s). Reload as soon as it's back.
|
|
||||||
await PromiseUtil.sleep(5000);
|
|
||||||
const deadline = Date.now() + 90_000;
|
|
||||||
let back = false;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
|
||||||
if (r?.data?.success) { back = true; break; }
|
|
||||||
} catch (_) { /* still restarting */ }
|
|
||||||
await PromiseUtil.sleep(2000);
|
|
||||||
}
|
|
||||||
if (back) {
|
|
||||||
message.success(t('pages.index.panelUpdateStartedPopover'));
|
|
||||||
await PromiseUtil.sleep(800);
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
|
|
||||||
<a-alert v-if="info.updateAvailable" type="warning" class="mb-12" :message="t('pages.index.panelUpdateDesc')"
|
|
||||||
show-icon />
|
|
||||||
|
|
||||||
<a-list bordered class="version-list">
|
|
||||||
<a-list-item class="version-list-item">
|
|
||||||
<span>{{ t('pages.index.currentPanelVersion') }}</span>
|
|
||||||
<a-tag color="green">v{{ info.currentVersion || '?' }}</a-tag>
|
|
||||||
</a-list-item>
|
|
||||||
<a-list-item v-if="info.updateAvailable" class="version-list-item">
|
|
||||||
<span>{{ t('pages.index.latestPanelVersion') }}</span>
|
|
||||||
<a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
|
|
||||||
</a-list-item>
|
|
||||||
<a-list-item v-else class="version-list-item">
|
|
||||||
<span>{{ t('pages.index.panelUpToDate') }}</span>
|
|
||||||
<a-tag color="green">{{ t('pages.index.panelUpToDate') }}</a-tag>
|
|
||||||
</a-list-item>
|
|
||||||
</a-list>
|
|
||||||
|
|
||||||
<div class="actions-row">
|
|
||||||
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
|
|
||||||
<template #icon>
|
|
||||||
<CloudDownloadOutlined />
|
|
||||||
</template>
|
|
||||||
{{ t('pages.index.updatePanel') }}
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mb-12 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
8
frontend/src/pages/index/StatusCard.css
Normal file
8
frontend/src/pages/index/StatusCard.css
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.status-card .text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card .ant-progress-text {
|
||||||
|
font-size: 14px !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
107
frontend/src/pages/index/StatusCard.tsx
Normal file
107
frontend/src/pages/index/StatusCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Card hoverable className="status-card">
|
||||||
|
<Row gutter={[0, isMobile ? 16 : 0]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Row>
|
||||||
|
<Col span={12} className="text-center">
|
||||||
|
<Progress
|
||||||
|
type="dashboard"
|
||||||
|
status="normal"
|
||||||
|
strokeColor={status.cpu.color}
|
||||||
|
trailColor={TRAIL_COLOR}
|
||||||
|
percent={status.cpu.percent}
|
||||||
|
size={gaugeSize}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<b>{t('pages.index.cpu')}:</b> {CPUFormatter.cpuCoreFormat(status.cpuCores)}
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<b>{t('pages.index.logicalProcessors')}:</b> {status.logicalPro}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>{t('pages.index.frequency')}:</b>{' '}
|
||||||
|
{CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AreaChartOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12} className="text-center">
|
||||||
|
<Progress
|
||||||
|
type="dashboard"
|
||||||
|
status="normal"
|
||||||
|
strokeColor={status.mem.color}
|
||||||
|
trailColor={TRAIL_COLOR}
|
||||||
|
percent={status.mem.percent}
|
||||||
|
size={gaugeSize}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<b>{t('pages.index.memory')}:</b> {SizeFormatter.sizeFormat(status.mem.current)} /{' '}
|
||||||
|
{SizeFormatter.sizeFormat(status.mem.total)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Row>
|
||||||
|
<Col span={12} className="text-center">
|
||||||
|
<Progress
|
||||||
|
type="dashboard"
|
||||||
|
status="normal"
|
||||||
|
strokeColor={status.swap.color}
|
||||||
|
trailColor={TRAIL_COLOR}
|
||||||
|
percent={status.swap.percent}
|
||||||
|
size={gaugeSize}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<b>{t('pages.index.swap')}:</b> {SizeFormatter.sizeFormat(status.swap.current)} /{' '}
|
||||||
|
{SizeFormatter.sizeFormat(status.swap.total)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12} className="text-center">
|
||||||
|
<Progress
|
||||||
|
type="dashboard"
|
||||||
|
status="normal"
|
||||||
|
strokeColor={status.disk.color}
|
||||||
|
trailColor={TRAIL_COLOR}
|
||||||
|
percent={status.disk.percent}
|
||||||
|
size={gaugeSize}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<b>{t('pages.index.storage')}:</b> {SizeFormatter.sizeFormat(status.disk.current)} /{' '}
|
||||||
|
{SizeFormatter.sizeFormat(status.disk.total)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { AreaChartOutlined } from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
import { CPUFormatter, SizeFormatter } from '@/utils';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
status: { type: Object, required: true },
|
|
||||||
isMobile: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
// AD-Vue's default 120px dashboard renders the percent text at ~36px
|
|
||||||
// which dwarfs the rest of the card. 70 (60 on mobile) plus the
|
|
||||||
// :deep(.ant-progress-text) override below keep the gauges compact.
|
|
||||||
const gaugeSize = computed(() => (props.isMobile ? 60 : 70));
|
|
||||||
|
|
||||||
// AD-Vue's default unfinished trail (rgba(0,0,0,0.06) /
|
|
||||||
// rgba(255,255,255,0.08)) is invisible against the light card; a
|
|
||||||
// neutral mid-gray reads on both themes.
|
|
||||||
const trailColor = 'rgba(128, 128, 128, 0.25)';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-card hoverable>
|
|
||||||
<a-row :gutter="[0, isMobile ? 16 : 0]">
|
|
||||||
<!-- CPU + Memory -->
|
|
||||||
<a-col :xs="24" :md="12">
|
|
||||||
<a-row>
|
|
||||||
<a-col :span="12" class="text-center">
|
|
||||||
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color" :trail-color="trailColor"
|
|
||||||
:percent="status.cpu.percent" :width="gaugeSize" />
|
|
||||||
<div>
|
|
||||||
<b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
|
|
||||||
<a-tooltip>
|
|
||||||
<template #title>
|
|
||||||
<div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
|
|
||||||
<div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<AreaChartOutlined />
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :span="12" class="text-center">
|
|
||||||
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color" :trail-color="trailColor"
|
|
||||||
:percent="status.mem.percent" :width="gaugeSize" />
|
|
||||||
<div>
|
|
||||||
<b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
|
|
||||||
{{ SizeFormatter.sizeFormat(status.mem.total) }}
|
|
||||||
</div>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<!-- Swap + Disk -->
|
|
||||||
<a-col :xs="24" :md="12">
|
|
||||||
<a-row>
|
|
||||||
<a-col :span="12" class="text-center">
|
|
||||||
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color" :trail-color="trailColor"
|
|
||||||
:percent="status.swap.percent" :width="gaugeSize" />
|
|
||||||
<div>
|
|
||||||
<b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
|
|
||||||
{{ SizeFormatter.sizeFormat(status.swap.total) }}
|
|
||||||
</div>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :span="12" class="text-center">
|
|
||||||
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color" :trail-color="trailColor"
|
|
||||||
:percent="status.disk.percent" :width="gaugeSize" />
|
|
||||||
<div>
|
|
||||||
<b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
|
|
||||||
{{ SizeFormatter.sizeFormat(status.disk.total) }}
|
|
||||||
</div>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pin the percent number to a label-sized 14px — AD-Vue scales it
|
|
||||||
* from the SVG's intrinsic size, so :width alone leaves it too big. */
|
|
||||||
:deep(.ant-progress-text) {
|
|
||||||
font-size: 14px !important;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
18
frontend/src/pages/index/SystemHistoryModal.css
Normal file
18
frontend/src/pages/index/SystemHistoryModal.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
166
frontend/src/pages/index/SystemHistoryModal.tsx
Normal file
166
frontend/src/pages/index/SystemHistoryModal.tsx
Normal file
|
|
@ -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<number[]>([]);
|
||||||
|
const [labels, setLabels] = useState<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
closable
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '95vw' : 900}
|
||||||
|
onCancel={onClose}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{t('pages.index.systemHistoryTitle')}
|
||||||
|
<Select
|
||||||
|
value={bucket}
|
||||||
|
size="small"
|
||||||
|
className="bucket-select"
|
||||||
|
onChange={setBucket}
|
||||||
|
options={[
|
||||||
|
{ value: 2, label: '2m' },
|
||||||
|
{ value: 30, label: '30m' },
|
||||||
|
{ value: 60, label: '1h' },
|
||||||
|
{ value: 120, label: '2h' },
|
||||||
|
{ value: 180, label: '3h' },
|
||||||
|
{ value: 300, label: '5h' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={setActiveKey}
|
||||||
|
size="small"
|
||||||
|
className="history-tabs"
|
||||||
|
items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="cpu-chart-wrap">
|
||||||
|
<div className="cpu-chart-meta">
|
||||||
|
Timeframe: {bucket} sec per point (total {points.length} points)
|
||||||
|
</div>
|
||||||
|
<Sparkline
|
||||||
|
data={points}
|
||||||
|
labels={labels}
|
||||||
|
vbWidth={840}
|
||||||
|
height={220}
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
showGrid
|
||||||
|
showAxes
|
||||||
|
tickCountX={5}
|
||||||
|
maxPoints={points.length || 1}
|
||||||
|
fillOpacity={0.18}
|
||||||
|
markerRadius={3.2}
|
||||||
|
showTooltip
|
||||||
|
valueMin={0}
|
||||||
|
valueMax={activeMetric?.valueMax ?? null}
|
||||||
|
yFormatter={yFormatter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
|
||||||
import Sparkline from '@/components/Sparkline.vue';
|
|
||||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
status: { type: Object, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open']);
|
|
||||||
|
|
||||||
// One tab per system metric. The order here drives the tab order in
|
|
||||||
// the UI; everything else (axis label, tooltip unit, fetch URL) is
|
|
||||||
// looked up from the active key. Adding another metric is one row.
|
|
||||||
const metrics = [
|
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeKey = ref('cpu');
|
|
||||||
const bucket = ref(2);
|
|
||||||
const points = ref([]);
|
|
||||||
const labels = ref([]);
|
|
||||||
|
|
||||||
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
|
||||||
|
|
||||||
// CPU keeps using the status-card color so the modal visually echoes
|
|
||||||
// the dot in StatusCard. Non-CPU tabs each get their own constant color.
|
|
||||||
const strokeColor = computed(() => {
|
|
||||||
const m = activeMetric.value;
|
|
||||||
if (m?.stroke) return m.stroke;
|
|
||||||
return props.status?.cpu?.color || '#008771';
|
|
||||||
});
|
|
||||||
|
|
||||||
function unitFormatter(unit) {
|
|
||||||
if (unit === 'B/s') {
|
|
||||||
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`;
|
|
||||||
}
|
|
||||||
if (unit === '%') {
|
|
||||||
return (v) => `${Number(v).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
// Plain numbers: load averages get two decimals, online client count
|
|
||||||
// is integer. Heuristic on the unit-less metric key is good enough.
|
|
||||||
return (v) => {
|
|
||||||
const n = Number(v) || 0;
|
|
||||||
if (activeKey.value === 'online') return String(Math.round(n));
|
|
||||||
return n.toFixed(2);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
|
||||||
|
|
||||||
async function fetchBucket() {
|
|
||||||
const m = activeMetric.value;
|
|
||||||
if (!m) return;
|
|
||||||
try {
|
|
||||||
const url = `/panel/api/server/history/${m.key}/${bucket.value}`;
|
|
||||||
const msg = await HttpUtil.get(url);
|
|
||||||
if (msg?.success && Array.isArray(msg.obj)) {
|
|
||||||
const vals = [];
|
|
||||||
const labs = [];
|
|
||||||
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.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
|
||||||
vals.push(Number(p.v) || 0);
|
|
||||||
}
|
|
||||||
labels.value = labs;
|
|
||||||
points.value = vals;
|
|
||||||
} else {
|
|
||||||
labels.value = [];
|
|
||||||
points.value = [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch history bucket', e);
|
|
||||||
labels.value = [];
|
|
||||||
points.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => {
|
|
||||||
if (next) {
|
|
||||||
activeKey.value = 'cpu';
|
|
||||||
fetchBucket();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
watch([activeKey, bucket], () => {
|
|
||||||
if (props.open) fetchBucket();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
|
||||||
<template #title>
|
|
||||||
{{ t('pages.index.systemHistoryTitle') }}
|
|
||||||
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
|
||||||
<a-select-option :value="2">2m</a-select-option>
|
|
||||||
<a-select-option :value="30">30m</a-select-option>
|
|
||||||
<a-select-option :value="60">1h</a-select-option>
|
|
||||||
<a-select-option :value="120">2h</a-select-option>
|
|
||||||
<a-select-option :value="180">3h</a-select-option>
|
|
||||||
<a-select-option :value="300">5h</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
|
|
||||||
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
|
|
||||||
</a-tabs>
|
|
||||||
|
|
||||||
<div class="cpu-chart-wrap">
|
|
||||||
<div class="cpu-chart-meta">
|
|
||||||
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
|
||||||
</div>
|
|
||||||
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
|
|
||||||
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
|
|
||||||
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="activeMetric?.valueMax ?? null"
|
|
||||||
:y-formatter="yFormatter" />
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
25
frontend/src/pages/index/VersionModal.css
Normal file
25
frontend/src/pages/index/VersionModal.css
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reload-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
173
frontend/src/pages/index/VersionModal.tsx
Normal file
173
frontend/src/pages/index/VersionModal.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Button, Collapse, List, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import type { Status } from '@/models/status';
|
||||||
|
import CustomGeoSection from './CustomGeoSection';
|
||||||
|
import './VersionModal.css';
|
||||||
|
|
||||||
|
interface BusyEvent {
|
||||||
|
busy: boolean;
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionModalProps {
|
||||||
|
open: boolean;
|
||||||
|
status: Status;
|
||||||
|
onClose: () => void;
|
||||||
|
onBusy: (e: BusyEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEOFILES = [
|
||||||
|
'geosite.dat',
|
||||||
|
'geoip.dat',
|
||||||
|
'geosite_IR.dat',
|
||||||
|
'geoip_IR.dat',
|
||||||
|
'geosite_RU.dat',
|
||||||
|
'geoip_RU.dat',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function VersionModal({ open, status, onClose, onBusy }: VersionModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [activeKey, setActiveKey] = useState<string | string[]>('1');
|
||||||
|
const [versions, setVersions] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchVersions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
|
||||||
|
if (msg?.success) setVersions(msg.obj || []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) fetchVersions();
|
||||||
|
}, [open, fetchVersions]);
|
||||||
|
|
||||||
|
function switchXrayVersion(version: string) {
|
||||||
|
modal.confirm({
|
||||||
|
title: t('pages.index.xraySwitchVersionDialog'),
|
||||||
|
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
|
||||||
|
okText: t('confirm'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
onClose();
|
||||||
|
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
|
||||||
|
try {
|
||||||
|
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
|
||||||
|
} finally {
|
||||||
|
onBusy({ busy: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGeofile(fileName: string) {
|
||||||
|
const isSingle = !!fileName;
|
||||||
|
modal.confirm({
|
||||||
|
title: t('pages.index.geofileUpdateDialog'),
|
||||||
|
content: isSingle
|
||||||
|
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
|
||||||
|
: t('pages.index.geofilesUpdateDialogDesc'),
|
||||||
|
okText: t('confirm'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
onClose();
|
||||||
|
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
|
||||||
|
const url = isSingle
|
||||||
|
? `/panel/api/server/updateGeofile/${fileName}`
|
||||||
|
: '/panel/api/server/updateGeofile';
|
||||||
|
try {
|
||||||
|
await HttpUtil.post(url);
|
||||||
|
} finally {
|
||||||
|
onBusy({ busy: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeKeyStr = Array.isArray(activeKey) ? activeKey[0] : activeKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('pages.index.xrayUpdates')}
|
||||||
|
closable
|
||||||
|
footer={null}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
{modalContextHolder}
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Collapse
|
||||||
|
accordion
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={setActiveKey}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: 'Xray',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
className="mb-12"
|
||||||
|
message={t('pages.index.xraySwitchClickDesk')}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
<List bordered className="version-list">
|
||||||
|
{versions.map((version, index) => (
|
||||||
|
<List.Item key={version} className="version-list-item">
|
||||||
|
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
|
||||||
|
<Radio
|
||||||
|
checked={version === `v${status?.xray?.version}`}
|
||||||
|
onClick={() => switchXrayVersion(version)}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: 'Geofiles',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<List bordered className="version-list">
|
||||||
|
{GEOFILES.map((file, index) => (
|
||||||
|
<List.Item key={file} className="version-list-item">
|
||||||
|
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
|
||||||
|
<Tooltip title={t('update')}>
|
||||||
|
<ReloadOutlined
|
||||||
|
className="reload-icon"
|
||||||
|
onClick={() => updateGeofile(file)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button onClick={() => updateGeofile('')}>
|
||||||
|
{t('pages.index.geofilesUpdateAll')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: t('pages.index.customGeoTitle'),
|
||||||
|
children: <CustomGeoSection active={activeKeyStr === '3'} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { Modal } from 'ant-design-vue';
|
|
||||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import CustomGeoSection from './CustomGeoSection.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
status: { type: Object, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'busy']);
|
|
||||||
|
|
||||||
const activeKey = ref('1');
|
|
||||||
const versions = ref([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
// Geofiles list is hardcoded in the legacy panel — same set of files
|
|
||||||
// served from /panel/api/server/updateGeofile/{name}.
|
|
||||||
const GEOFILES = ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat'];
|
|
||||||
|
|
||||||
async function fetchVersions() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
|
|
||||||
if (msg?.success) versions.value = msg.obj || [];
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchXrayVersion(version) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('pages.index.xraySwitchVersionDialog'),
|
|
||||||
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
|
|
||||||
okText: t('confirm'),
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: async () => {
|
|
||||||
close();
|
|
||||||
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
|
||||||
try {
|
|
||||||
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
|
|
||||||
} finally {
|
|
||||||
emit('busy', { busy: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateGeofile(fileName) {
|
|
||||||
const isSingle = !!fileName;
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('pages.index.geofileUpdateDialog'),
|
|
||||||
content: isSingle
|
|
||||||
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
|
|
||||||
: t('pages.index.geofilesUpdateDialogDesc'),
|
|
||||||
okText: t('confirm'),
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: async () => {
|
|
||||||
close();
|
|
||||||
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
|
||||||
const url = isSingle
|
|
||||||
? `/panel/api/server/updateGeofile/${fileName}`
|
|
||||||
: '/panel/api/server/updateGeofile';
|
|
||||||
try {
|
|
||||||
await HttpUtil.post(url);
|
|
||||||
} finally {
|
|
||||||
emit('busy', { busy: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => { if (next) fetchVersions(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :title="t('pages.index.xrayUpdates')" :closable="true" :footer="null" @cancel="close">
|
|
||||||
<a-spin :spinning="loading">
|
|
||||||
<a-collapse v-model:active-key="activeKey" accordion>
|
|
||||||
<a-collapse-panel key="1" header="Xray">
|
|
||||||
<a-alert type="warning" class="mb-12" :message="t('pages.index.xraySwitchClickDesk')" show-icon />
|
|
||||||
<a-list bordered class="version-list">
|
|
||||||
<a-list-item v-for="(version, index) in versions" :key="version" class="version-list-item">
|
|
||||||
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ version }}</a-tag>
|
|
||||||
<a-radio :checked="version === `v${status?.xray?.version}`" @click="switchXrayVersion(version)" />
|
|
||||||
</a-list-item>
|
|
||||||
</a-list>
|
|
||||||
</a-collapse-panel>
|
|
||||||
|
|
||||||
<a-collapse-panel key="2" header="Geofiles">
|
|
||||||
<a-list bordered class="version-list">
|
|
||||||
<a-list-item v-for="(file, index) in GEOFILES" :key="file" class="version-list-item">
|
|
||||||
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ file }}</a-tag>
|
|
||||||
<a-tooltip :title="t('update')">
|
|
||||||
<ReloadOutlined class="reload-icon" @click="updateGeofile(file)" />
|
|
||||||
</a-tooltip>
|
|
||||||
</a-list-item>
|
|
||||||
</a-list>
|
|
||||||
<div class="actions-row">
|
|
||||||
<a-button @click="updateGeofile('')">{{ t('pages.index.geofilesUpdateAll') }}</a-button>
|
|
||||||
</div>
|
|
||||||
</a-collapse-panel>
|
|
||||||
|
|
||||||
<a-collapse-panel key="3" :header="t('pages.index.customGeoTitle')">
|
|
||||||
<CustomGeoSection :active="activeKey === '3'" />
|
|
||||||
</a-collapse-panel>
|
|
||||||
</a-collapse>
|
|
||||||
</a-spin>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mb-12 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reload-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
160
frontend/src/pages/index/XrayLogModal.css
Normal file
160
frontend/src/pages/index/XrayLogModal.css
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
.log-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar .filter-item {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar .download-item {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
--log-blocked: #e04141;
|
||||||
|
--log-proxy: #3c89e8;
|
||||||
|
--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;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container-mobile {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-tag {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-route {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-addr {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-arrow {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta-pair {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta-key {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.6;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
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-blocked: #ff7575;
|
||||||
|
--log-proxy: #6aa6ee;
|
||||||
|
--log-divider: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="ultra-dark"] .log-container {
|
||||||
|
--log-blocked: #ff8a8a;
|
||||||
|
--log-proxy: #7fb6f1;
|
||||||
|
--log-divider: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-modal-mobile {
|
||||||
|
top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-modal-mobile .ant-modal-content {
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-modal-mobile .ant-modal-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table td,
|
||||||
|
.xraylog-table th {
|
||||||
|
padding: 2px 15px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table .log-row-1 {
|
||||||
|
color: var(--log-blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table .log-row-2 {
|
||||||
|
color: var(--log-proxy);
|
||||||
|
}
|
||||||
233
frontend/src/pages/index/XrayLogModal.tsx
Normal file
233
frontend/src/pages/index/XrayLogModal.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
|
||||||
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
||||||
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import './XrayLogModal.css';
|
||||||
|
|
||||||
|
interface XrayLogModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XrayLogEntry {
|
||||||
|
DateTime?: string | number;
|
||||||
|
FromAddress?: string;
|
||||||
|
ToAddress?: string;
|
||||||
|
Inbound?: string;
|
||||||
|
Outbound?: string;
|
||||||
|
Email?: string;
|
||||||
|
Event?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
||||||
|
const EVENT_COLORS: Record<number, string> = { 0: 'green', 1: 'red', 2: 'blue' };
|
||||||
|
|
||||||
|
function eventLabel(ev?: number): string {
|
||||||
|
return EVENT_LABELS[ev ?? -1] ?? String(ev ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventColor(ev?: number): string {
|
||||||
|
return EVENT_COLORS[ev ?? -1] ?? 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTime(value?: string | number): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
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 `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { datepicker } = useDatepicker();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [rows, setRows] = useState('20');
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [showDirect, setShowDirect] = useState(true);
|
||||||
|
const [showBlocked, setShowBlocked] = useState(true);
|
||||||
|
const [showProxy, setShowProxy] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<XrayLogEntry[]>([]);
|
||||||
|
const openRef = useRef(open);
|
||||||
|
|
||||||
|
const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows}`, {
|
||||||
|
filter,
|
||||||
|
showDirect,
|
||||||
|
showBlocked,
|
||||||
|
showProxy,
|
||||||
|
});
|
||||||
|
if (msg?.success) setLogs(msg.obj || []);
|
||||||
|
await PromiseUtil.sleep(300);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [rows, filter, showDirect, showBlocked, showProxy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = open;
|
||||||
|
if (open) refresh();
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openRef.current) refresh();
|
||||||
|
}, [rows, showDirect, showBlocked, showProxy, refresh]);
|
||||||
|
|
||||||
|
function fullDate(value?: string | number): string {
|
||||||
|
return IntlUtil.formatDate(value, datepicker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!Array.isArray(logs) || logs.length === 0) {
|
||||||
|
FileManager.downloadTextFile('', 'x-ui.log');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = logs.map((l) => {
|
||||||
|
try {
|
||||||
|
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
||||||
|
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
||||||
|
const eventText = eventLabel(l.Event);
|
||||||
|
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
||||||
|
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
||||||
|
} catch {
|
||||||
|
return JSON.stringify(l);
|
||||||
|
}
|
||||||
|
}).join('\n');
|
||||||
|
FileManager.downloadTextFile(lines, 'x-ui.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
closable
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '100vw' : '80vw'}
|
||||||
|
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
||||||
|
onCancel={onClose}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{t('pages.index.logs')}
|
||||||
|
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form layout="inline" className="log-toolbar">
|
||||||
|
<Form.Item>
|
||||||
|
<Select value={rows} size="small" style={{ width: 70 }} onChange={setRows}>
|
||||||
|
<Select.Option value="10">10</Select.Option>
|
||||||
|
<Select.Option value="20">20</Select.Option>
|
||||||
|
<Select.Option value="50">50</Select.Option>
|
||||||
|
<Select.Option value="100">100</Select.Option>
|
||||||
|
<Select.Option value="500">500</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('filter')} className="filter-item">
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
size="small"
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'Enter') refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Checkbox checked={showDirect} onChange={(e) => setShowDirect(e.target.checked)}>
|
||||||
|
Direct
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox checked={showBlocked} onChange={(e) => setShowBlocked(e.target.checked)}>
|
||||||
|
Blocked
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox checked={showProxy} onChange={(e) => setShowProxy(e.target.checked)}>
|
||||||
|
Proxy
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item className="download-item">
|
||||||
|
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||||
|
{orderedLogs.length === 0 ? (
|
||||||
|
<div className="log-empty">No Record...</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
orderedLogs.map((log, idx) => (
|
||||||
|
<div key={idx} className="log-card">
|
||||||
|
<div className="log-card-head">
|
||||||
|
<span className="log-time" title={fullDate(log.DateTime)}>
|
||||||
|
{shortTime(log.DateTime)}
|
||||||
|
</span>
|
||||||
|
<Tag color={eventColor(log.Event)} className="log-event-tag">
|
||||||
|
{eventLabel(log.Event)}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="log-route">
|
||||||
|
<span className="log-addr">{log.FromAddress}</span>
|
||||||
|
<span className="log-arrow">→</span>
|
||||||
|
<span className="log-addr">{log.ToAddress}</span>
|
||||||
|
</div>
|
||||||
|
<div className="log-meta">
|
||||||
|
{log.Inbound && (
|
||||||
|
<span className="log-meta-pair">
|
||||||
|
<span className="log-meta-key">in</span>
|
||||||
|
<span className="log-meta-val">{log.Inbound}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.Outbound && (
|
||||||
|
<span className="log-meta-pair">
|
||||||
|
<span className="log-meta-key">out</span>
|
||||||
|
<span className="log-meta-val">{log.Outbound}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.Email && (
|
||||||
|
<span className="log-meta-pair">
|
||||||
|
<span className="log-meta-key">email</span>
|
||||||
|
<span className="log-meta-val">{log.Email}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<table className="xraylog-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Inbound</th>
|
||||||
|
<th>Outbound</th>
|
||||||
|
<th>Email</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orderedLogs.map((log, idx) => (
|
||||||
|
<tr key={idx} className={`log-row-${log.Event}`}>
|
||||||
|
<td>
|
||||||
|
<b>{fullDate(log.DateTime)}</b>
|
||||||
|
</td>
|
||||||
|
<td>{log.FromAddress}</td>
|
||||||
|
<td>{log.ToAddress}</td>
|
||||||
|
<td>{log.Inbound}</td>
|
||||||
|
<td>{log.Outbound}</td>
|
||||||
|
<td>{log.Email}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,357 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
|
||||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
|
||||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const { datepicker } = useDatepicker();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open']);
|
|
||||||
|
|
||||||
const rows = ref('20');
|
|
||||||
const filter = ref('');
|
|
||||||
const showDirect = ref(true);
|
|
||||||
const showBlocked = ref(true);
|
|
||||||
const showProxy = ref(true);
|
|
||||||
const loading = ref(false);
|
|
||||||
const logs = ref([]);
|
|
||||||
|
|
||||||
// Newest first.
|
|
||||||
const orderedLogs = computed(() => [...logs.value].reverse());
|
|
||||||
|
|
||||||
const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
|
||||||
const EVENT_COLORS = { 0: 'green', 1: 'red', 2: 'blue' };
|
|
||||||
|
|
||||||
function eventLabel(ev) { return EVENT_LABELS[ev] || String(ev ?? ''); }
|
|
||||||
function eventColor(ev) { return EVENT_COLORS[ev] || 'default'; }
|
|
||||||
|
|
||||||
function fullDate(value) {
|
|
||||||
return IntlUtil.formatDate(value, datepicker.value);
|
|
||||||
}
|
|
||||||
function shortTime(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
const d = new Date(value);
|
|
||||||
if (isNaN(d.getTime())) return '';
|
|
||||||
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 `${hh}:${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows.value}`, {
|
|
||||||
filter: filter.value,
|
|
||||||
showDirect: showDirect.value,
|
|
||||||
showBlocked: showBlocked.value,
|
|
||||||
showProxy: showProxy.value,
|
|
||||||
});
|
|
||||||
if (msg?.success) logs.value = msg.obj || [];
|
|
||||||
await PromiseUtil.sleep(300);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function download() {
|
|
||||||
if (!Array.isArray(logs.value) || logs.value.length === 0) {
|
|
||||||
FileManager.downloadTextFile('', 'x-ui.log');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lines = logs.value.map((l) => {
|
|
||||||
try {
|
|
||||||
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
|
||||||
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
|
||||||
const eventText = eventLabel(l.Event);
|
|
||||||
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
|
||||||
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
|
||||||
} catch (_e) {
|
|
||||||
return JSON.stringify(l);
|
|
||||||
}
|
|
||||||
}).join('\n');
|
|
||||||
FileManager.downloadTextFile(lines, 'x-ui.log');
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => { if (next) refresh(); });
|
|
||||||
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
|
|
||||||
|
|
||||||
const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
|
|
||||||
:class="{ 'xraylog-modal-mobile': isMobile }" @cancel="close">
|
|
||||||
<template #title>
|
|
||||||
{{ t('pages.index.logs') }}
|
|
||||||
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-form layout="inline" class="log-toolbar">
|
|
||||||
<a-form-item>
|
|
||||||
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
|
||||||
<a-select-option value="10">10</a-select-option>
|
|
||||||
<a-select-option value="20">20</a-select-option>
|
|
||||||
<a-select-option value="50">50</a-select-option>
|
|
||||||
<a-select-option value="100">100</a-select-option>
|
|
||||||
<a-select-option value="500">500</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item :label="t('filter')" class="filter-item">
|
|
||||||
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-checkbox v-model:checked="showDirect">Direct</a-checkbox>
|
|
||||||
<a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
|
|
||||||
<a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item class="download-item">
|
|
||||||
<a-button type="primary" @click="download">
|
|
||||||
<template #icon>
|
|
||||||
<DownloadOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
|
|
||||||
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
|
||||||
<div v-if="orderedLogs.length === 0" class="log-empty">No Record...</div>
|
|
||||||
|
|
||||||
<template v-else-if="isMobile">
|
|
||||||
<div v-for="(log, idx) in orderedLogs" :key="idx" class="log-card">
|
|
||||||
<div class="log-card-head">
|
|
||||||
<span class="log-time" :title="fullDate(log.DateTime)">{{ shortTime(log.DateTime) }}</span>
|
|
||||||
<a-tag :color="eventColor(log.Event)" class="log-event-tag">{{ eventLabel(log.Event) }}</a-tag>
|
|
||||||
</div>
|
|
||||||
<div class="log-route">
|
|
||||||
<span class="log-addr">{{ log.FromAddress }}</span>
|
|
||||||
<span class="log-arrow">→</span>
|
|
||||||
<span class="log-addr">{{ log.ToAddress }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="log-meta">
|
|
||||||
<span v-if="log.Inbound" class="log-meta-pair">
|
|
||||||
<span class="log-meta-key">in</span>
|
|
||||||
<span class="log-meta-val">{{ log.Inbound }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="log.Outbound" class="log-meta-pair">
|
|
||||||
<span class="log-meta-key">out</span>
|
|
||||||
<span class="log-meta-val">{{ log.Outbound }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="log.Email" class="log-meta-pair">
|
|
||||||
<span class="log-meta-key">email</span>
|
|
||||||
<span class="log-meta-val">{{ log.Email }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<table v-else class="xraylog-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>From</th>
|
|
||||||
<th>To</th>
|
|
||||||
<th>Inbound</th>
|
|
||||||
<th>Outbound</th>
|
|
||||||
<th>Email</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(log, idx) in orderedLogs" :key="idx" :class="`log-row-${log.Event}`">
|
|
||||||
<td><b>{{ fullDate(log.DateTime) }}</b></td>
|
|
||||||
<td>{{ log.FromAddress }}</td>
|
|
||||||
<td>{{ log.ToAddress }}</td>
|
|
||||||
<td>{{ log.Inbound }}</td>
|
|
||||||
<td>{{ log.Outbound }}</td>
|
|
||||||
<td>{{ log.Email }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.reload-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-toolbar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-toolbar .filter-item {
|
|
||||||
flex: 1 1 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-toolbar .download-item {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-container {
|
|
||||||
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
|
||||||
below so blocked/proxy rows keep ≥4.5:1 contrast on darker surfaces. */
|
|
||||||
--log-blocked: #e04141;
|
|
||||||
--log-proxy: #3c89e8;
|
|
||||||
--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;
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-container-mobile {
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-empty {
|
|
||||||
text-align: center;
|
|
||||||
opacity: 0.5;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-event-tag {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 16px;
|
|
||||||
padding: 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-route {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-addr {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-arrow {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-meta-pair {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-meta-key {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
opacity: 0.6;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(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-blocked: #ff7575;
|
|
||||||
--log-proxy: #6aa6ee;
|
|
||||||
--log-divider: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global([data-theme="ultra-dark"]) .log-container {
|
|
||||||
--log-blocked: #ff8a8a;
|
|
||||||
--log-proxy: #7fb6f1;
|
|
||||||
--log-divider: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: pull the modal flush with the screen edges. */
|
|
||||||
:global(.xraylog-modal-mobile) {
|
|
||||||
top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
max-width: 100vw !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.xraylog-modal-mobile .ant-modal-content) {
|
|
||||||
border-radius: 0;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.xraylog-modal-mobile .ant-modal-body) {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xraylog-table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xraylog-table td,
|
|
||||||
.xraylog-table th {
|
|
||||||
padding: 2px 15px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xraylog-table .log-row-1 {
|
|
||||||
color: var(--log-blocked);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xraylog-table .log-row-2 {
|
|
||||||
color: var(--log-proxy);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
53
frontend/src/pages/index/XrayMetricsModal.css
Normal file
53
frontend/src/pages/index/XrayMetricsModal.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
343
frontend/src/pages/index/XrayMetricsModal.tsx
Normal file
343
frontend/src/pages/index/XrayMetricsModal.tsx
Normal file
|
|
@ -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<number[]>([]);
|
||||||
|
const [labels, setLabels] = useState<string[]>([]);
|
||||||
|
const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
|
||||||
|
const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
|
||||||
|
const [obsActiveTag, setObsActiveTag] = useState('');
|
||||||
|
const obsTimerRef = useRef<number | null>(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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
closable
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '95vw' : 900}
|
||||||
|
onCancel={onClose}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{t('pages.index.xrayMetricsTitle')}
|
||||||
|
<Select
|
||||||
|
value={bucket}
|
||||||
|
size="small"
|
||||||
|
className="bucket-select"
|
||||||
|
onChange={setBucket}
|
||||||
|
options={[
|
||||||
|
{ value: 2, label: '2m' },
|
||||||
|
{ value: 30, label: '30m' },
|
||||||
|
{ value: 60, label: '1h' },
|
||||||
|
{ value: 120, label: '2h' },
|
||||||
|
{ value: 180, label: '3h' },
|
||||||
|
{ value: 300, label: '5h' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!state.enabled && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
className="metrics-alert"
|
||||||
|
message={t('pages.index.xrayMetricsDisabled')}
|
||||||
|
description={state.reason || t('pages.index.xrayMetricsHint')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={setActiveKey}
|
||||||
|
size="small"
|
||||||
|
className="history-tabs"
|
||||||
|
items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isObservatory && (
|
||||||
|
<div className="obs-pane">
|
||||||
|
{state.enabled && obsTags.length === 0 ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="metrics-alert"
|
||||||
|
message={t('pages.index.xrayObservatoryEmpty')}
|
||||||
|
description={t('pages.index.xrayObservatoryHint')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="obs-controls">
|
||||||
|
<Select
|
||||||
|
value={obsActiveTag}
|
||||||
|
size="small"
|
||||||
|
className="obs-select"
|
||||||
|
placeholder={t('pages.index.xrayObservatoryTagPlaceholder')}
|
||||||
|
onChange={setObsActiveTag}
|
||||||
|
options={obsTags.map((tg) => ({
|
||||||
|
value: tg.tag,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<span className={`obs-dot ${tg.alive ? 'is-alive' : 'is-dead'}`} />
|
||||||
|
{tg.tag}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeObsTag && (
|
||||||
|
<div className="obs-stats">
|
||||||
|
<Tag color={activeObsTag.alive ? 'green' : 'red'}>
|
||||||
|
{activeObsTag.alive
|
||||||
|
? t('pages.index.xrayObservatoryAlive')
|
||||||
|
: t('pages.index.xrayObservatoryDead')}
|
||||||
|
</Tag>
|
||||||
|
<Tag color="blue">{activeObsTag.delay} ms</Tag>
|
||||||
|
<span className="obs-stamp">
|
||||||
|
{t('pages.index.xrayObservatoryLastSeen')}: {fmtTimestamp(activeObsTag.lastSeenTime)}
|
||||||
|
</span>
|
||||||
|
<span className="obs-stamp">
|
||||||
|
{t('pages.index.xrayObservatoryLastTry')}: {fmtTimestamp(activeObsTag.lastTryTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="cpu-chart-wrap">
|
||||||
|
<div className="cpu-chart-meta">
|
||||||
|
Timeframe: {bucket} sec per point (total {points.length} points)
|
||||||
|
{state.enabled && state.listen && (
|
||||||
|
<span className="listen-tag"> · {state.listen}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Sparkline
|
||||||
|
data={points}
|
||||||
|
labels={labels}
|
||||||
|
vbWidth={840}
|
||||||
|
height={220}
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
showGrid
|
||||||
|
showAxes
|
||||||
|
tickCountX={5}
|
||||||
|
maxPoints={points.length || 1}
|
||||||
|
fillOpacity={0.18}
|
||||||
|
markerRadius={3.2}
|
||||||
|
showTooltip
|
||||||
|
valueMin={0}
|
||||||
|
valueMax={null}
|
||||||
|
yFormatter={yFormatter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,347 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
|
||||||
import Sparkline from '@/components/Sparkline.vue';
|
|
||||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
const emit = defineEmits(['update:open']);
|
|
||||||
|
|
||||||
const OBS_KEY = 'xrObs';
|
|
||||||
|
|
||||||
const metrics = [
|
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeKey = ref('xrAlloc');
|
|
||||||
const bucket = ref(2);
|
|
||||||
const points = ref([]);
|
|
||||||
const labels = ref([]);
|
|
||||||
const state = ref({ enabled: false, listen: '', reason: '' });
|
|
||||||
const obsTags = ref([]);
|
|
||||||
const obsActiveTag = ref('');
|
|
||||||
let obsTimer = null;
|
|
||||||
|
|
||||||
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
|
||||||
const isObservatory = computed(() => activeKey.value === OBS_KEY);
|
|
||||||
const strokeColor = computed(() => activeMetric.value?.stroke || '#008771');
|
|
||||||
const activeObsTag = computed(() => obsTags.value.find((tg) => tg.tag === obsActiveTag.value) || null);
|
|
||||||
|
|
||||||
function unitFormatter(unit) {
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
|
||||||
|
|
||||||
function fmtTimestamp(unixSec) {
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchState() {
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
|
|
||||||
if (msg?.success && msg.obj) state.value = msg.obj;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch xray metrics state', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchObservatory() {
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
|
|
||||||
if (msg?.success && Array.isArray(msg.obj)) {
|
|
||||||
obsTags.value = msg.obj;
|
|
||||||
if (!obsTags.value.find((tg) => tg.tag === obsActiveTag.value)) {
|
|
||||||
obsActiveTag.value = obsTags.value[0]?.tag || '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
obsTags.value = [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch observatory snapshot', e);
|
|
||||||
obsTags.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMetricBucket() {
|
|
||||||
const m = activeMetric.value;
|
|
||||||
if (!m) return;
|
|
||||||
try {
|
|
||||||
const url = `/panel/api/server/xrayMetricsHistory/${m.key}/${bucket.value}`;
|
|
||||||
const msg = await HttpUtil.get(url);
|
|
||||||
applyHistory(msg);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch xray metrics bucket', e);
|
|
||||||
labels.value = [];
|
|
||||||
points.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchObsBucket() {
|
|
||||||
const tag = obsActiveTag.value;
|
|
||||||
if (!tag) {
|
|
||||||
labels.value = [];
|
|
||||||
points.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(tag)}/${bucket.value}`;
|
|
||||||
const msg = await HttpUtil.get(url);
|
|
||||||
applyHistory(msg);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch observatory bucket', e);
|
|
||||||
labels.value = [];
|
|
||||||
points.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyHistory(msg) {
|
|
||||||
if (msg?.success && Array.isArray(msg.obj)) {
|
|
||||||
const vals = [];
|
|
||||||
const labs = [];
|
|
||||||
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.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
|
||||||
vals.push(Number(p.v) || 0);
|
|
||||||
}
|
|
||||||
labels.value = labs;
|
|
||||||
points.value = vals;
|
|
||||||
} else {
|
|
||||||
labels.value = [];
|
|
||||||
points.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshActive() {
|
|
||||||
if (isObservatory.value) {
|
|
||||||
fetchObsBucket();
|
|
||||||
} else {
|
|
||||||
fetchMetricBucket();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startObsPolling() {
|
|
||||||
stopObsPolling();
|
|
||||||
obsTimer = window.setInterval(async () => {
|
|
||||||
if (!props.open || !isObservatory.value) return;
|
|
||||||
await fetchObservatory();
|
|
||||||
fetchObsBucket();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopObsPolling() {
|
|
||||||
if (obsTimer != null) {
|
|
||||||
window.clearInterval(obsTimer);
|
|
||||||
obsTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => {
|
|
||||||
if (next) {
|
|
||||||
activeKey.value = 'xrAlloc';
|
|
||||||
fetchState();
|
|
||||||
fetchMetricBucket();
|
|
||||||
} else {
|
|
||||||
stopObsPolling();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(activeKey, async (key) => {
|
|
||||||
if (!props.open) return;
|
|
||||||
if (key === OBS_KEY) {
|
|
||||||
await fetchObservatory();
|
|
||||||
fetchObsBucket();
|
|
||||||
startObsPolling();
|
|
||||||
} else {
|
|
||||||
stopObsPolling();
|
|
||||||
fetchMetricBucket();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(bucket, () => {
|
|
||||||
if (props.open) refreshActive();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(obsActiveTag, () => {
|
|
||||||
if (props.open && isObservatory.value) fetchObsBucket();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
|
||||||
<template #title>
|
|
||||||
{{ t('pages.index.xrayMetricsTitle') }}
|
|
||||||
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
|
||||||
<a-select-option :value="2">2m</a-select-option>
|
|
||||||
<a-select-option :value="30">30m</a-select-option>
|
|
||||||
<a-select-option :value="60">1h</a-select-option>
|
|
||||||
<a-select-option :value="120">2h</a-select-option>
|
|
||||||
<a-select-option :value="180">3h</a-select-option>
|
|
||||||
<a-select-option :value="300">5h</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-alert v-if="!state.enabled" type="warning" show-icon class="metrics-alert"
|
|
||||||
:message="t('pages.index.xrayMetricsDisabled')"
|
|
||||||
:description="state.reason || t('pages.index.xrayMetricsHint')" />
|
|
||||||
|
|
||||||
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
|
|
||||||
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
|
|
||||||
</a-tabs>
|
|
||||||
|
|
||||||
<div v-if="isObservatory" class="obs-pane">
|
|
||||||
<a-alert v-if="state.enabled && obsTags.length === 0" type="info" show-icon class="metrics-alert"
|
|
||||||
:message="t('pages.index.xrayObservatoryEmpty')"
|
|
||||||
:description="t('pages.index.xrayObservatoryHint')" />
|
|
||||||
|
|
||||||
<div v-else class="obs-controls">
|
|
||||||
<a-select v-model:value="obsActiveTag" size="small" class="obs-select"
|
|
||||||
:placeholder="t('pages.index.xrayObservatoryTagPlaceholder')">
|
|
||||||
<a-select-option v-for="tg in obsTags" :key="tg.tag" :value="tg.tag">
|
|
||||||
<span class="obs-dot" :class="tg.alive ? 'is-alive' : 'is-dead'" />
|
|
||||||
{{ tg.tag }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
|
|
||||||
<div v-if="activeObsTag" class="obs-stats">
|
|
||||||
<a-tag :color="activeObsTag.alive ? 'green' : 'red'">
|
|
||||||
{{ activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead') }}
|
|
||||||
</a-tag>
|
|
||||||
<a-tag color="blue">{{ activeObsTag.delay }} ms</a-tag>
|
|
||||||
<span class="obs-stamp">
|
|
||||||
{{ t('pages.index.xrayObservatoryLastSeen') }}: {{ fmtTimestamp(activeObsTag.lastSeenTime) }}
|
|
||||||
</span>
|
|
||||||
<span class="obs-stamp">
|
|
||||||
{{ t('pages.index.xrayObservatoryLastTry') }}: {{ fmtTimestamp(activeObsTag.lastTryTime) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cpu-chart-wrap">
|
|
||||||
<div class="cpu-chart-meta">
|
|
||||||
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
|
||||||
<span v-if="state.enabled && state.listen" class="listen-tag"> · {{ state.listen }}</span>
|
|
||||||
</div>
|
|
||||||
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
|
|
||||||
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
|
|
||||||
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="null" :y-formatter="yFormatter" />
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bucket-select {
|
|
||||||
width: 80px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-alert {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-tabs {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cpu-chart-wrap {
|
|
||||||
padding: 8px 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cpu-chart-meta {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listen-tag {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
44
frontend/src/pages/index/XrayStatusCard.css
Normal file
44
frontend/src/pages/index/XrayStatusCard.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
frontend/src/pages/index/XrayStatusCard.tsx
Normal file
137
frontend/src/pages/index/XrayStatusCard.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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 = (
|
||||||
|
<Space direction="horizontal">
|
||||||
|
<span>{t('pages.index.xrayStatus')}</span>
|
||||||
|
{isMobile && status.xray.version && status.xray.version !== 'Unknown' && (
|
||||||
|
<Tag color="green">v{status.xray.version}</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorLines = useMemo(
|
||||||
|
() => (status.xray.errorMsg || '').split('\n'),
|
||||||
|
[status.xray.errorMsg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const extra =
|
||||||
|
status.xray.state !== 'error' ? (
|
||||||
|
<Badge
|
||||||
|
status="processing"
|
||||||
|
className={`xray-processing-animation ${badgeAnimationClass(status.xray.color)}`}
|
||||||
|
text={stateText}
|
||||||
|
color={status.xray.color}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Popover
|
||||||
|
title={
|
||||||
|
<Row align="middle" justify="space-between">
|
||||||
|
<Col>
|
||||||
|
<span>{t('pages.index.xrayStatusError')}</span>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<BarsOutlined className="cursor-pointer" onClick={onOpenLogs} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
{errorLines.map((line, i) => (
|
||||||
|
<span key={i} className="error-line">
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
status="processing"
|
||||||
|
text={stateText}
|
||||||
|
color={status.xray.color}
|
||||||
|
className="xray-processing-animation xray-error-animation"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
...(ipLimitEnable
|
||||||
|
? [
|
||||||
|
<Space direction="horizontal" className="action" key="xraylogs" onClick={onOpenXrayLogs}>
|
||||||
|
<BarsOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.logs')}</span>}
|
||||||
|
</Space>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
<Space direction="horizontal" className="action" key="stop" onClick={onStopXray}>
|
||||||
|
<PoweroffOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
|
||||||
|
</Space>,
|
||||||
|
<Space direction="horizontal" className="action" key="restart" onClick={onRestartXray}>
|
||||||
|
<ReloadOutlined />
|
||||||
|
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
|
||||||
|
</Space>,
|
||||||
|
<Space direction="horizontal" className="action" key="switch" onClick={onOpenVersionSwitch}>
|
||||||
|
<ToolOutlined />
|
||||||
|
{!isMobile && (
|
||||||
|
<span>
|
||||||
|
{status.xray.version && status.xray.version !== 'Unknown'
|
||||||
|
? `v${status.xray.version}`
|
||||||
|
: t('pages.index.xraySwitch')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Space>,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hoverable title={title} extra={extra} actions={actions} className="xray-status-card" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import {
|
|
||||||
BarsOutlined,
|
|
||||||
PoweroffOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
ToolOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
status: { type: Object, required: true },
|
|
||||||
isMobile: { type: Boolean, default: false },
|
|
||||||
ipLimitEnable: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits(['stop-xray', 'restart-xray', 'open-logs', 'open-xray-logs', 'open-version-switch']);
|
|
||||||
|
|
||||||
const XRAY_STATE_KEYS = {
|
|
||||||
running: 'pages.index.xrayStatusRunning',
|
|
||||||
stop: 'pages.index.xrayStatusStop',
|
|
||||||
error: 'pages.index.xrayStatusError',
|
|
||||||
};
|
|
||||||
|
|
||||||
const stateText = computed(() =>
|
|
||||||
t(XRAY_STATE_KEYS[props.status.xray.state] ?? 'pages.index.xrayStatusUnknown'),
|
|
||||||
);
|
|
||||||
|
|
||||||
function badgeAnimationClass(color) {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-card hoverable>
|
|
||||||
<template #title>
|
|
||||||
<a-space direction="horizontal">
|
|
||||||
<span>{{ t('pages.index.xrayStatus') }}</span>
|
|
||||||
<a-tag v-if="isMobile && status.xray.version && status.xray.version !== 'Unknown'" color="green">
|
|
||||||
v{{ status.xray.version }}
|
|
||||||
</a-tag>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #extra>
|
|
||||||
<template v-if="status.xray.state !== 'error'">
|
|
||||||
<a-badge status="processing" :class="['xray-processing-animation', badgeAnimationClass(status.xray.color)]"
|
|
||||||
:text="stateText" :color="status.xray.color" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<a-popover>
|
|
||||||
<template #title>
|
|
||||||
<a-row type="flex" align="middle" justify="space-between">
|
|
||||||
<a-col><span>{{ t('pages.index.xrayStatusError') }}</span></a-col>
|
|
||||||
<a-col>
|
|
||||||
<BarsOutlined class="cursor-pointer" @click="$emit('open-logs')" />
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<span v-for="(line, i) in (status.xray.errorMsg || '').split('\n')" :key="i" class="error-line">
|
|
||||||
{{ line }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<a-badge status="processing" :text="stateText" :color="status.xray.color"
|
|
||||||
:class="['xray-processing-animation', 'xray-error-animation']" />
|
|
||||||
</a-popover>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<a-space v-if="ipLimitEnable" direction="horizontal" class="action" @click="$emit('open-xray-logs')">
|
|
||||||
<BarsOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space direction="horizontal" class="action" @click="$emit('stop-xray')">
|
|
||||||
<PoweroffOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.stopXray') }}</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space direction="horizontal" class="action" @click="$emit('restart-xray')">
|
|
||||||
<ReloadOutlined />
|
|
||||||
<span v-if="!isMobile">{{ t('pages.index.restartXray') }}</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space direction="horizontal" class="action" @click="$emit('open-version-switch')">
|
|
||||||
<ToolOutlined />
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ status.xray.version && status.xray.version !== 'Unknown'
|
|
||||||
? `v${status.xray.version}`
|
|
||||||
: t('pages.index.xraySwitch') }}
|
|
||||||
</span>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.action {
|
|
||||||
cursor: pointer;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-line {
|
|
||||||
display: block;
|
|
||||||
max-width: 400px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Legacy xray-*-animation classes — they need to be global so they
|
|
||||||
* pierce the AD-Vue badge's internal DOM (.ant-badge-status-*). */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -96,10 +96,10 @@ export default function NodeFormModal({
|
||||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||||
}
|
}
|
||||||
: base;
|
: base;
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
setForm(next);
|
setForm(next);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
}, [open, mode, node]);
|
}, [open, mode, node]);
|
||||||
|
|
||||||
const title = useMemo(
|
const title = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
loadApiTokens();
|
loadApiTokens();
|
||||||
}, [loadApiTokens]);
|
}, [loadApiTokens]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,12 +93,12 @@ export default function SettingsPage() {
|
||||||
const [entryIsIP, setEntryIsIP] = useState(false);
|
const [entryIsIP, setEntryIsIP] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
setEntryHost(host);
|
setEntryHost(host);
|
||||||
setEntryPort(window.location.port);
|
setEntryPort(window.location.port);
|
||||||
setEntryIsIP(isIp(host));
|
setEntryIsIP(isIp(host));
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [alertVisible, setAlertVisible] = useState(true);
|
const [alertVisible, setAlertVisible] = useState(true);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export default function TwoFactorModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
setEnteredCode('');
|
setEnteredCode('');
|
||||||
totpRef.current = null;
|
totpRef.current = null;
|
||||||
setQrValue('');
|
setQrValue('');
|
||||||
|
|
@ -50,7 +50,7 @@ export default function TwoFactorModal({
|
||||||
totpRef.current = totp;
|
totpRef.current = totp;
|
||||||
setQrValue(totp.toString());
|
setQrValue(totp.toString());
|
||||||
}
|
}
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
}, [open, token]);
|
}, [open, token]);
|
||||||
|
|
||||||
function close(success: boolean, code = '') {
|
function close(success: boolean, code = '') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue