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:
MHSanaei 2026-05-21 22:20:09 +02:00
parent ef36757b88
commit 107fa877e5
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
55 changed files with 3542 additions and 2878 deletions

View file

@ -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>

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

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

View file

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

View file

@ -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');
});

View 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>,
);
}
});

View file

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

View file

@ -52,7 +52,7 @@ export function useAllSetting() {
); );
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchAll(); fetchAll();
}, [fetchAll]); }, [fetchAll]);

View file

@ -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 {

View file

@ -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]);

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

View file

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

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

View file

@ -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]);

View file

@ -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]);

View file

@ -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]);

View file

@ -0,0 +1,9 @@
.backup-list {
width: 100%;
}
.backup-item {
display: flex;
align-items: center;
gap: 16px;
}

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

View file

@ -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>

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

View file

@ -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>

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

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

View file

@ -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>

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

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

View file

@ -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>

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

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

View file

@ -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>

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

View 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>
</>
);
}

View file

@ -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>

View file

@ -0,0 +1,8 @@
.status-card .text-center {
text-align: center;
}
.status-card .ant-progress-text {
font-size: 14px !important;
font-weight: 500;
}

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

View file

@ -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>

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

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

View file

@ -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>

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

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

View file

@ -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>

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

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

View file

@ -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>

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

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

View file

@ -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>

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

View 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" />
);
}

View file

@ -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>

View file

@ -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(

View file

@ -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]);

View file

@ -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);

View file

@ -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 = '') {