P7: restore agent node type, agent heartbeat expiry, build-version injection, remove log/history/xray-metrics modules, optional sub port

- nodes: port node-type (traditional/agent) picker back from web-src into
  the embedded frontend; backend annotates NodeView.nodeType from
  agent_nodes bindings; fix agent-node update path (skip reachability
  probe and apiToken requirement for agent-managed nodes); restore i18n
  keys in all 13 locales; install command now uses the Gitea mirror
- jobs: expire agent-bound nodes whose agent_nodes.last_seen is older
  than max(3x heartbeat interval, 120s) = 180s; last_seen=0 (never
  registered) is left untouched; transitions reuse UpdateHeartbeat +
  node event broadcast
- config: GetBaseVersion honors ldflags-injected buildVersion; release
  builds inject 3.7.0-agent.1 so the panel no longer reports a phantom
  update against the Gitea release tag
- logger/server: remove the three log viewers (panel/xray/amneziawg),
  their API endpoints and the 10240-entry in-memory log buffer; file
  logging via lumberjack unchanged; tests assert via a new optional
  logger hook instead of the removed buffer
- server: remove system history and xray metrics modules end to end
  (endpoints, sampling appends in RefreshStatus, per-minute persist
  cron, gob snapshot/restore, XrayMetricsService); realtime status and
  per-node history are untouched
- settings: subPort may be 0/unset - subscription links are generated
  without a port while the subscription service keeps binding the
  default 2096; the settings form declares the cleared state via
  onNumber's new onClear option instead of snapping back to 1
This commit is contained in:
Muse
2026-10-21 06:44:00 +00:00
parent 2c85f85549
commit f887f47bb9
66 changed files with 1093 additions and 5129 deletions
+2 -2
View File
@@ -98,7 +98,7 @@ export const AllSettingSchema = z.object({
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
@@ -214,7 +214,7 @@ export const AllSettingViewSchema = z.object({
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
@@ -1,17 +0,0 @@
.awglog-events-title {
margin-top: 14px;
font-size: 12px;
font-weight: 600;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.awglog-event-line {
padding: 2px 0;
word-break: break-word;
}
.xraylog-table .log-row-offline {
opacity: 0.6;
}
@@ -1,254 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil, SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { AmneziaWGLogs } from '@/generated/types';
import './XrayLogModal.css';
import './AmneziaWGLogModal.css';
interface AmneziaWGLogModalProps {
open: boolean;
onClose: () => void;
}
const AUTO_UPDATE_INTERVAL = 5000;
function shortTime(value?: 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 AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('50');
const [filter, setFilter] = useState('');
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<Partial<AmneziaWGLogs>>({});
const peers = useMemo(() => logs.peers ?? [], [logs.peers]);
const events = useMemo(() => logs.events ?? [], [logs.events]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<AmneziaWGLogs>(`/panel/api/server/amneziawglogs/${rows}`, {
filter,
});
if (msg?.success) setLogs(msg.obj || {});
await PromiseUtil.sleep(300);
} finally {
setLoading(false);
}
}, [rows, filter]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open ? `${rows}|${filter}` : null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, rows, filter, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
function fullDate(value?: number): string {
return value ? IntlUtil.formatDate(value, datepicker) : '';
}
function download() {
const peerLines = peers.map((p) => {
const at = p.handshake ? new Date(p.handshake).toISOString() : 'never';
return `${at} IFACE=${p.interface || ''} INBOUND=${p.tag || ''} EMAIL=${p.email || ''} ENDPOINT=${p.endpoint || '-'} ALLOWEDIPS=${p.allowedIPs || ''} UP=${p.up ?? 0} DOWN=${p.down ?? 0} ONLINE=${p.online ? 'yes' : 'no'}`;
});
FileManager.downloadTextFile([...peerLines, '', ...events].join('\n'), 'amneziawg.log');
}
return (
<Modal
open={open}
footer={null}
width={isMobile ? '100vw' : '80vw'}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'xraylog-modal-mobile' : undefined}
onCancel={onClose}
title={
<>
{t('pages.index.amneziawgLogs')}
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Select
value={rows}
size="small"
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
]}
/>
</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={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{peers.length === 0 ? (
<div className="log-empty">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('pages.index.amneziawgNoPeers')}
/>
</div>
) : isMobile ? (
peers.map((peer, idx) => (
<div key={idx} className="log-card">
<div className="log-card-head">
<span className="log-time" title={fullDate(peer.handshake)}>
{shortTime(peer.handshake) || '—'}
</span>
<Tag color={peer.online ? 'green' : 'default'} className="log-event-tag">
{peer.online ? t('online') : t('pages.index.amneziawgIdle')}
</Tag>
</div>
<div className="log-route">
<span className="log-addr">{peer.endpoint || '—'}</span>
<span className="log-arrow">→</span>
<span className="log-addr">{peer.allowedIPs}</span>
</div>
<div className="log-meta">
<span className="log-meta-pair">
<span className="log-meta-key">iface</span>
<span className="log-meta-val">{peer.interface}</span>
</span>
<span className="log-meta-pair">
<span className="log-meta-key">inbound</span>
<span className="log-meta-val">{peer.tag}</span>
</span>
{peer.email && (
<span className="log-meta-pair">
<span className="log-meta-key">email</span>
<span className="log-meta-val">{peer.email}</span>
</span>
)}
<span className="log-meta-pair">
<span className="log-meta-key">↑↓</span>
<span className="log-meta-val">
{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}
</span>
</span>
</div>
</div>
))
) : (
<table className="xraylog-table">
<thead>
<tr>
<th>{t('pages.index.amneziawgHandshake')}</th>
<th>{t('pages.index.amneziawgInterface')}</th>
<th>{t('pages.index.amneziawgInbound')}</th>
<th>Email</th>
<th>{t('pages.index.amneziawgEndpoint')}</th>
<th>{t('pages.clients.amneziaWgAllowedIPs')}</th>
<th>↑ / ↓</th>
</tr>
</thead>
<tbody>
{peers.map((peer, idx) => (
<tr key={idx} className={peer.online ? undefined : 'log-row-offline'}>
<td>
<b>{fullDate(peer.handshake) || '—'}</b>
</td>
<td>{peer.interface}</td>
<td>{peer.tag}</td>
<td>{peer.email}</td>
<td>{peer.endpoint || '—'}</td>
<td>{peer.allowedIPs}</td>
<td>{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="awglog-events-title">{t('pages.index.amneziawgEvents')}</div>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{events.length === 0 ? (
<div className="log-empty">{t('pages.index.amneziawgNoEvents')}</div>
) : (
events.map((line, idx) => (
<div key={idx} className="awglog-event-line">
{line}
</div>
))
)}
</div>
</Modal>
);
}
+1 -41
View File
@@ -1,38 +1,18 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
interface ConnectionsCardProps {
status: Status;
tcp: number[];
udp: number[];
labels: string[];
isMobile: boolean;
}
export default function ConnectionsCard({
status,
tcp,
udp,
labels,
isMobile,
}: ConnectionsCardProps) {
export default function ConnectionsCard({ status }: ConnectionsCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const udpColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.udpCount, color: udpColor, dash: '2 4' },
{ y: status.tcpCount, color: accent, dash: '2 4' },
],
[status.tcpCount, status.udpCount, accent, udpColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head ov-wide-head-stack">
@@ -55,26 +35,6 @@ export default function ConnectionsCard({
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={tcp}
data2={udp}
labels={labels}
height={isMobile ? 120 : 170}
strokeWidth={1.5}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={udpColor}
name1="TCP"
name2="UDP"
yFormatter={(v) => Math.round(v).toLocaleString()}
referenceLines={referenceLines}
/>
</div>
</Card>
);
}
+9 -73
View File
@@ -28,16 +28,10 @@ import VitalTile from './VitalTile';
import ThroughputCard from './ThroughputCard';
import ConnectionsCard from './ConnectionsCard';
import SystemStrip from './SystemStrip';
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
const LogModal = lazy(() => import('./LogModal'));
const BackupModal = lazy(() => import('./BackupModal'));
const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
const XrayLogModal = lazy(() => import('./XrayLogModal'));
const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
import './IndexPage.css';
export default function IndexPage() {
@@ -50,7 +44,6 @@ export default function IndexPage() {
setMessageInstance(messageApi);
}, [messageApi]);
const [accessLogEnable, setAccessLogEnable] = useState(false);
const [devChannelEnable, setDevChannelEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '',
@@ -61,29 +54,21 @@ export default function IndexPage() {
const basePath = window.X_UI_BASE_PATH || '';
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 [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
const [configTextOpen, setConfigTextOpen] = useState(false);
const [configText, setConfigText] = useState('');
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
).then((msg) => {
if (msg?.success && msg.obj) {
setAccessLogEnable(!!msg.obj.accessLogEnable);
setDevChannelEnable(!!msg.obj.devChannelEnable);
}
});
HttpUtil.post<{ devChannelEnable?: boolean }>('/panel/api/setting/defaultSettings').then(
(msg) => {
if (msg?.success && msg.obj) {
setDevChannelEnable(!!msg.obj.devChannelEnable);
}
},
);
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
});
@@ -182,19 +167,12 @@ export default function IndexPage() {
) : (
<div className="ov-page">
<OverviewActionBar
status={status}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
panelVersion={displayVersion}
latestVersion={panelUpdateInfo.latestVersion}
updateAvailable={panelUpdateInfo.updateAvailable}
onOpenLogs={() => setLogsOpen(true)}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
onOpenConfig={openConfig}
onOpenBackup={() => setBackupOpen(true)}
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
/>
@@ -214,9 +192,6 @@ export default function IndexPage() {
percent={status.cpu.percent}
statusColor={status.cpu.color}
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
data={history.series.cpu}
isMobile={isMobile}
/>
<VitalTile
@@ -225,9 +200,6 @@ export default function IndexPage() {
percent={status.mem.percent}
statusColor={status.mem.color}
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
data={history.series.mem}
isMobile={isMobile}
/>
<VitalTile
@@ -236,9 +208,6 @@ export default function IndexPage() {
percent={status.swap.percent}
statusColor={status.swap.color}
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
data={history.series.swap}
isMobile={isMobile}
/>
<VitalTile
@@ -248,27 +217,13 @@ export default function IndexPage() {
statusColor={status.disk.color}
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
data={history.series.diskUsage}
isMobile={isMobile}
/>
</div>
<div className="ov-mid">
<ThroughputCard
status={status}
up={history.series.netUp}
down={history.series.netDown}
labels={history.labels}
isMobile={isMobile}
/>
<ConnectionsCard
status={status}
tcp={history.series.tcpCount}
udp={history.series.udpCount}
labels={history.labels}
isMobile={isMobile}
/>
<ThroughputCard status={status} />
<ConnectionsCard status={status} />
</div>
<SystemStrip
@@ -292,9 +247,6 @@ export default function IndexPage() {
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={logsOpen}>
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
</LazyMount>
<LazyMount when={backupOpen}>
<BackupModal
open={backupOpen}
@@ -303,22 +255,6 @@ export default function IndexPage() {
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={sysHistoryOpen}>
<SystemHistoryModal
open={sysHistoryOpen}
status={status}
onClose={() => setSysHistoryOpen(false)}
/>
</LazyMount>
<LazyMount when={xrayMetricsOpen}>
<XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
</LazyMount>
<LazyMount when={xrayLogsOpen}>
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
</LazyMount>
<LazyMount when={amneziawgLogsOpen}>
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
</LazyMount>
<LazyMount when={configTextOpen}>
<Modal
open={configTextOpen}
-172
View File
@@ -1,172 +0,0 @@
.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 var(--ant-color-border);
border-radius: 6px;
background: var(--ant-color-fill-tertiary);
color: var(--ant-color-text);
}
.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 {
--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 .ant-modal-content {
border-radius: 0;
height: 100vh;
}
.logmodal-mobile .ant-modal-body {
padding: 12px;
}
-202
View File
@@ -1,202 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse';
import './LogModal.css';
interface LogModalProps {
open: boolean;
onClose: () => void;
}
const AUTO_UPDATE_INTERVAL = 5000;
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 [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
level,
syslog,
});
if (msg?.success) {
setLogs(msg.obj || []);
}
await PromiseUtil.sleep(300);
} finally {
setLoading(false);
}
}, [rows, level, syslog]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open ? `${rows}\u0000${level}\u0000${syslog}` : null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
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"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
);
return (
<Modal
open={open}
footer={null}
width={isMobile ? '100vw' : 800}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'logmodal-mobile' : undefined}
onCancel={onClose}
title={titleNode}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Space.Compact>
<Select
value={rows}
size="small"
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1000' },
]}
/>
<Select
value={level}
size="small"
style={{ minWidth: 95 }}
onChange={setLevel}
options={[
{ value: 'debug', label: t('pages.index.logLevelDebug') },
{ value: 'info', label: t('pages.index.logLevelInfo') },
{ value: 'notice', label: t('pages.index.logLevelNotice') },
{ value: 'warning', label: t('pages.index.logLevelWarning') },
{ value: 'err', label: t('pages.index.logLevelError') },
]}
/>
</Space.Compact>
</Form.Item>
<Form.Item>
<Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
SysLog
</Checkbox>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</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) && (
<>
{(log.stamp || log.levelText) && <span> - </span>}
{log.service && <b>{log.service}</b>}
{log.service && log.body ? ' ' : ''}
<span>{log.body}</span>
</>
)}
</div>
))
)}
</div>
</Modal>
);
}
+27 -94
View File
@@ -1,35 +1,17 @@
import { Fragment } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag, Tooltip } from 'antd';
import {
ApiOutlined,
ArrowUpOutlined,
AreaChartOutlined,
BarsOutlined,
CloudDownloadOutlined,
CloudServerOutlined,
ControlOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version';
import type { Status } from '@/models/status';
interface OverviewActionBarProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
panelVersion: string;
latestVersion: string;
updateAvailable: boolean;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenAmneziaWGLogs: () => void;
onOpenConfig: () => void;
onOpenBackup: () => void;
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void;
}
@@ -42,74 +24,30 @@ interface BarAction {
}
export default function OverviewActionBar({
status,
isMobile,
accessLogEnable,
panelVersion,
latestVersion,
updateAvailable,
onOpenLogs,
onOpenXrayLogs,
onOpenAmneziaWGLogs,
onOpenConfig,
onOpenBackup,
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const size = isMobile ? ('small' as const) : ('middle' as const);
const actionGroups: BarAction[][] = [
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
...(accessLogEnable
? [
{
key: 'accessLogs',
icon: <FileTextOutlined />,
text: t('pages.index.accessLogs'),
onClick: onOpenXrayLogs,
},
]
: []),
...(status.amneziawg.configured
? [
{
key: 'amneziawgLogs',
icon: <ApiOutlined />,
text: t('pages.index.amneziawgLogs'),
onClick: onOpenAmneziaWGLogs,
},
]
: []),
{
key: 'config',
icon: <ControlOutlined />,
text: t('pages.index.config'),
onClick: onOpenConfig,
},
{
key: 'backup',
icon: <CloudServerOutlined />,
text: t('pages.index.backupTitle'),
onClick: onOpenBackup,
},
],
[
{
key: 'history',
icon: <AreaChartOutlined />,
text: t('pages.index.systemHistoryTitle'),
onClick: onOpenSystemHistory,
},
{
key: 'metrics',
icon: <ArrowUpOutlined />,
text: t('pages.index.xrayMetricsTitle'),
onClick: onOpenXrayMetrics,
},
],
const actions: BarAction[] = [
{
key: 'config',
icon: <ControlOutlined />,
text: t('pages.index.config'),
onClick: onOpenConfig,
},
{
key: 'backup',
icon: <CloudServerOutlined />,
text: t('pages.index.backupTitle'),
onClick: onOpenBackup,
},
];
return (
@@ -132,24 +70,19 @@ export default function OverviewActionBar({
)}
<div className="ov-bar-actions">
{actionGroups.map((group, groupIndex) => (
<Fragment key={group[0].key}>
{groupIndex > 0 && <span className="ov-bar-sep" />}
{group.map((action) => (
<Button
key={action.key}
type={action.primary ? undefined : 'text'}
color={action.primary ? 'primary' : undefined}
variant={action.primary ? 'outlined' : undefined}
size={size}
icon={action.icon}
aria-label={action.text}
onClick={action.onClick}
>
{isMobile ? undefined : action.text}
</Button>
))}
</Fragment>
{actions.map((action) => (
<Button
key={action.key}
type={action.primary ? undefined : 'text'}
color={action.primary ? 'primary' : undefined}
variant={action.primary ? 'outlined' : undefined}
size={size}
icon={action.icon}
aria-label={action.text}
onClick={action.onClick}
>
{isMobile ? undefined : action.text}
</Button>
))}
</div>
</div>
@@ -1,34 +0,0 @@
.metric-modal-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.bucket-select {
width: 80px;
}
.history-tabs {
margin-bottom: 4px;
}
.history-chart-title {
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
color: var(--ant-color-text);
}
.cpu-chart-wrap {
margin: 8px 8px 16px;
padding: 16px 18px 18px;
border-radius: 14px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--ant-color-primary) 6%, transparent),
transparent
);
border: 1px solid var(--ant-color-border-secondary);
box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
}
@@ -1,406 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Select, Tabs } from 'antd';
import {
ApiOutlined,
DashboardOutlined,
DatabaseOutlined,
DeploymentUnitOutlined,
GlobalOutlined,
HddOutlined,
LineChartOutlined,
PieChartOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
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;
tabKey?: string;
title: string;
icon: ReactNode;
valueMax: number | null;
unit: string;
stroke: string;
key2?: string;
stroke2?: string;
name1?: string;
name2?: string;
key3?: string;
stroke3?: string;
name3?: string;
}
const METRICS: MetricDef[] = [
{
key: 'cpu',
tab: 'CPU',
tabKey: 'pages.index.cpu',
title: 'pages.index.historyTitleCpu',
icon: <DashboardOutlined />,
valueMax: 100,
unit: '%',
stroke: '',
},
{
key: 'mem',
tab: 'RAM',
tabKey: 'pages.index.memory',
title: 'pages.index.historyTitleMem',
icon: <DatabaseOutlined />,
valueMax: 100,
unit: '%',
stroke: '#7c4dff',
key2: 'swap',
stroke2: '#ffa940',
name1: 'pages.index.memory',
name2: 'pages.index.swap',
},
{
key: 'netUp',
tab: 'Bandwidth',
tabKey: 'pages.index.historyTabBandwidth',
title: 'pages.index.historyTitleNetwork',
icon: <GlobalOutlined />,
valueMax: null,
unit: 'B/s',
stroke: '#1890ff',
key2: 'netDown',
stroke2: '#13c2c2',
name1: 'Up',
name2: 'Down',
},
{
key: 'pktUp',
tab: 'Packets',
tabKey: 'pages.index.historyTabPackets',
title: 'pages.index.historyTitlePackets',
icon: <DeploymentUnitOutlined />,
valueMax: null,
unit: 'pkt/s',
stroke: '#2f54eb',
key2: 'pktDown',
stroke2: '#36cfc9',
name1: 'Up',
name2: 'Down',
},
{
key: 'tcpCount',
tab: 'Connections',
tabKey: 'pages.index.historyTabConnections',
title: 'pages.index.historyTitleConnections',
icon: <ApiOutlined />,
valueMax: null,
unit: '',
stroke: '#597ef7',
key2: 'udpCount',
stroke2: '#73d13d',
name1: 'TCP',
name2: 'UDP',
},
{
key: 'diskRead',
tab: 'Disk I/O',
tabKey: 'pages.index.historyTabDisk',
title: 'pages.index.historyTitleDisk',
icon: <HddOutlined />,
valueMax: null,
unit: 'B/s',
stroke: '#eb2f96',
key2: 'diskWrite',
stroke2: '#722ed1',
name1: 'Read',
name2: 'Write',
},
{
key: 'diskUsage',
tab: 'Disk Usage',
tabKey: 'pages.index.historyTabDiskUsage',
title: 'pages.index.historyTitleDiskUsage',
icon: <PieChartOutlined />,
valueMax: 100,
unit: '%',
stroke: '#13c2c2',
},
{
key: 'online',
tab: 'Online',
tabKey: 'pages.index.historyTabOnline',
title: 'pages.index.historyTitleOnline',
icon: <TeamOutlined />,
valueMax: null,
unit: '',
stroke: '#52c41a',
},
{
key: 'load1',
tab: 'Load',
tabKey: 'pages.index.historyTabLoad',
title: 'pages.index.historyTitleLoad',
icon: <LineChartOutlined />,
valueMax: null,
unit: '',
stroke: '#fa8c16',
key2: 'load5',
stroke2: '#f5222d',
name1: '1m',
name2: '5m',
key3: 'load15',
stroke3: '#a0d911',
name3: '15m',
},
];
function unitFormatter(unit: string, activeKey: string): (v: number) => string {
if (unit === 'B/s') {
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)).replace(/\.\d+/, '')}/s`;
}
if (unit === 'pkt/s') {
return (v) => `${Math.round(Math.max(0, Number(v) || 0)).toLocaleString()}/s`;
}
if (unit === '%') {
return (v) => `${Number(v).toFixed(1)}%`;
}
return (v) => {
const n = Number(v) || 0;
if (activeKey === 'online' || activeKey === 'tcpCount' || activeKey === 'udpCount') {
return Math.round(n).toLocaleString();
}
return n.toFixed(2);
};
}
function formatFullTimestamp(unixSec: number): string {
const d = new Date(unixSec * 1000);
const today = new Date();
const sameDay =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const time = `${hh}:${mm}:${ss}`;
if (sameDay) return time;
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${MM}-${DD} ${time}`;
}
interface HistoryChart {
points: number[];
points2: number[];
points3: number[];
labels: string[];
timestamps: number[];
}
const EMPTY_CHART: HistoryChart = {
points: [],
points2: [],
points3: [],
labels: [],
timestamps: [],
};
async function loadBucket(metric: (typeof METRICS)[number], bucket: number): Promise<HistoryChart> {
try {
const msg = await HttpUtil.get(`/panel/api/server/history/${metric.key}/${bucket}`);
if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
const points: number[] = [];
const labels: string[] = [];
const timestamps: number[] = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labels.push(
bucket >= 2880
? `${MM}-${DD} ${hh}:${mm}`
: bucket >= 60
? `${hh}:${mm}`
: `${hh}:${mm}:${ss}`,
);
points.push(Number(p.v) || 0);
timestamps.push(Number(p.t) || 0);
}
const fetchAligned = async (key?: string): Promise<number[]> => {
if (!key) return [];
const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
if (!m?.success || !Array.isArray(m.obj)) return [];
const byTs = new Map<number, number>();
for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
return timestamps.map((ts) => byTs.get(ts) ?? 0);
};
return {
labels,
points,
timestamps,
points2: await fetchAligned(metric.key2),
points3: await fetchAligned(metric.key3),
};
} catch (e) {
console.error('Failed to fetch history bucket', e);
return EMPTY_CHART;
}
}
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, points2, points3, labels, timestamps }, setChart] =
useState<HistoryChart>(EMPTY_CHART);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n);
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
const yFormatter = useMemo(
() => unitFormatter(activeMetric?.unit ?? '', activeKey),
[activeMetric, activeKey],
);
const tsLookup = useMemo(() => {
const m = new Map<string, number>();
for (let i = 0; i < labels.length; i++) {
m.set(labels[i], timestamps[i]);
}
return m;
}, [labels, timestamps]);
const tooltipLabelFormatter = useCallback(
(label: string) => {
const ts = tsLookup.get(label);
return ts ? formatFullTimestamp(ts) : label;
},
[tsLookup],
);
const fetchBucket = useCallback(async () => {
if (!activeMetric) return;
const next = await loadBucket(activeMetric, bucket);
setChart(next);
}, [activeMetric, bucket]);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setActiveKey('cpu');
}
useEffect(() => {
if (!open || !activeMetric) return;
let cancelled = false;
void (async () => {
const next = await loadBucket(activeMetric, bucket);
if (!cancelled) setChart(next);
})();
return () => {
cancelled = true;
};
}, [open, activeMetric, bucket]);
useEffect(() => {
if (!open) return undefined;
const ms = bucket <= 30 ? 2000 : 10000;
const id = window.setInterval(() => fetchBucket(), ms);
return () => window.clearInterval(id);
}, [open, bucket, fetchBucket]);
return (
<Modal
open={open}
footer={null}
width={isMobile ? '95vw' : 900}
onCancel={onClose}
title={
<div className="metric-modal-title">
<span>{t('pages.index.systemHistoryTitle')}</span>
<Select
value={bucket}
size="small"
className="bucket-select"
onChange={setBucket}
options={[
{ value: 2, label: '2m' },
{ value: 60, label: '1h' },
{ value: 180, label: '3h' },
{ value: 360, label: '6h' },
{ value: 720, label: '12h' },
{ value: 1440, label: '24h' },
{ value: 2880, label: '2d' },
{ value: 10080, label: '7d' },
]}
/>
</div>
}
>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
size="small"
className="history-tabs"
items={METRICS.map((m) => {
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
return {
key: m.key,
label: isMobile ? (
<span title={tabLabel} aria-label={tabLabel}>
{m.icon}
</span>
) : (
tabLabel
),
};
})}
/>
<div className="cpu-chart-wrap">
{activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
<Sparkline
data={points}
data2={activeMetric?.key2 ? points2 : undefined}
data3={activeMetric?.key3 ? points3 : undefined}
stroke2={activeMetric?.stroke2}
stroke3={activeMetric?.stroke3}
name1={trName(activeMetric?.name1)}
name2={trName(activeMetric?.name2)}
name3={trName(activeMetric?.name3)}
labels={labels}
height={260}
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}
tooltipLabelFormatter={tooltipLabelFormatter}
extrema={{ show: !activeMetric?.key2, formatter: yFormatter }}
/>
</div>
</Modal>
);
}
+2 -53
View File
@@ -1,49 +1,26 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
import { mean, peak } from './useOverviewHistory';
interface ThroughputCardProps {
status: Status;
up: number[];
down: number[];
labels: string[];
isMobile: boolean;
}
export default function ThroughputCard({
status,
up,
down,
labels,
isMobile,
}: ThroughputCardProps) {
export default function ThroughputCard({ status }: ThroughputCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const downColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.netIO.down, color: downColor, dash: '2 4' },
{ y: status.netIO.up, color: accent, dash: '2 4' },
],
[status.netIO.up, status.netIO.down, accent, downColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head">
<div>
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
<div className="ov-sub">
{`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
</div>
<div className="ov-sub">{t('pages.index.throughputSub')}</div>
</div>
<div className="ov-wide-legend">
<div className="ov-legend-label">
@@ -59,26 +36,6 @@ export default function ThroughputCard({
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={up}
data2={down}
labels={labels}
height={isMobile ? 140 : 186}
strokeWidth={1.75}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={downColor}
name1={t('pages.index.upload')}
name2={t('pages.index.download')}
yFormatter={SizeFormatter.speedFormat}
referenceLines={referenceLines}
/>
</div>
<div className="ov-wide-foot">
<div>
<div className="ov-kicker">{t('pages.index.sent')}</div>
@@ -89,14 +46,6 @@ export default function ThroughputCard({
<div className="ov-kicker">{t('pages.index.received')}</div>
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
</div>
<span className="ov-foot-sep" />
<div>
<div className="ov-kicker">{t('pages.index.avgWindow')}</div>
<div className="ov-foot-value">
<span className="ov-foot-part">{`↑ ${SizeFormatter.speedFormat(mean(up))}`}</span>{' '}
<span className="ov-foot-part">{`↓ ${SizeFormatter.speedFormat(mean(down))}`}</span>
</div>
</div>
</div>
</Card>
);
+8 -47
View File
@@ -1,9 +1,5 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import { mean, peak } from './useOverviewHistory';
import { Card } from 'antd';
interface VitalTileProps {
icon: ReactNode;
@@ -11,31 +7,11 @@ interface VitalTileProps {
percent: number;
statusColor: string;
detail: string;
footLeft: string;
footRight: string;
data: number[];
footLeft?: string;
isMobile: boolean;
}
export default function VitalTile({
icon,
label,
percent,
statusColor,
detail,
footLeft,
footRight,
data,
isMobile,
}: VitalTileProps) {
const { token } = theme.useToken();
const meanColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
[data, meanColor],
);
export default function VitalTile({ icon, label, percent, detail, footLeft }: VitalTileProps) {
return (
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
<div className="ov-tile-head">
@@ -50,26 +26,11 @@ export default function VitalTile({
<div className="ov-tile-detail">{detail}</div>
<div className="ov-tile-foot">
<span>{footLeft}</span>
<span>{footRight}</span>
</div>
<div className="ov-tile-chart">
<Sparkline
data={data}
height={isMobile ? 48 : 62}
strokeWidth={1.5}
fillOpacity={0.3}
showGrid={false}
showMarker={false}
valueMax={peak(data) > 0 ? null : 100}
stroke={statusColor}
referenceLines={referenceLines}
yFormatter={(v) => `${v.toFixed(0)}%`}
name1={label}
/>
</div>
{footLeft ? (
<div className="ov-tile-foot">
<span>{footLeft}</span>
</div>
) : null}
</Card>
);
}
-151
View File
@@ -1,151 +0,0 @@
.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 var(--ant-color-border);
border-radius: 6px;
background: var(--ant-color-fill-tertiary);
color: var(--ant-color-text);
}
.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 {
--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 .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);
}
-295
View File
@@ -1,295 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
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 { activateOnKey } from '@/utils/a11y';
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;
}
// The downloaded log is a data format people grep, so it keeps the stable
// tokens; only what is rendered on screen follows the panel language.
const EVENT_TOKENS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
const EVENT_KEYS: Record<number, string> = {
0: 'pages.index.accessDirect',
1: 'pages.index.accessBlocked',
2: 'pages.index.accessProxy',
};
const EVENT_COLORS: Record<number, string> = { 0: 'green', 1: 'red', 2: 'blue' };
function eventToken(ev?: number): string {
return EVENT_TOKENS[ev ?? -1] ?? String(ev ?? '');
}
function eventLabel(t: TFunction, ev?: number): string {
const key = EVENT_KEYS[ev ?? -1];
return key ? t(key) : 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}`;
}
const AUTO_UPDATE_INTERVAL = 5000;
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 [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<XrayLogEntry[]>([]);
const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<XrayLogEntry[]>(`/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]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open
? `${rows}\u0000${showDirect}\u0000${showBlocked}\u0000${showProxy}`
: null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, rows, showDirect, showBlocked, showProxy, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
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 = eventToken(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}
footer={null}
width={isMobile ? '100vw' : '80vw'}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'xraylog-modal-mobile' : undefined}
onCancel={onClose}
title={
<>
{t('pages.index.accessLogs')}
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Select
value={rows}
size="small"
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1000' },
]}
/>
</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>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</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(t, log.Event)}
</Tag>
</div>
<div className="log-route">
<span className="log-addr">{log.FromAddress}</span>
<span className="log-arrow">→</span>
<span className="log-addr">{log.ToAddress}</span>
</div>
<div className="log-meta">
{log.Inbound && (
<span className="log-meta-pair">
<span className="log-meta-key">in</span>
<span className="log-meta-val">{log.Inbound}</span>
</span>
)}
{log.Outbound && (
<span className="log-meta-pair">
<span className="log-meta-key">out</span>
<span className="log-meta-val">{log.Outbound}</span>
</span>
)}
{log.Email && (
<span className="log-meta-pair">
<span className="log-meta-key">email</span>
<span className="log-meta-val">{log.Email}</span>
</span>
)}
</div>
</div>
))
) : (
<table className="xraylog-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Inbound</th>
<th>Outbound</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{orderedLogs.map((log, idx) => (
<tr key={idx} className={`log-row-${log.Event}`}>
<td>
<b>{fullDate(log.DateTime)}</b>
</td>
<td>{log.FromAddress}</td>
<td>{log.ToAddress}</td>
<td>{log.Inbound}</td>
<td>{log.Outbound}</td>
<td>{log.Email}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Modal>
);
}
@@ -1,71 +0,0 @@
.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;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
}
.obs-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 18%, transparent);
}
.obs-dot.is-alive {
background: var(--ant-color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
animation: obs-dot-pulse 2.2s ease-in-out infinite;
}
.obs-dot.is-dead {
background: var(--ant-color-error);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-error) 22%, transparent);
}
@keyframes obs-dot-pulse {
0%,
100% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--ant-color-success) 6%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.obs-dot.is-alive {
animation: none;
}
}
@@ -1,428 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Tabs, Tag } from 'antd';
import {
BlockOutlined,
CloudServerOutlined,
DatabaseOutlined,
DeleteOutlined,
EyeOutlined,
PauseCircleOutlined,
} from '@ant-design/icons';
import { HttpUtil, Msg, SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import './XrayMetricsModal.css';
const OBS_KEY = 'xrObs';
interface XrayMetricsModalProps {
open: boolean;
onClose: () => void;
}
interface MetricDef {
key: string;
tab: string;
tabKey: string;
title: string;
icon: ReactNode;
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',
tabKey: 'pages.index.xrayTabHeap',
title: 'pages.index.xrayTitleHeap',
icon: <DatabaseOutlined />,
unit: 'B',
stroke: '#7c4dff',
},
{
key: 'xrSys',
tab: 'Sys',
tabKey: 'pages.index.xrayTabSys',
title: 'pages.index.xrayTitleSys',
icon: <CloudServerOutlined />,
unit: 'B',
stroke: '#1890ff',
},
{
key: 'xrHeapObjects',
tab: 'Objects',
tabKey: 'pages.index.xrayTabObjects',
title: 'pages.index.xrayTitleObjects',
icon: <BlockOutlined />,
unit: '',
stroke: '#13c2c2',
},
{
key: 'xrNumGC',
tab: 'GC Count',
tabKey: 'pages.index.xrayTabGcCount',
title: 'pages.index.xrayTitleGcCount',
icon: <DeleteOutlined />,
unit: '',
stroke: '#fa8c16',
},
{
key: 'xrPauseNs',
tab: 'GC Pause',
tabKey: 'pages.index.xrayTabGcPause',
title: 'pages.index.xrayTitleGcPause',
icon: <PauseCircleOutlined />,
unit: 'ns',
stroke: '#f5222d',
},
{
key: OBS_KEY,
tab: 'Observatory',
tabKey: 'pages.index.xrayTabObservatory',
title: 'pages.index.xrayTitleObservatory',
icon: <EyeOutlined />,
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}`;
}
function formatFullTimestamp(unixSec: number): string {
const d = new Date(unixSec * 1000);
const today = new Date();
const sameDay =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const time = `${hh}:${mm}:${ss}`;
if (sameDay) return time;
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${MM}-${DD} ${time}`;
}
interface MetricsChart {
points: number[];
labels: string[];
timestamps: number[];
}
const EMPTY_CHART: MetricsChart = { points: [], labels: [], timestamps: [] };
function toChart(msg: Msg<{ t: number; v: number }[]> | null | undefined, bucket: number) {
if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
const points: number[] = [];
const labels: string[] = [];
const timestamps: number[] = [];
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');
labels.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
points.push(Number(p.v) || 0);
timestamps.push(Number(p.t) || 0);
}
return { points, labels, timestamps };
}
async function loadHistory(url: string | null, bucket: number): Promise<MetricsChart> {
if (!url) return EMPTY_CHART;
try {
return toChart(await HttpUtil.get<{ t: number; v: number }[]>(url), bucket);
} catch (e) {
console.error('Failed to fetch xray metrics bucket', e);
return EMPTY_CHART;
}
}
async function loadState(): Promise<XrayState | null> {
try {
const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
return msg?.success && msg.obj ? msg.obj : null;
} catch (e) {
console.error('Failed to fetch xray metrics state', e);
return null;
}
}
async function loadObservatory(): Promise<ObservatoryTag[]> {
try {
const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
return msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
} catch (e) {
console.error('Failed to fetch observatory snapshot', e);
return [];
}
}
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, labels, timestamps }, setChart] = useState<MetricsChart>(EMPTY_CHART);
const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
const [obsActiveTag, setObsActiveTag] = useState('');
const [obsTick, setObsTick] = useState(0);
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 tsLookup = useMemo(() => {
const m = new Map<string, number>();
for (let i = 0; i < labels.length; i++) {
m.set(labels[i], timestamps[i]);
}
return m;
}, [labels, timestamps]);
const tooltipLabelFormatter = useCallback(
(label: string) => {
const ts = tsLookup.get(label);
return ts ? formatFullTimestamp(ts) : label;
},
[tsLookup],
);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setActiveKey('xrAlloc');
}
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
const next = await loadState();
if (!cancelled && next) setState(next);
})();
return () => {
cancelled = true;
};
}, [open]);
// The observatory snapshot is a live view, so it re-polls; obsTick then pulls
// the chart along with it.
useEffect(() => {
if (!open || !isObservatory) return;
let cancelled = false;
const tick = async () => {
const tags = await loadObservatory();
if (cancelled) return;
setObsTags(tags);
setObsActiveTag((prev) => (tags.find((tg) => tg.tag === prev) ? prev : tags[0]?.tag || ''));
setObsTick((n) => n + 1);
};
void tick();
const id = window.setInterval(() => void tick(), 2000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [open, isObservatory]);
const historyUrl = isObservatory
? obsActiveTag
? `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`
: null
: activeMetric
? `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`
: null;
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
const next = await loadHistory(historyUrl, bucket);
if (!cancelled) setChart(next);
})();
return () => {
cancelled = true;
};
}, [open, historyUrl, bucket, obsTick]);
return (
<Modal
open={open}
footer={null}
width={isMobile ? '95vw' : 900}
onCancel={onClose}
title={
<div className="metric-modal-title">
<span>{t('pages.index.xrayMetricsTitle')}</span>
<Select
value={bucket}
size="small"
className="bucket-select"
onChange={setBucket}
options={[
{ value: 2, label: '2m' },
{ value: 60, label: '1h' },
{ value: 180, label: '3h' },
{ value: 360, label: '6h' },
{ value: 720, label: '12h' },
]}
/>
</div>
}
>
{!state.enabled && (
<Alert
type="warning"
showIcon
className="metrics-alert"
title={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) => {
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
return {
key: m.key,
label: isMobile ? (
<span title={tabLabel} aria-label={tabLabel}>
{m.icon}
</span>
) : (
tabLabel
),
};
})}
/>
{isObservatory && (
<div className="obs-pane">
{state.enabled && obsTags.length === 0 ? (
<Alert
type="info"
showIcon
className="metrics-alert"
title={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">
{activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
<Sparkline
data={points}
labels={labels}
height={260}
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}
tooltipLabelFormatter={tooltipLabelFormatter}
extrema={{ show: true, formatter: yFormatter }}
/>
</div>
</Modal>
);
}
-119
View File
@@ -1,119 +0,0 @@
// Parser for the panel log viewer. Logs reach the UI in two shapes:
//
// - App log (SysLog off): the in-memory buffer, formatted as
// "2006/01/02 15:04:05 LEVEL - message"
// - SysLog (journalctl -o short): every entry is prefixed with
// "Mon DD HH:MM:SS host ident[pid]: " before the real message, and the
// message itself is one of several shapes depending on which subsystem
// emitted it:
// "INFO - mtproto: ..." go-logging (x-ui + xray)
// "2026/06/08 19:22:22 http: ..." Go std log (net/http, runtime)
// "[Mon Jun 8 23:56:52 UTC 2026] ERROR ..." telego bot
// "Stopping x-ui.service - ..." systemd
//
// parseLogLine normalises all of these into a stamp + level + service + body so
// the viewer renders a readable line instead of a bare timestamp.
export interface ParsedLog {
date: string;
time: string;
stamp: string;
levelText: string;
levelClass: string;
service: string;
body: string;
}
export const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
export const LEVEL_CLASSES = [
'level-debug',
'level-info',
'level-notice',
'level-warning',
'level-error',
];
// "Mon DD HH:MM:SS host ident[pid]: <message>" — captures the journal date,
// time, and the message that follows the syslog identifier.
const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S+?:\s+(.*)$/;
// Redundant Go std-log date prefix ("2006/01/02 15:04:05 ") to strip — the
// journal already carries the timestamp.
const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
// App-log format emitted by the in-memory buffer:
// "2006/01/02 15:04:05 LEVEL - message". Only a line matching this exact shape
// carries a structured timestamp/level; anything else (e.g. a plain notice such
// as the Windows "Syslog is not supported" message) is kept whole as the body.
const APP_LOG = /^(\d{4}\/\d{2}\/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\S+)\s+-\s+([\s\S]*)$/;
// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
// and the remainder. Returns null when the message does not start with a level.
function splitLevelDash(message: string): { level: string; rest: string } | null {
const dash = message.indexOf(' - ');
if (dash < 0) return null;
const level = message.slice(0, dash).trim();
if (LEVELS.indexOf(level) < 0) return null;
return { level, rest: message.slice(dash + 3) };
}
export function parseLogLine(line: string): ParsedLog {
const raw = (line || '').trim();
let date = '';
let time = '';
let levelText = '';
let body: string;
const sys = raw.match(SYSLOG_PREFIX);
if (sys) {
date = sys[1];
time = sys[2];
let message = sys[3];
const ld = splitLevelDash(message);
if (ld) {
// go-logging: "LEVEL - message"
levelText = ld.level;
body = ld.rest;
} else {
// Strip the redundant Go std-log date, then try to lift a level out of a
// telego "[timestamp] LEVEL ..." line; otherwise keep the message as-is.
message = message.replace(GO_LOG_DATE, '');
const tg = message.match(TELEGO);
if (tg && LEVELS.indexOf(tg[1]) >= 0) {
levelText = tg[1];
body = tg[2];
} else {
body = message;
}
}
} else {
const app = raw.match(APP_LOG);
if (app) {
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
date = app[1];
time = app[2];
levelText = app[3];
body = app[4];
} else {
// Plain message with no timestamp/level — show it verbatim.
body = raw;
}
}
const li = LEVELS.indexOf(levelText);
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
let service = '';
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 };
}
@@ -1,150 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { HttpUtil, TimeFormatter } from '@/utils';
import type { Status } from '@/models/status';
const OVERVIEW_WINDOW = 72;
const SEED_BUCKET_SECONDS = 2;
const SERIES_KEYS = [
'cpu',
'mem',
'swap',
'diskUsage',
'netUp',
'netDown',
'tcpCount',
'udpCount',
] as const;
export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
export interface OverviewHistory {
series: Record<OverviewSeriesKey, number[]>;
labels: string[];
}
interface HistoryPoint {
t: number;
v: number;
}
interface HistoryWindow {
series: Record<OverviewSeriesKey, number[]>;
times: number[];
}
function emptySeries(): Record<OverviewSeriesKey, number[]> {
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<
OverviewSeriesKey,
number[]
>;
}
function emptyWindow(): HistoryWindow {
return { series: emptySeries(), times: [] };
}
function sampleOf(status: Status): Record<OverviewSeriesKey, number> {
return {
cpu: status.cpu.percent,
mem: status.mem.percent,
swap: status.swap.percent,
diskUsage: status.disk.percent,
netUp: status.netIO.up,
netDown: status.netIO.down,
tcpCount: status.tcpCount,
udpCount: status.udpCount,
};
}
function tailWindow<T>(values: T[]): T[] {
return values.slice(-OVERVIEW_WINDOW);
}
export function mean(values: number[]): number {
if (values.length === 0) return 0;
let total = 0;
for (const v of values) total += v;
return total / values.length;
}
export function peak(values: number[]): number {
let max = 0;
for (const v of values) if (v > max) max = v;
return max;
}
/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
2s is the smallest and matches the status poll cadence */
export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
useEffect(() => {
let cancelled = false;
const seed = async () => {
const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
await Promise.all(
SERIES_KEYS.map(async (key) => {
const msg = await HttpUtil.get<HistoryPoint[]>(
`/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
undefined,
{ silent: true },
);
if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
}),
);
if (cancelled || responses.size === 0) return;
let axis: HistoryPoint[] = [];
for (const points of responses.values()) {
if (points.length > axis.length) axis = points;
}
axis = tailWindow(axis);
if (axis.length === 0) return;
const seedTimes = axis.map((p) => Number(p.t) || 0);
const seedSeries = emptySeries();
for (const key of SERIES_KEYS) {
const byTs = new Map<number, number>();
for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
}
setTrend((prev) => {
const merged = emptyWindow();
merged.times = tailWindow(seedTimes.concat(prev.times));
for (const key of SERIES_KEYS) {
merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
}
return merged;
});
};
seed().catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
// Each polled status is appended during render; an effect would show the
// chart one sample behind the numbers beside it.
const [sampledStatus, setSampledStatus] = useState<Status | null>(null);
if (hasData && status !== sampledStatus) {
setSampledStatus(status);
setTrend((prev) => {
const point = sampleOf(status);
const next = emptyWindow();
next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
for (const key of SERIES_KEYS) {
next.series[key] = tailWindow(prev.series[key].concat(point[key]));
}
return next;
});
}
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
}
+245 -123
View File
@@ -40,6 +40,7 @@ function defaultValues(): NodeFormValues {
id: 0,
name: '',
remark: '',
nodeType: 'traditional',
scheme: 'https',
address: '',
port: 2053,
@@ -76,9 +77,14 @@ export default function NodeFormModal({
const [fetchingInbounds, setFetchingInbounds] = useState(false);
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const [agentToken, setAgentToken] = useState<string | null>(null);
const [agentNodeName, setAgentNodeName] = useState('');
const [agentMasterUrl, setAgentMasterUrl] = useState('');
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
const isAgent = nodeType === 'agent';
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -138,13 +144,16 @@ export default function NodeFormModal({
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
const token = values.apiToken.trim();
const agentMode = values.nodeType === 'agent';
const payload: Partial<NodeRecord> = {
id: values.id || 0,
name: values.name.trim(),
remark: values.remark?.trim() || '',
nodeType: values.nodeType,
scheme: values.scheme,
address: values.address.trim(),
port: values.port,
// Agent nodes have no panel address; the backend ignores these.
address: agentMode ? 'agent' : values.address.trim(),
port: agentMode ? 2053 : values.port,
basePath: values.basePath.trim() || '/',
enable: values.enable,
allowPrivateAddress: values.allowPrivateAddress,
@@ -217,18 +226,34 @@ export default function NodeFormModal({
setSubmitting(true);
try {
const payload = buildPayload(result.data);
const test = await testConnection(payload);
const probe = test?.success ? test.obj : null;
if (!probe || probe.status !== 'online') {
setTestResult(
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
);
return;
const agentMode = result.data.nodeType === 'agent';
// Agent nodes have no panel to probe; skip the connection test.
if (!agentMode) {
const test = await testConnection(payload);
const probe = test?.success ? test.obj : null;
if (!probe || probe.status !== 'online') {
setTestResult(
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
);
return;
}
setTestResult(probe);
}
setTestResult(probe);
const msg = await save(payload);
if (msg?.success) {
onOpenChange(false);
// Backend returns { node, agentToken, nodeType } for agent nodes.
const obj = msg.obj as unknown as
| { agentToken?: string; node?: { id?: number } }
| undefined;
if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim());
// Default master URL to the current panel origin; user can edit it.
setAgentMasterUrl(window.location.origin);
// Keep the modal open state; the token dialog shows on top.
} else {
onOpenChange(false);
}
}
} finally {
setSubmitting(false);
@@ -255,6 +280,27 @@ export default function NodeFormModal({
>
<FormProvider {...methods}>
<Form layout="vertical">
<Row gutter={16}>
<Col xs={24}>
<FormField label={t('pages.nodes.nodeType')} name="nodeType">
<Select
disabled={mode === 'edit'}
options={[
{ value: 'traditional', label: t('pages.nodes.nodeTypeTraditional') },
{ value: 'agent', label: t('pages.nodes.nodeTypeAgent') },
]}
/>
</FormField>
</Col>
</Row>
{isAgent && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.agentHint')}
/>
)}
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField
@@ -272,80 +318,97 @@ export default function NodeFormModal({
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.scheme')}
name="scheme"
onAfterChange={(value) => {
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
}}
>
<Select
options={[
{ value: 'https', label: 'https' },
{ value: 'http', label: 'http' },
]}
/>
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.address')}
name="address"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
>
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
</FormField>
</Col>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.port')}
name="port"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</Col>
</Row>
{!isAgent && (
<Row gutter={16}>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.scheme')}
name="scheme"
onAfterChange={(value) => {
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
}}
>
<Select
options={[
{ value: 'https', label: 'https' },
{ value: 'http', label: 'http' },
]}
/>
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.address')}
name="address"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
>
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
</FormField>
</Col>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.port')}
name="port"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</Col>
</Row>
)}
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.basePath')} name="basePath">
<Input placeholder="/" />
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
</Row>
{!isAgent && (
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.basePath')} name="basePath">
<Input placeholder="/" />
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
</Row>
)}
{isAgent && (
<Row gutter={16}>
<Col xs={24}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
</Row>
)}
<FormField
label={t('pages.nodes.allowPrivateAddress')}
name="allowPrivateAddress"
valueProp="checked"
tooltip={t('pages.nodes.allowPrivateAddressHint')}
>
<Switch />
</FormField>
{!isAgent && (
<FormField
label={t('pages.nodes.allowPrivateAddress')}
name="allowPrivateAddress"
valueProp="checked"
tooltip={t('pages.nodes.allowPrivateAddressHint')}
>
<Switch />
</FormField>
)}
<FormField
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
tooltip={t('pages.nodes.tlsVerifyModeHint')}
>
<Select
disabled={scheme === 'http'}
options={[
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
]}
/>
</FormField>
{!isAgent && (
<FormField
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
tooltip={t('pages.nodes.tlsVerifyModeHint')}
>
<Select
disabled={scheme === 'http'}
options={[
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
]}
/>
</FormField>
)}
{tlsVerifyMode === 'skip' && (
<Alert
@@ -380,21 +443,23 @@ export default function NodeFormModal({
</FormField>
)}
<FormField
label={t('pages.nodes.apiToken')}
name="apiToken"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
tooltip={t('pages.nodes.apiTokenHint')}
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
>
<Input.Password
placeholder={
editingWithToken
? t('pages.nodes.apiTokenKeepHint')
: t('pages.nodes.apiTokenPlaceholder')
}
/>
</FormField>
{!isAgent && (
<FormField
label={t('pages.nodes.apiToken')}
name="apiToken"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
tooltip={t('pages.nodes.apiTokenHint')}
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
>
<Input.Password
placeholder={
editingWithToken
? t('pages.nodes.apiTokenKeepHint')
: t('pages.nodes.apiTokenPlaceholder')
}
/>
</FormField>
)}
<FormField
label={t('pages.nodes.outboundTag')}
@@ -455,35 +520,92 @@ export default function NodeFormModal({
</FormField>
)}
<div className="test-row">
<Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')}
</Button>
{testResult && (
<div className="test-result">
{testResult.status === 'online' ? (
<Alert
type="success"
showIcon
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
description={
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
}
/>
) : (
<Alert
type="error"
showIcon
title={t('pages.nodes.connectionFailed')}
description={testResult.error}
/>
)}
</div>
)}
</div>
{!isAgent && (
<div className="test-row">
<Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')}
</Button>
{testResult && (
<div className="test-result">
{testResult.status === 'online' ? (
<Alert
type="success"
showIcon
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
description={
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
}
/>
) : (
<Alert
type="error"
showIcon
title={t('pages.nodes.connectionFailed')}
description={testResult.error}
/>
)}
</div>
)}
</div>
)}
</Form>
</FormProvider>
</Modal>
{/* Agent install command - shown once after creating an agent node */}
<Modal
open={agentToken !== null}
title={t('pages.nodes.agentTokenTitle')}
width={640}
footer={[
<Button
key="copy"
type="primary"
onClick={() => {
const cmd = `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken} --name ${agentNodeName}`;
navigator.clipboard.writeText(cmd).then(
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
);
}}
>
{t('pages.nodes.copyToken')}
</Button>,
<Button
key="close"
onClick={() => {
setAgentToken(null);
onOpenChange(false);
}}
>
{t('close')}
</Button>,
]}
onCancel={() => {
setAgentToken(null);
onOpenChange(false);
}}
>
<Alert
type="success"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.agentTokenCreated', { name: agentNodeName })}
/>
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
<Input
value={agentMasterUrl}
onChange={(e) => setAgentMasterUrl(e.target.value.trim())}
style={{ marginBottom: 12, fontFamily: 'monospace' }}
/>
<p>{t('pages.nodes.agentTokenHint')}</p>
<Input.TextArea
value={`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken} --name ${agentNodeName}`}
readOnly
rows={4}
style={{ fontFamily: 'monospace' }}
/>
<p style={{ marginTop: 12 }}>{t('pages.nodes.agentTokenInstructions')}</p>
</Modal>
</>
);
}
@@ -112,11 +112,12 @@ export default function SubscriptionGeneralTab({
description={t('pages.settings.subPortDesc')}
>
<InputNumber
value={allSetting.subPort}
min={1}
value={allSetting.subPort || null}
min={0}
max={65535}
placeholder="2096"
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subPort: v }))}
onChange={onNumber((v) => updateSetting({ subPort: v }), { onClear: 0 })}
/>
</SettingListItem>
<SettingListItem
+27 -2
View File
@@ -44,6 +44,9 @@ export const NodeRecordSchema = z
guid: z.string().optional(),
parentGuid: z.string().optional(),
transitive: z.boolean().optional(),
// P7: node kind, annotated by the backend for nodes registered by an
// agent. Absent/traditional for classic panel-to-panel nodes.
nodeType: z.enum(['traditional', 'agent']).optional(),
})
.loose();
@@ -66,12 +69,19 @@ export const NodeFormSchema = z
id: z.number().optional(),
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
remark: z.string().optional(),
// Node type: traditional panel-to-panel, or lightweight agent.
// Agent nodes skip the panel probe and API token; an agent token is
// issued on creation instead.
nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'),
scheme: z.enum(['http', 'https']),
address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
port: z.number().int().min(1).max(65535),
// For agent nodes the panel never dials the node (agent dials out),
// so address/port are not needed. Required for traditional nodes.
address: z.string().trim().optional().default(''),
port: z.number().int().min(1).max(65535).optional(),
basePath: z.string(),
// mTLS nodes authenticate via the client certificate, so the token is optional
// there; every other verify mode still requires one (matches remote.do()).
// Agent nodes use an agent token instead of a panel API token.
apiToken: z.string().trim(),
hasStoredToken: z.boolean().optional().default(false),
enable: z.boolean(),
@@ -88,6 +98,21 @@ export const NodeFormSchema = z
outboundTag: z.string().optional(),
})
.superRefine((val, ctx) => {
if (val.nodeType === 'agent') return; // no panel credentials needed
if (!val.address || val.address.trim().length === 0) {
ctx.addIssue({
code: 'custom',
path: ['address'],
message: 'pages.nodes.toasts.fillRequired',
});
}
if (val.port === undefined) {
ctx.addIssue({
code: 'custom',
path: ['port'],
message: 'pages.nodes.toasts.fillRequired',
});
}
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
ctx.addIssue({
code: 'custom',
+3 -1
View File
@@ -51,7 +51,9 @@ export const AllSettingSchema = z
subIncyEnableRouting: z.boolean().optional(),
subIncyRoutingRules: z.string().optional(),
subListen: z.string().optional(),
subPort: port.optional(),
// 0 = unset: subscription links are generated without a port (the
// service still binds the default port). See P7 report, fix 6.
subPort: z.number().int().min(0).max(65535).optional(),
subPath: absolutePath.optional(),
subJsonPath: absolutePath.optional(),
subClashEnable: z.boolean().optional(),
-82
View File
@@ -1,82 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseLogLine } from '@/pages/index/logParse';
// Fixtures are real lines captured from `journalctl -u x-ui` on a production
// host (the SysLog view) plus the in-memory app-log format. Each journald entry
// carries a "Mon DD HH:MM:SS host ident[pid]: " prefix that the viewer used to
// mistake for the level, leaving only a bare timestamp on screen.
describe('parseLogLine — SysLog (journalctl) formats', () => {
it('x-ui go-logging line: keeps level, strips prefix, tags X-UI', () => {
const r = parseLogLine(
'Jun 08 23:57:28 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443',
);
expect(r.stamp).toBe('Jun 08 23:57:28');
expect(r.levelText).toBe('INFO');
expect(r.service).toBe('X-UI:');
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
});
it('xray go-logging line: lifts the XRAY service tag', () => {
const r = parseLogLine(
'Jun 08 23:56:52 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: WARNING - XRAY: core: Xray 26.6.1 started',
);
expect(r.stamp).toBe('Jun 08 23:56:52');
expect(r.levelText).toBe('WARNING');
expect(r.service).toBe('XRAY:');
expect(r.body).toBe('core: Xray 26.6.1 started');
});
it('Go std-log line: strips the redundant embedded date, keeps the message', () => {
const r = parseLogLine(
'Jun 08 19:22:22 ubuntu-4gb-fsn1-1 x-ui[1439]: 2026/06/08 19:22:22 http: TLS handshake error from 18.97.5.1:36022: EOF',
);
expect(r.stamp).toBe('Jun 08 19:22:22');
expect(r.levelText).toBe('');
expect(r.body).toBe('http: TLS handshake error from 18.97.5.1:36022: EOF');
});
it('telego bracketed line: lifts the ERROR level out of "[ts] ERROR ..."', () => {
const r = parseLogLine(
'Jun 09 00:14:52 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun 9 00:14:52 UTC 2026] ERROR Retrying getting updates in 8s...',
);
expect(r.stamp).toBe('Jun 09 00:14:52');
expect(r.levelText).toBe('ERROR');
expect(r.body).toBe('Retrying getting updates in 8s...');
});
it('systemd line: shows the body rather than a bare timestamp', () => {
const r = parseLogLine(
'Jun 08 23:56:47 ubuntu-4gb-fsn1-1 systemd[1]: Stopping x-ui.service - x-ui Service...',
);
expect(r.stamp).toBe('Jun 08 23:56:47');
expect(r.body).toBe('Stopping x-ui.service - x-ui Service...');
});
it('never collapses a journald entry to just its timestamp', () => {
const r = parseLogLine(
'Jun 09 00:15:00 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun 9 00:15:00 UTC 2026] ERROR Getting updates: telego: getUpdates: api: 409 "Conflict"',
);
expect(r.body.length).toBeGreaterThan(0);
expect(r.body).toContain('Conflict');
});
});
describe('parseLogLine — app-log format (SysLog off)', () => {
it('parses "YYYY/MM/DD HH:MM:SS LEVEL - body"', () => {
const r = parseLogLine(
'2026/06/09 00:35:09 INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443',
);
expect(r.date).toBe('2026/06/09');
expect(r.time).toBe('00:35:09');
expect(r.levelText).toBe('INFO');
expect(r.service).toBe('X-UI:');
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
});
it('handles an empty line without throwing', () => {
const r = parseLogLine('');
expect(r.stamp).toBe('');
expect(r.body).toBe('');
});
});
+9
View File
@@ -20,4 +20,13 @@ describe('onNumber', () => {
handler(NaN);
expect(apply).not.toHaveBeenCalled();
});
it('applies the declared onClear value for cleared events when opted in', () => {
const apply = vi.fn();
const handler = onNumber(apply, { onClear: 0 });
handler(2096);
handler(null);
handler(undefined);
expect(apply.mock.calls).toEqual([[2096], [0], [0]]);
});
});
+14 -2
View File
@@ -5,12 +5,24 @@
* input snaps back on blur — while real numbers pass through unchanged.
* Number-only by design: not for `stringMode` inputs, whose whole point is
* to avoid the IEEE-754 round trip this signature would force.
*
* Fields whose cleared state is itself meaningful (a stored value, not
* "no change") opt in explicitly via `onClear`: clearing then applies
* that value instead of being ignored. Declaring the exception here —
* rather than as an ad-hoc fallback in the field's onChange — is what
* the input-number lint guard protects (see #6127). Currently used by
* the subscription listen port, where cleared means 0 = "no port in
* generated links" (P7).
*/
export function onNumber(
apply: (value: number) => void,
options?: { onClear?: number },
): (value: number | null | undefined) => void {
return (value) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return;
apply(value);
if (typeof value === 'number' && Number.isFinite(value)) {
apply(value);
return;
}
if (options?.onClear !== undefined) apply(options.onClear);
};
}