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:
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user